Java效能優化指南(四):GC收集器導論
- 本章主要介紹垃圾收集器的基礎知識。為了提升效能,如果需要重寫程式碼,那肯定需要花費很大的精力,所以一般都是在不得已的情況下才會這麼做。實踐證明,對垃圾收集器進行調優可以對應用帶來比較大的效能提升,它也是效能工程師對應用進行調優的重要手段。
- 當前Java虛擬機器主要有4類垃圾收集器:SerialCollector(單執行緒,用於單CPU機器上)、the throughput (parallel) collector(吞吐量並行收集器)、CMS收集器、G1收集器。它們的效能特性各異,所以,在下一章會詳細討論它們使用的演算法。本章重點介紹它們共同使用的基本概念,並對收集器是如何執行的做概要性描述。
垃圾收集器概述
- Java相對於c/c++的一個重要特徵就是不用開發人員對記憶體進行管理,Java虛擬機器會對垃圾記憶體定時進行清理。但是對於效能工程師來說,這種機制大大加大了調優難度。不過,在絕大多數情況下,Java虛擬機器都能很好的工作。
- 垃圾收集的基本原理就是找到不再使用的物件,然後釋放這些物件。找到不再使用的物件大部分情況下就是要找到那些沒有任何引用指向的物件(可以使用引用計數哦)。但是也有特例,考慮到一個雙向連結串列,連結串列中的每個元素都有引用指向,它們的引用計數都至少為1。這樣它們就無法進行釋放,但是,實際上整個連結串列都不在使用了,是可以釋放的。所以,單獨使用引用計數來標識物件是否使用還是不夠的。一個替代方案就是,Java虛擬機器必須要定時對heap進行全量掃描,從而找到不再使用的物件。找到之後,就可以釋放這些物件的記憶體了,以便分配給其它物件使用。但是,實際情況並不是這麼簡單,由於記憶體頻繁釋放和在分配,就會導致記憶體碎片的問題,考慮到下面的情形:一個程式分配一個數組,它由
- 通過上面的介紹,我們可以總結一下垃圾收集器的主要工作:搜尋未使用的物件,釋放未使用物件的記憶體,對堆進行整理減少碎片。不同的垃圾收集器會採用不同的手段來做這些事情,從而導致它們的效能有所不同。
- 在執行上面的操作過程中,如果所有業務執行緒都不再允許,那就大大簡化了垃圾收集器的設計。但是由於Java程式常常是多執行緒的,這麼做就會對應用的效能產生影響。另一方面,由於在垃圾收集過程中(特別是進行記憶體整理的時候),物件的地址會發生變化,此時必須要確保業務執行緒不能對這個物件進行訪問,也就是說停止業務執行緒不可避免。這個停止的過程,有一個術語專門進行描述,叫做
分代垃圾收集器
- 儘管垃圾收集器之間存在細節的不同,但是所有垃圾收集器都將堆劃分為多個不同的部分。這些不同的部分,大致為:老年代(OldGeneration)和新生代(NewGeneration)。新生代又可以分為Eden和Surivior兩個部分(我們經常將Eden部分指代新生代)。
- 將heap分為多個不同的部分是有實踐依據的:大多數物件的生存週期都非常短。比如,考慮下面的情形:
sum = newBigDecimal(0);
for (StockPrice sp: prices.values()){
BigDecimal diff = sp.getClosingPrice().subtract(averagePrice);
diff = diff.multiply(diff);
sum =sum.add(diff);
}
diff物件是一個BIgDecimal型別,這個型別是Immutable的,因此,每次迴圈都會建立一個新的物件,導致會建立大量的BigDecimal物件;而在迴圈結束之後,這個物件的空間就沒有用了(也就是生存週期很短)。這種情況在Java程式碼中非常普遍。為了對這些短暫生命週期的物件進行管理,垃圾收集器對Heap進行分代劃分,並使用新生代來管理這些物件。當新生代填滿的時候,Java虛擬機器會停止業務執行緒,並對新生代進行垃圾收集,最終會清空新生代,清理不再使用的物件,將還在使用的物件轉移到Surivior區或老生代。這個過程稱之為MinorGC。由於新生代只佔整個Heap空間的一部分,所以MinorGC是比較快的。但是,由於新生代空間較小,所以填充滿的可能性變大,也就是發生MinorGC的頻率會加快。這裡就是要做一個均衡了。另一方面,在進行MinorGC的過程中,正在使用的物件一定會進行遷移,所以GC完成了之後,記憶體也就完成了整理。
- 由於新生代的物件會不斷地向老生代遷移,隨著時間的推移,老生代的空間就會被充滿,此時就會對老生代的空間進行垃圾收集。這個是不同GC演算法差別最大的地方。簡單的演算法是停止所有業務執行緒,然後進行收集。這個過程被稱為FullGC,會導致應用執行緒的長時間停止。複雜的演算法是不停止業務執行緒,CMS和G1都是採用這種方式。由於在GC的過程中,它們不會停止業務執行緒(還是有可能需要停止的,不過時間很短),所以,它們又稱之為併發收集器。同時,由於它們在收集過程停止業務執行緒的時間很短,所以也稱之為低延遲收集器。
- 使用CMS和G1收集器可以使業務執行緒停止的時間更短,但是帶來的問題就是需要消耗更多的CPU。不過要記住的是,CMS和G1也可能產生更長時間的FullGC(這個是調優這些收集器的關鍵)。
- 這麼多垃圾收集演算法,我們到底使用哪一個呢?這個需要根據系統的整體效能目標來確定。在每種情況下都需要進行均衡。假設一個應用關注的是每個請求的響應時間(JavaEE伺服器),考慮下面的情形:
- 每個請求都會受STWP時間的影響,特別是暫停時間比較長的FullGC。如果目標是減少它們對響應時間的影響,併發收集器就比較適合。
- 如果平均響應時間更重要,吞吐量收集器(throughputcollector)常常是更好的選擇。
- 併發收集器可以減少暫停時間,但是也會消耗更多的CPU。
- 同樣的,如果應用是批量處理的,我們就考慮:
- 如果有充足的CPU,使用併發收集器來避免FullGC可以加快批量處理工作的完成。
- 如果CPU不是特別充裕,併發收集器則會帶來副作用,批量處理工作的完成需要更多的時間。
GC演算法簡介
線性收集器
- 線性收集器是最簡單的,是client類機器的預設收集器(32bitwindows機器或單核處理器機器)。線性收集器使用單執行緒來處理堆。在處理過程中,會停止所有業務執行緒。
- 可以使用-XX:+UseSerialGC開啟線性收集器,和其它標誌性引數不同的是,使用-XX:-UseSerialGC不能關閉線性垃圾收集器。在預設使用線性收集器的機器上,只有選擇另外一個垃圾收集演算法才能關閉掉線性收集器。
吞吐量收集器
- 吞吐量收集器是server類機器的預設收集器(多核的Unix機器,64位的JVM)。吞吐量收集器使用多個執行緒來收集新生代,提升了處理新生代的效能。吞吐量收集器也能夠使用多個執行緒來處理老年代。在JDK 7u4之後,這個特性是預設行為;之前的JDK版本,如果要使用這個特性可以通過設定-XX:+UseParallelOldGC來實現。吞吐量收集器在MinorGC和FullGC過程中都會停止所有業務執行緒,並在FullGC過程中對老年代進行整理。這個特性是預設,一般不需要特別指定;如果需要,可以使用-XX:+UseParallelGC-XX:+UseParallelOldGC開啟。
CMS收集器
- CMS收集器目的就是為了減少FullGC的STWP的時長。在MinorGC的過程中,CMS會停止所有應用執行緒,然後使用多執行緒來收集新生代。雖然吞吐量收集器也是使用多執行緒來手機新生代,但是演算法是完全不一樣的。CMS的演算法可以使用-XX:+UseParNewGC開啟,而吞吐量收集器使用的是-XX:+UseParallelGC開啟。
- 對於老年代,CMS會啟動一個或多個後臺執行緒,週期性地進行掃描,以發現未使用的物件。這種方式減少了對老年代進行收集的時候,業務執行緒停止的時間。對於CMS,只有MinorGC會停止業務執行緒(處理老年代時也會,不過時間很短,主要是後臺執行緒掃描老年代的時候)。總體來說,CMS比吞吐量收集器停止業務執行緒的時間少很多。
- 但是沒有完美的事情。CMS雖然減少了業務執行緒的停止時間,但是增加了CPU的利用率。因此,使用CMS垃圾收集器的時候,一般要確認系統中有足夠的CPU。另外,CMS的後臺執行緒不會對來年代進行整理,這會導致老年代的記憶體碎片會隨著時間的推移變得越來越嚴重。如果發生了下面兩種情況:1)CMS的後臺執行緒不能獲得更多的CPU 2)老年代因為碎片不能分配新的物件,CMS就會採用線性收集器的模式來收集老年代(當然會進行整理)。之後,CMS又會進行多個後臺執行緒的模式。CMS可以使用-XX:+UseConcMarkSweepGC-XX:+UseParNewGC開啟。
G1收集器
- G1收集器是為了處理堆空間很大的情況而設計的。它將堆分成多個區域,但是它仍是一個分代收集器。有些區域術語新生代,在對新生代進行收集的時候,還是會暫停所有業務執行緒,並把不再使用的物件移動到老年代或survivor。這個操作是多個執行緒進行的(和CMS收集器一樣)。
- G1收集器還被稱為併發收集器,因為它對老年代的收集使用的是後臺執行緒,這個過程不會影響業務執行緒的執行。和CMS收集器不同的是,它將老年代劃分了區域,所以在收集的過程中,會將還需使用的物件從一個區域移動到另外一個區域。這樣,收集的過程就對老年代進行了整理(雖然比較粗)。這使得G1收集器產生碎片的可能性大大降低。
- 和CMS收集器一樣,我們需要權衡CPU的使用率。G1收集器可以使用-XX:+UseG1GC開啟。
選擇一個GC演算法
- 對GC演算法的選擇,一方面依賴於應用的型別;另外一方面依賴於應用的效能目標。線性收集器只適用於那些heap佔用記憶體小於100M的情形。這大大限制了線性收集器的使用範圍,因此,我們一般都在吞吐量收集器和並行收集器之間進行選擇。
批處理和GC演算法
- 對於批處理程式,吞吐量收集器引入的STWP時間(特別是FullGC帶來的)會大大影響其效能。比如:吞吐量收集器每次收集帶來的STWP時間為0.5s。如果批處理程式執行了5分鐘,進行了20次的Heap收集。則吞吐量收集器帶來了3.4%的效能損耗。
- 如果系統中有足夠的CPU,使用並行收集器將會帶來更好的效能。因為富裕的CPU可以用於GC後臺執行緒,而不會影響正常的業務執行緒。
- 如果系統是單執行緒的,或系統有多個CPU,但是不足以同時執行所有業務執行緒和GC後臺執行緒,它們都會加劇CPU的競爭。
- 下圖是在不同機器上(4核和單核)採用CMS和吞吐量收集器,程式消耗CPU和業務完成情況:
可以看到,在CPU充足的情況下(4核),CMS消耗更多的CPU,但是完成時間更短;如果CPU不充足(單核),CMS完成的時間反而更長。在4核機器上,應用本來消耗的CPU應該為25%,為什麼會大於這個值呢?這就是CMS和吞吐量收集器的後臺執行緒消耗的。CMS是後臺執行緒週期性掃描heap;吞吐量收集器是後臺執行緒短時使用100%CPU導致的。
吞吐量和GC演算法
- 使用之前股票servlet做測試,我們傳送10個請求給這個servlet,servlet會將這些請求保持到session中(以便給GC施加壓力)。下圖顯示了在4核機器上做這個測試的情況:
在上圖中,10個請求的情況,系統不能提供足夠多的CPU,此時,CMS的TPS比吞吐量收集器要低很多,大概23.5%。但是如果CPU充足的情況下,CMS的TPS比吞吐量收集器要高5%左右。這裡要注意,在上表中,雖然CPU不夠,但是CMS沒有達到100%的CPU消耗。這是因為,由於CPU不充足,CMS產生了併發模式失效的情形。這就意味著CMS需要使用線性收集器的方式來對heap進行整理。由於整理使用的是單執行緒,所以CPU的消耗不可能達到100%。
響應時間和GC演算法
- 同樣使用之前的股票servlet做測試,這次請求每隔250ms傳送一個,也就是將吞吐量固定為29TPS。效能測量則採用的是90th%和99th%的請求的平均響應時間;測試結果如下圖所示:
第一次測試使用儲存10個請求的會話狀態,得到的結果是非常典型的。吞吐量收集器在平均(甚至90%的請求)處理時間都比並發收集器要快,但是10%的請求,吞吐量收集器使用的時間明顯大於併發收集器,這是由於fullGC導致的。
第二次測試使用50個請求,可以看到併發收集器比吞吐量收集器要快了,並且在fullGC的情況下,吞吐量收集器的處理請求時間是併發收集器的10倍,效能大大降低。這可能是因為請求越多,使用的記憶體閱讀,導致fullGC可能性越大。但是當heap記憶體碎片不斷增多(或CPU不充足),併發收集器會產生併發模式失效的情形。
對於這裡兩種演算法的選擇,我們需要做個權衡;如果我們關係平均處理時間,那麼吞吐量收集器和併發收集器效果差不多;如果我們關注的是CPU使用率,那麼他們之間也差不多(總體來說,吞吐量收集器稍好些)。如果應用中會導致比較多的fullGC,那麼,併發收集器在平均處理時間上常常是更好的。
CMS和G1演算法
- CMS一般用於堆記憶體比較小的情況(一般小於4G),如果堆記憶體比較大,使用G1演算法常常更有優勢(因為G1對堆再次進行了劃分)。
- CMS後臺執行緒在掃描完堆空間之後,才能進行物件的釋放;如果在掃描完堆記憶體之前,堆就滿了,那麼就會產生併發模式失效的情況。此時,所有業務執行緒都會停止,並使用單執行緒對堆進行收集,效能大大下降。當然,CMS的後臺執行緒可以配置為多個執行緒,但是當記憶體比較大的時候,需要掃描的範圍就大,也更有可能發生併發模式失效。
- G1將老年代進行了劃分,因此更容易用多執行緒收集,也就減少了發生併發模式失效的概率(但是還是會存在的)。
- 另外,CMS也比G1更容易導致記憶體碎片,從而產生併發模式失效。
- 雖然我們可以對CMS和G