如何寫出高效能程式碼之優化記憶體回收(GC)

xindoo發表於2022-05-03

導語

  同一份邏輯,不同人的實現的程式碼效能會出現數量級的差異; 同一份程式碼,你可能微調幾個字元或者某行程式碼的順序,就會有數倍的效能提升;同一份程式碼,也可能在不同處理器上執行也會有幾倍的效能差異;十倍程式設計師 不是隻存在於傳說中,可能在我們的周圍也比比皆是。十倍體現在程式設計師的方法面面,而程式碼效能卻是其中最直觀的一面。
  本文是《如何寫出高效能程式碼》系列的第三篇,本文將告訴你如何寫出GC更優的程式碼,以達到提升程式碼效能的目的

優化記憶體回收

  垃圾回收GC(Garbage Collection)是現在高階程式語言記憶體回收的主要手段,也是高階語言所必備的特性,比如大家所熟知的Java、python、go都是自帶GC的,甚至是連C++ 也開始有了GC的影子。GC可以自動清理掉那些不用的垃圾物件,釋放記憶體空間,這個特性對新手程式猿極其友好,反觀沒有GC機制的語言,比如C++,程式猿需要自己去管理和釋放記憶體,很容易出現記憶體洩露的bug,這也是C++的上手難度遠高於很多語言的原因之一。
  GC的出現降低了程式語言上手的難度,但是過度依賴於GC也會影響你程式的效能。這裡就不得不提到一個臭名昭著的詞——STW(stop the world) ,它的含義就是應用程式暫停所有的工作,把時間都讓出來讓給GC執行緒去清理垃圾。別小看這個STW,如果時間過長,會明顯影響到使用者體驗。像我之前從事的廣告業務,有研究表明廣告系統響應時間越長,廣告點選量越低,也就意味著掙到的錢越少。
  GC還有個關鍵的效能指標——吞吐率(Throughput),它的定義是執行使用者程式碼的時間佔總CPU執行時間的比例。舉個例子,假設吞吐率是60%,意味著有60%的CPU時間是執行使用者程式碼的,而剩下的40%的CPU時間是被GC佔用。從其定義來看,當然是吞吐率越高越好,那麼如何提升應用的GC吞吐率呢? 這裡我總結了三條。

減少物件數量

  這個很好理解了,產生的垃圾物件越少,需要的GC次數也就越少。那如何能減少物件的數量?這就不得不回顧下我們在上一講巧用資料特性) 中提到的兩個特性——可複用性和非必要性,忘記的同學可以再點開上面的連結回顧下。這裡再大概講下這兩個特性是如何減少物件生成的。

可複用性

  可複用性在這裡指的是,大多數的物件都是可以被複用的,這些可以被複用的物件就沒必要每次都新建出來,浪費記憶體空間了。 處了巧用資料特性) 中的例子,我這裡再個Java中已經被用到的例子,這個還得從一段奇怪的程式碼說起。

Integer i1 = Integer.valueOf(111);
Integer i2 = Integer.valueOf(111);
System.out.println(i1 == i2);

Integer i3 = Integer.valueOf(222);
Integer i4 = Integer.valueOf(222);
System.out.println(i3 == i4);

  
  上面這段程式碼的輸出結果會是啥呢?你以為是true+true,實際上是true+false。 What?? Java中222不等於222,難道是有Bug? 其實這是新手在比較數值大小時常犯的一個錯誤,包裝型別間的相等判斷應該用equals而不是'==’,'==’只會判斷這兩個物件是否是同一個物件,而不是物件中包的具體值是否相等。
 在這裡插入圖片描述
  像1、2、3、4……等一批數字,在任何場景下都是非常常用的,如果每次使用都新建個物件很是浪費,Java的開發者也考慮到了這點,所以在Jdk中提取快取了一批整數的物件(-128到127),這些數字每次都可以直接拿過來用,而不是新建一個物件出來。而在-128到127範圍外的數字,每次都會是新物件,下面是Integer.valueOf()的原始碼及註釋:

/**
     * Returns an {@code Integer} instance representing the specified
     * {@code int} value.  If a new {@code Integer} instance is not
     * required, this method should generally be used in preference to
     * the constructor {@link #Integer(int)}, as this method is likely
     * to yield significantly better space and time performance by
     * caching frequently requested values.
     *
     * This method will always cache values in the range -128 to 127,
     * inclusive, and may cache other values outside of this range.
     * 
     * @param  i an {@code int} value.
     * @return an {@code Integer} instance representing {@code i}.
     * @since  1.5
     */
    public static Integer valueOf(int i) {
        if (i >= IntegerCache.low && i <= IntegerCache.high)
            return IntegerCache.cache[i + (-IntegerCache.low)];
        return new Integer(i);
    }

  我在Idea中通過Debug看到了i1-i4幾個物件,其實111的兩個物件確實是同一個,而222的兩個物件確實不同,這就解釋了上面程式碼中的詭異現象。

