為任務關鍵型Java應用優化垃圾回收(下)

文 學敏發表於2016-08-06

為任務關鍵型Java應用優化垃圾回收(上)

並行標記清除(CMS)回收器

CMS垃圾回收器是第一個廣泛使用的低延遲迴收器。雖然在Java 1.4.2中就可以使用了,但剛開始還不是很穩定。這些問題直到Java 5才得以解決。

從CMS回收器的名字就可以看出它使用並行方式:大部分回收工作由一個GC執行緒完成,與處理使用者請求的工作執行緒並行執行。老年代原來單一的stop-the-world回收過程被劃分為兩個更短的stop-the-world暫停加上5個並行階段。在這些並行階段中,原來的工作執行緒照常執行(不會被暫停)。關於CMS更詳細的介紹可以參考這篇文章《Java SE 6 HotSpot Virtual Machine Garbage Collection Tuning》。

使用下面的引數可以啟用CMS回收器:

-XX:+UseConcMarkSweepGC

再次應用到上面的測試程式(並提高負載)可以得到以下結果:

圖4 優化堆大小並使用CMS的JVM在50小時內的GC行為(-Xms1200m -Xmx1200m -XX:NewSize=400m -XX:MaxNewSize=400m -XX:SurvivorRatio=6 -XX:+UseConcMarkSweepGC))

可以看到,老年代GC的8s左右暫停已經消失了。現在,老年代回收過程只出現兩次暫停(前一次的結果50小時內有5次),並且所有暫停都在1s內。

預設情況下,CMS回收器使用ParNew(GC演算法)處理新生代回收。如果ParNew和CMS一起執行,它的暫停會比沒有CMS時長一點,因為他們之間需要額外的協同工作。與上次的測試結果相比,可以從新生代的平均暫停時間略有上升發現這個問題。新生代暫停時間中離群值頻繁出現,從這裡也可以發現這個問題。離群值可以達到0.5s左右。但是這些暫停對很多應用來說已經足夠短了,所以CMS/ParNew組合可以作為一個很好的低延遲優化選擇。

CMS回收器的一個嚴重缺陷就是,當老年代空間都被佔滿時CMS無法啟動。一旦老年代被佔滿了,啟動CMS就太晚了;虛擬機器必須使用通常的“stop-the-world”策略(在GC日誌中會出現“concurrent mode failure”的記錄)。為了實現低延遲目標,當老年代空間佔用量達到一定門限值時,就應該啟動CMS回收器,通過以下設定來實現:

-XX:CMSInitiatingOccupancyFraction=80

這表示一旦老年代空間被佔用80%時,CMS回收器就會執行。對於我們的應用,使用這個值(也就是預設值)就可以。但如果把門限值設太高的話,就會產生“concurrent mode failure”,導致長時間的老年代GC暫停。反過來,如果設的太低(低於活躍空間大小),CMS可能一直並行執行,導致某個CPU核心完全用在GC上。如果一個應用的物件建立和堆使用行為變化很快,比如通過互動的方式或者計時器啟動專門的任務,很難設定一個合適的門限值同時避免上述兩種問題。

碎片的陰影

然而,CMS最大的一個問題是它不會整理老年代堆空間。這樣會產生堆碎片,隨著時間執行,會導致服務嚴重惡化。有兩個因素會導致這種情況:緊缺的老年代空間大小,以及頻繁的CMS回收。第一個因素可以通過增大老年代堆空間來改善,要大於ParallelGC回收器所需要的空間(我從1024M增加到1200M,從前幾幅圖可以看到)。第二個問題可以通過合適地劃分各代空間來優化,前面講過。我們可以實際看一下這樣可以把老年代GC的頻率降低多少。

為了證明使用CMS前合理地調整各代堆大小很重要,我們先看看如果不遵守上述的原則,在圖1(幾乎不對堆做優化)的基礎上直接使用CMS回收器會怎麼樣:

圖5 未優化堆大小的GC行為,以及使用CMS後記憶體碎片導致的效能惡化(從第14小時開始)

很明顯,JVM在這樣設定的負載測試下可以穩定地工作將近14個小時(在生產環境以及更小的負載條件下,這個不穩定的良性階段可能會持續更久)。接下來,突然間會出現多次很長的GC暫停,暫停時間幾乎佔剩餘時間的一半。不僅老年代的暫停時間會達到10s以上,而且新生代的暫停時間也會達到數秒。因為回收器為了將新生代的物件移到老年代,需要耗費很長的時間搜尋老年代空間。

CMS低延遲優點的代價就是記憶體碎片。這個問題可以最小化,但是不會徹底消失。你永遠不知道它什麼時候會被觸發。然而,通過合理的優化與監控可以控制它的風險。

G1(Garbage First)回收器的希望

G1回收器設計的目的就是保證低延遲的同時而沒有堆碎片風險。因此,Oracle把它作為CMS的一個長期取代。G1可以避免碎片風險是因為它會整理堆空間。對於GC暫停來說,G1的目標並不是使暫停時間最小化,而是設定一個時間上限,使GC暫停儘量滿足這一上限值。讀者可以從G1的重要教程《Getting Started with the G1 Garbage Collector》中瞭解到更詳細的內容。德國的讀者可以也閱讀Angelika Langer的文章《Der Garbage-First Garbage Collector (G1) – Übersicht über die Funktionalität》。

