導語
同一份邏輯,不同人的實作的代碼性能會出現數量級的差異; 同一份代碼,你可能微調幾個字元或者某行代碼的順序,就會有數倍的性能提升;同一份代碼,也可能在不同處理器上運作也會有幾倍的性能差異;十倍程式員 不是隻存在于傳說中,可能在我們的周圍也比比皆是。十倍展現在程式員的方法面面,而代碼性能卻是其中最直覺的一面。
本文是《如何寫出高性能代碼》系列的第三篇,本文将告訴你如何寫出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的兩個對象确實不同,這就解釋了上面代碼中的詭異現象。
非必要性
非必要性的意思是有些對象可能沒必要生成。這裡我舉個例子,可能類似下面這種代碼,在業務系統中會很常見。
<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和eclipse-collections中IntSet在不同資料量下的記憶體占用對比,IntSet的記憶體占用隻有HashSet的四分之一。
另外,咱在寫業務代碼的時候,寫一些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就可以讓你安全的享受到堆外記憶體的好處,具體使用方式可以查閱官網,這裡不再贅述。
好了,今天的分享就到這裡了,看完你可能會發現今天的内容和上一講 (二)巧用資料特性有一些重複的内容,沒錯,我了解性能優化底層都是同一套方法論,很多新方法隻是以不同的視角在不同領域所衍生出來的。最後感謝下大家的支援,希望你看完文章有所收獲。另外有興趣的話也可以關注下本系列的前兩篇文章。