保持GC低開銷的竅門有哪些?
隨著一再拖延而即將發布的Java9,G1(“GarbageFirst”)垃圾回收器將被成為HotSpot虛擬機默認的垃圾回收器。從serial垃圾回收器到CMS收集器,JVM見證了許多GC實現,而G1將成為其下一代垃圾回收器。
隨著垃圾收集器的發展,每一代GC與其上一代相比,都帶來了巨大的進步和改善。parallelGC與serialGC相比,它讓垃圾收集器以多線程的方式工作,充分利用了多核計算機的計算能力。CMS(“ConcurrentMark-Sweep”)收集器與parallelGC相比,它將回收過程分成了多個階段,使得應用線程正在運行的時候,收集工作可以并發地完成,大大改善了頻繁執行“stop-the-world”的情況。G1對于擁有大量堆內存的JVM表現出更好的性能,并且具有更好的可預測和統一的暫停過程。
Tip#1:預測集合的容量
所有標準的Java集合,包括定制和擴展的實現(比如Trove和Google的Guava),底層都使用了數組(原生數據類型或者基于對象的類型)。因為數組一旦被分配,其大小就不可變,因此添加元素到集合時,大多數情況下都會導致需要重新申請一個新的大容量數組替換老的數組(指集合底層實現使用的數組)。
即使沒有提供集合初始化的大小,大多數集合的實現都盡量優化重新分配數組的處理并且將其開銷平攤到zui低。不過,在構造集合的時候就提供大小可以得到很好的效果。
讓我們將下面的代碼作為一個簡單的例子分析一下:
publicstaticListreverse(List<?extendsT>list){
Listresult=newArrayList();
for(inti=list.size()-1;i>=0;i--){
result.add(list.get(i));
}
returnresult;
}
Thismethodallocatesanewarray,thenfillsitupwithitemsfromanotherlist,onlyinreverseorder.這個方法分配了一個新的數組,然后用另一個list中元素對該數組進行填充,只是元素的數序發生了變化。
這個處理方式可能會付出慘重的性能代價,其優化的點在添加元素到新的list中這行代碼。隨著每一次添加元素,list都需要確保其底層數組擁有足夠的位置來容納新的元素。如果有空閑的位置,那么只是簡單地將新元素存儲到下一個空閑的槽位。如果沒有的話,將分配一個新的底層數組,拷貝舊的數組內容到新的數組中,然后添加新的元素。這將導致多次分配數組,那些剩余的舊數組會被GC所回收。
我們可以通過在構造集合時讓其底層的數組知道它將存儲多少元素,從而避免這些多余的分配
publicstaticListreverse(List<?extendsT>list){
Listresult=newArrayList(list.size());
for(inti=list.size()-1;i>=0;i--){
result.add(list.get(i));
}
returnresult;
}
上面的代碼通過ArrayList的構造器指定足夠大的空間來存儲list.size()個元素,在初始化時完成分配的執行,這意味著List在迭代的過程中無需再次分配內存。
Guava的集合類則更進一步,允許初始化集合時明確指定期望元素的個數或者指定一個預測值。
Listresult=Lists.newArrayListWithCapacity(list.size());
Listresult=Lists.newArrayListWithExpectedSize(list.size());
上面的代碼中,前者用于我們已經準確地知道集合將要存儲多少元素,而后者的分配方式考慮了錯誤預估的情況。
Tip#2:直接處理數據流
當處理數據流時,比如從一個文件讀取數據或者從網絡中下載數據,下面的代碼是非常常見的:
byte[]fileData=readFileToByteArray(newFile("myfile.txt"));
所產生的字節數組可能被解析XML文檔、JSON對象或者協議緩沖消息,以及一些常見的可選項。
當處理大文件或者文件的大小無法預測時,上面的做法很是不明智的,因為當JVM無法分配一個緩沖區來處理真正文件時,就會導致OutOfMemeoryErrors。
即使數據的大小是可管理的,當到垃圾回收時,使用上面的模式依然會造成巨大的開銷,因為它在堆中分配了一塊非常大的區域來存儲文件數據。
一種更加好的處理方式是使用合適的InputStream(比如在這個例子中使用FileInputStream)直接傳遞給解析器,不再一次性將整個文件讀取到一個字節數組中。所有主流的開源庫都提供相應的API來直接接受一個輸入流進行處理,比如:
FileInputStreamfis=newFileInputStream(fileName);
MyProtoBufMessagemsg=MyProtoBufMessage.parseFrom(fis);
Tip#3:使用不可變的對象
不變性有太多的好處。甚至不用我贅述什么。然而,有一個優點會對垃圾回收產生影響,應該關注一下。
一個不可變對象的屬性在對象被創建后就不能被修改(在這里的例子使用的是引用數據類型的屬性),比如:
publicclassObjectPair{
privatefinalObjectfirst;
privatefinalObjectsecond;
publicObjectPair(Objectfirst,Objectsecond){
this.first=first;
this.second=second;
}
publicObjectgetFirst(){
returnfirst;
}
publicObjectgetSecond(){
returnsecond;
}
}
將上面的類實例化后會產生一個不可變對象—它的所有屬性用final修飾,構造完成后就不能改變了。
不可變性意味著所有被一個不可變容器所引用的對象,在容器構造完成前對象就已經被創建。就GC而言:這個容器年輕程度至少和其所持有的年輕的引用一樣。這意味著當在年輕代執行垃圾回收的過程中,GC因為不可變對象處于老年代而跳過它們,直到確定這些不可變對象在老年代中不被任何對象所引用時,才完成對它們的回收。
更少的掃描對象意味著對內存頁更少的掃描,越少的掃描內存頁就意味著更短的GC生命周期,也意味著更短的GC暫停和更好的總吞吐量。
Tip#4:小心字符串拼接
字符串可能是在所有基于JVM應用程序中常用的非原生數據結構。然而,由于其隱式地開銷負擔和簡便的使用,非常容易成為占用大量內存的罪歸禍首。
這個問題很明顯不在于字符串字面值,而是在運行時分配內存初始化產生的。讓我們快速看一下動態構建字符串的例子:
publicstaticStringtoString(T[]array){
Stringresult="[";
for(inti=0;i<array.length;i++){
result+=(array[i]==array?"this":array[i]);
if(i<array.length-1){
result+=",";
}
}
result+="]";
returnresult;
}
這是個看似不錯的方法,接收一個字符數組然后返回一個字符串。但是這對于對象內存分配卻是災難性的。
很難看清這語法糖的背后,但是幕后的實際情況是這樣的:
publicstaticStringtoString(T[]array){
Stringresult="[";
for(inti=0;i<array.length;i++){
StringBuildersb1=newStringBuilder(result);
sb1.append(array[i]==array?"this":array[i]);
result=sb1.toString();
if(i<array.length-1){
StringBuildersb2=newStringBuilder(result);
sb2.append(",");
result=sb2.toString();
}
}
StringBuildersb3=newStringBuilder(result);
sb3.append("]");
result=sb3.toString();
returnresult;
}
字符串是不可變的,這意味著每發生一次拼接時,它們本身不會被修改,而是依次分配新的字符串。此外,編譯器使用了標準的StringBuilder類來執行這些拼接操作。這就會有問題了,因為每一次迭代,既隱式地分配了一個臨時字符串,又隱式分配了一個臨時的StringBuilder對象來幫助構建結果。
更好的方式是避免上面的情況,使用StringBuilder和直接的追加,以取代本地拼接操作符(“+”)。下面是一個例子:
publicstaticStringtoString(T[]array){
StringBuildersb=newStringBuilder("[");
for(inti=0;i<array.length;i++){
sb.append(array[i]==array?"this":array[i]);
if(i<array.length-1){
sb.append(",");
}
}
sb.append("]");
returnsb.toString();
}
這里,我們只在方法開始的時候分配了僅有的一個StringBuilder。至此,所有的字符串和list中的元素都被追加到單獨的一個StringBuilder中。使用toString()方法一次性將其轉成成字符串返回。
Tip#5:使用特定的原生類型的集合
Java標準的集合庫簡單且支持泛型,允許在使用集合時對類型進行半靜態地綁定。比如想要創建一個只存放字符串的Set或者存儲Map<Pair,List>這樣的map,這種處理方式是非常棒的。
真正的問題源于當我們想要使用一個list存儲int類型,或者一個map存儲double類型作為value。因為泛型不支持原生數據類型,因此另外的一種選擇是使用包裝類型來進行替換,這里我們使用List。
這種處理方式是非常浪費的,因為一個Integer是一個完全的對象,一個對象的頭部占用12個字節以及其內部的所維護的int屬性,每個Integer對象總共占用16個字節。這比起存儲相同個數的int類型的list而言,其消耗的空間是它的四倍!比這個更加嚴重的問題在于,事實上因為Integer是真正的對象實例,因此它需要垃圾收集階段被垃圾收集器所考慮是否要回收。
為了處理這個問題,我們在Takipi中使用非常棒的Trove集合庫。Trove摒棄了部分泛型的特定來支持特定的使用內存更快速的原生類型的集合。比如,我們使用非常消耗性能的Map<Integer,Double>,在Trove中有另一種特別的選擇方案,其形式為TIntDoubleMap
TIntDoubleMapmap=newTIntDoubleHashMap();
map.put(5,7.0);
map.put(-1,9.999);
...
Trove的底層實現使用了原生類型的數組,所以當操作集合的時候不會發生元素的裝箱(int->Integer)或者拆箱(Integer->int),沒有存儲對象,因為底層使用原生數據類型存儲。
最后
隨著垃圾收集器持續的改進,以及運行時的優化和JIT編譯器也變得越來越智能。我們作為開發者將會發現越來越少地考慮如何編寫GC友好的代碼。然而,就目前階段,不論G1如何改進,我們仍然有很多可以做的事來幫JVM提升性能。