在將G1回收器用於測試程式中並與上述其他經典回收器做對比之前,先總結兩點關於G1的重要資訊。

  • Oracle在Java 7u4中開始支援G1。為了使用G1你應該將Java 7更新到最新。Oracle的GC團隊一直致力於G1的研發,在最新的Java更新中(本文編寫時最新版本是7u7到7u9),G1的改進很顯著。另一方面,G1無法在任何Java 6版本中使用,而且到目前更優越的Java 7不可能向後移植到Java 6中。
  • 前面關於調節各代空間大小的優化對G1來說已經淘汰了。設定各代空間大小與設定暫停目標時間相沖突會使G1回收器偏離原本的設計目標。使用G1時,可以使用“-Xms”和“-Xmx”設定整體的記憶體大小,也可以設定GC暫停目標時間(可選),對G1來說不用設定其他選項。與ParallelGC回收器的AdapativeSizingPolicy類似,它自適應地調整各代空間大小來滿足暫停目標時間。

遵循這些原則後,G1回收器在預設配置下的結果如下:

圖6 最小配置(-Xms1024m -Xmx1024 -XX:+UseG1GC)的JVM在G1下26小時內的GC效能

在這個例子中,我們使用了預設的GC暫停目標時間200ms。從圖中可以看到,平均時間與這個目標比較吻合,最長GC暫停時間與使用CMS回收器差不多(圖4)。G1明顯可以很好地控制GC暫停,與平均時長相比,離群值也相當少。

另一方面,平均GC暫停時間要比CMS回收器長很多(270 vs 100ms),而且更頻繁。這意味著GC累積暫停時間(也就是GC本身所佔總時間)是使用CMS的4倍以上(6.96% vs 1.66%)。

與CMS一樣,G1也分為GC暫停階段和並行回收階段(不暫停任務)。同樣與CMS類似,當堆佔用比達到一定門限後,它才啟動並行回收階段。從圖6可以看到,1GB的可用記憶體到目前為止並沒有完全使用。這是因為G1的預設佔用比門限值要比CMS低很多。也有人指出,一般來說較小的堆空間就可以滿足G1的需求。

垃圾回收器的定量比較

下面的表格總結了Oracle Java 7中4種最重要的垃圾回收器在測試中的關鍵效能指標。在同樣的應用程式上,進行相同的負載測試,但是負載的級別不同(由第2列的垃圾建立速率體現)。

表 幾種垃圾回收器的比較

所有的回收器都執行在1GB的堆空間上。傳統的回收器(ParallelGC、ParNewGC和CMS)另外使用下面的堆設定:

-XX:NewSize=400m -XX:MaxNewSize=400m -XX:SurvivorRatio=6

而G1回收器沒有額外的堆大小設定,並且使用預設的暫停目標時間200ms,也可以顯示設定:

-XX:MaxGCPauseMillis=200

從表中可以看到,傳統回收器在新生代回收上(第3列)時間差不多。對ParallelGC和ParNewGC來說是差不多的,而CMS實際上也是使用ParNewGC去回收新生代。然而,在新生代GC暫停中,將新生代存活物件移入老年代需要ParNewGC和CMS的協同。這樣的協同引入額外的代價,也就導致CMS的新生代GC暫停時間要略長。

第7列是GC暫停所耗費的時間佔總時間的百分比,這個值可以很好地反映GC的總時間代價。因為並行GC總時間(最後一列)以及引入的CPU佔用代價可以忽略。按前文所述,優化堆大小後老年代GC次數會變得很少,這樣第7列的值主要由新生代GC暫停總時間所決定。新生代暫停總時間是新生代暫停(連續)時長(第3列)與暫停次數的乘積。新生代暫停頻率與新生代空間大小有關,對傳統回收器來說,這個大小是相同的(400MB)。因此,對傳統回收器來說,第7列的值或多或少地反映著第3列的值(負載差不多的情況)。

CMS的優點可以從第6列明顯看出:它用稍長的總時間代價換來了更短(低一個量級)的老年代GC暫停。對很多真實環境的應用來說,這是一個不錯的折衷。

那麼,對於我們的應用,G1回收器表現怎麼樣呢?第6列(以及第5列)可以看出,在減少老年代GC暫停時長上,G1回收器要比CMS回收器做的好。但是從第7列也可以看到,它付出相當高的代價:同樣的負載下,GC總時間代價佔7%,而CMS只佔1.6%。

我會在後續的文章中檢查在什麼條件下會導致G1產生更高的GC時間代價,同樣也會分析G1與其他回收器(尤其是CMS回收器)相比的優缺點。這是一個龐大而且有價值的主題。

總結與展望

對所有的經典Java GC演算法(SerialGC、ParallelGC、ParNewGC和CMS)來說,優化各代堆空間大小是很重要的,然而實際中很多應用程式並沒有做足夠合理的優化。導致的結果就是應用效能不夠優化,以及操作退化(造成效能損失,如果沒有很好地監控甚至會出現一段時間內程式暫停的情況)。

優化各代堆空間大小可以顯著提高應用效能,並將GC長暫停次數減到最小。然後,消除GC長暫停需要使用低延遲迴收器。CMS一直(直到現在)是首選且有效的低延遲迴收器。在很多情況下,CMS就可以滿足需求。通過合理的優化,它還是可以保證長期穩定,只不過存在堆碎片的風險。

作為替代,G1回收器目前(Java 7u9)是一個被支援且可用的選擇,但仍有改進的餘地。對很多應用來說,它的結果可以接受,但與CMS回收器相比還不是很好。其優缺點的細節值得仔細地研究。

相關文章