非必要性

  非必要性的意思是有些物件可能沒必要生成。這裡我舉個例子,可能類似下面這種程式碼,在業務系統中會很常見。

    private List<UserInfo> getUserInfos(List<String> ids) {
        List<UserInfo> res = new ArrayList<>(ids.size());
        if (ids == null || res.size() == 0) {
            return new Collections.emptyList();
        }
        List<UserInfo> validUsers = ids.stream()
                .filter(id -> isValid(id))
                .map(id -> getUserInfos(id))
                .filter(Objects::nonNull)
                .collect(Collectors.toList());
        res.addAll(validUsers);
        return res;
    }

  上面程式碼非常簡單,就是通過一批使用者Id去獲取出來完整的使用者資訊,獲取前要對入參做校驗,之後還會對id做合法性校驗。 上面程式碼的問題是 res物件初始化太早了,如果一個UserInfo沒查到,res物件就白初始化了。另外,最後直接返回validUsers是不是就行了,沒必要再裝到res中,這裡res就具備了非必要性。
  像上述這種情況,可能在很多業務系統裡隨處可見(但不一定這麼直觀),提前初始化一些之後沒用的物件,除了浪費記憶體和CPU之外,也會給GC增加負擔。

縮小物件體積

  縮小體積物件也很好理解,如果物件在單位時間內生成的物件數量固定,但體積減小後,同樣大小的記憶體就能裝載更多的物件,更晚才觸發GC,GC的頻次就會降低,頻次低了自然對效能的影響就會變小。
  關於減少物件體積,這裡我給大家推薦一個jar包——eclipse-collections,其中提供了好多原始型別的集合,比如IntMap、LongMap…… 使用原始型別(int,long,double……)而不是封裝型別(Integer,Long,Double……),在一些數值偏多的業務中很有優勢,如下圖是我對比了HashSet<Integer>和eclipse-collections中IntSet在不同資料量下的記憶體佔用對比,IntSet的記憶體佔用只有HashSet<Integer>的四分之一。
在這裡插入圖片描述
  另外,我們在寫業務程式碼的時候,寫一些DO、BO、DTO的時候沒必要的欄位就別加進去了。查資料庫的時候,不用的欄位也就別查出來了。我之前看到過很多業務程式碼,查資料庫的時候把整行都查出來了,比如我要查一個使用者的年齡,結果把他的姓名、地址、生日、電話號碼…… 全查出來,這些資訊放在Java裡面需要一個個的物件去儲存的,沒有用到部分欄位首先就是白取了,其實存它還浪費記憶體空間。

縮短物件存活時間

  為什麼減少物件的存活時間就能提升GC的效能?總的垃圾物件並沒有減少啊! 是的 沒錯,單純縮短物件的存活時間並不會減少垃圾物件的數量,而是會減少GC的次數。要理解這個就得先知道GC的觸發機制,像Java中當堆空間使用率超過某個閾值後就會觸發GC,如果能縮短物件的時間,那每次GC就能釋放出來更多的空間,下次GC也就會來的更遲一些,總體上GC次數就會減少。
  這裡我舉個我自己經歷的真實案例,我們之前系統有個介面,僅僅是調整了兩行程式碼的順序,這個介面的效能就提升了40%,這個整個服務的CPU使用率降低了10%+,而這兩行順序的改動,縮短了大部分物件的生命週期,所以導致了效能提升。

    private List<Object> filterTest() {
        List<Object> list = getSomeList();
        List<Object> res = list
                .stream()
                .filter(x -> filter1(x))  // filter1需要呼叫外部介面做過濾判斷,效能低且過濾比例很少
                .filter(x -> filter2(x))  
                .filter(x -> filter3(x))  // filter3 本地數值校驗,不依賴外部,效率高且過濾比例高
                .collect(Collectors.toList());
    }

  上面程式碼中,filter1效能很低但過濾比低,filter3恰恰相反,往往沒被filter1過濾的會被filter3過濾,做了很多無用功。這裡只需要將filter1和filter3互換下位置,除了減少無用功之外,List中的大部分物件生命週期也會縮短。
  其實有個比較好的程式設計習慣,也可以減少物件的存活時間。其實在本系列的第篇中我也大概提到過,那就是縮小變數的作用域。能用區域性變數就用區域性變數,能放if或者for裡面就放裡面,因為程式語言作用域實現就是用的棧,作用域越小就越快出棧,其中使用到的物件就越快被判斷為死物件。


  除了上述三種優化GC的方式話,其實還有種騷操作,但是我本人不推薦使用,那就是——堆外記憶體

堆外記憶體

  在Java中,只有堆內記憶體才會受GC收集器管理,所以你要不被GC影響效能,最直接的方式就是使用堆外記憶體,Java中也提供了堆外記憶體使用的API。但是,堆外記憶體也是把雙刃劍,你要用就得做好完善的管理措施,否則記憶體洩露導致OOM就GG了,所以不推薦直接使用。但是,凡事總有但是,有一些優秀開原始碼,比如快取框架ehcache)就可以讓你安全的享受到堆外記憶體的好處,具體使用方式可以查閱官網,這裡不再贅述。


  好了,今天的分享就到這裡了,看完你可能會發現今天的內容和上一講 (二)巧用資料特性有一些重複的內容,沒錯,我理解效能優化底層都是同一套方法論,很多新方法只是以不同的視角在不同領域所衍生出來的。最後感謝下大家的支援,希望你看完文章有所收穫。另外有興趣的話也可以關注下本系列的前兩篇文章。

如何寫出高效能程式碼系列文章

相關文章