GC實踐

首先感謝阿寶同學的幫助,我纔對這個gc算法的調整有了一定的認識,而不是停留在過去僅僅瞭解的階段。在讀過sun的文檔和跟阿寶討論之後,做個小小的總結。
    CMS,全稱Concurrent Low Pause Collector,是jdk1.4後期版本開始引入的新gc算法,在jdk5和jdk6中得到了進一步改進,它的主要適合場景是對響應時間的重要性需求大於對吞吐量的要求,能夠承受垃圾回收線程和應用線程共享處理器資源,並且應用中存在比較多的長生命週期的對象的應用。CMS是用於對tenured generation的回收,也就是年老代的回收,目標是儘量減少應用的暫停時間,減少full gc發生的機率,利用和應用程序線程併發的垃圾回收線程來標記清除年老代。在我們的應用中,因爲有緩存的存在,並且對於響應時間也有比較高的要求,因此希望能嘗試使用CMS來替代默認的server型JVM使用的並行收集器,以便獲得更短的垃圾回收的暫停時間,提高程序的響應性。
    CMS並非沒有暫停,而是用兩次短暫停來替代串行標記整理算法的長暫停,它的收集週期是這樣:
    初始標記(CMS-initial-mark) -> 併發標記(CMS-concurrent-mark) -> 重新標記(CMS-remark) -> 併發清除(CMS-concurrent-sweep) ->併發重設狀態等待下次CMS的觸發(CMS-concurrent-reset)。
    其中的1,3兩個步驟需要暫停所有的應用程序線程的。第一次暫停從root對象開始標記存活的對象,這個階段稱爲初始標記;第二次暫停是在併發標記之後,暫停所有應用程序線程,重新標記併發標記階段遺漏的對象(在併發標記階段結束後對象狀態的更新導致)。第一次暫停會比較短,第二次暫停通常會比較長,並且remark這個階段可以並行標記。

    而併發標記、併發清除、併發重設階段的所謂併發,是指一個或者多個垃圾回收線程和應用程序線程併發地運行,垃圾回收線程不會暫停應用程序的執行,如果你有多於一個處理器,那麼併發收集線程將與應用線程在不同的處理器上運行,顯然,這樣的開銷就是會降低應用的吞吐量。Remark階段的並行,是指暫停了所有應用程序後,啓動一定數目的垃圾回收進程進行並行標記,此時的應用線程是暫停的。

    CMS的young generation的回收採用的仍然是並行複製收集器,這個跟Paralle gc算法是一致的。

    下面是參數介紹和遇到的問題總結,

1、啓用CMS:-XX:+UseConcMarkSweepGC。 咳咳,這裏犯過一個低級錯誤,竟然將+號寫成了-號

2。CMS默認啓動的回收線程數目是  (ParallelGCThreads + 3)/4),如果你需要明確設定,可以通過-XX:ParallelCMSThreads=20來設定,其中ParallelGCThreads是年輕代的並行收集線程數

3、CMS是不會整理堆碎片的,因此爲了防止堆碎片引起full gc,通過會開啓CMS階段進行合併碎片選項:-XX:+UseCMSCompactAtFullCollection,開啓這個選項一定程度上會影響性能,阿寶的blog裏說也許可以通過配置適當的CMSFullGCsBeforeCompaction來調整性能,未實踐。

4.爲了減少第二次暫停的時間,開啓並行remark: -XX:+CMSParallelRemarkEnabled,如果remark還是過長的話,可以開啓-XX:+CMSScavengeBeforeRemark選項,強制remark之前開始一次minor gc,減少remark的暫停時間,但是在remark之後也將立即開始又一次minor gc。

5.爲了避免Perm區滿引起的full gc,建議開啓CMS回收Perm區選項:

+CMSPermGenSweepingEnabled -XX:+CMSClassUnloadingEnabled


6.默認CMS是在tenured generation沾滿68%的時候開始進行CMS收集,如果你的年老代增長不是那麼快,並且希望降低CMS次數的話,可以適當調高此值:
-XX:CMSInitiatingOccupancyFraction=80

這裏修改成80%沾滿的時候纔開始CMS回收。

7.年輕代的並行收集線程數默認是(ncpus <= 8) ? ncpus : 3 + ((ncpus * 5) / 8),如果你希望設定這個線程數,可以通過-XX:ParallelGCThreads= N 來調整。

8.進入重點,在初步設置了一些參數後,例如:
-server -Xms1536m -Xmx1536m -XX:NewSize=256m -XX:MaxNewSize=256m -XX:PermSize=64m
-XX:MaxPermSize=64m -XX:-UseConcMarkSweepGC -XX:+UseCMSCompactAtFullCollection
-XX:CMSInitiatingOccupancyFraction=80 -XX:+CMSParallelRemarkEnabled
-XX:SoftRefLRUPolicyMSPerMB=0

需要在生產環境或者壓測環境中測量這些參數下系統的表現,這時候需要打開GC日誌查看具體的信息,因此加上參數:

-verbose:gc -XX:+PrintGCTimeStamps -XX:+PrintGCDetails -Xloggc:/home/test/logs/gc.log

在運行相當長一段時間內查看CMS的表現情況,CMS的日誌輸出類似這樣:
4391.322: [GC [1 CMS-initial-mark: 655374K(1310720K)] 662197K(1546688K), 0.0303050 secs] [Times: user=0.02 sys=0.02, real=0.03 secs]
4391.352: [CMS-concurrent-mark-start]
4391.779: [CMS-concurrent-mark: 0.427/0.427 secs] [Times: user=1.24 sys=0.31, real=0.42 secs]
4391.779: [CMS-concurrent-preclean-start]
4391.821: [CMS-concurrent-preclean: 0.040/0.042 secs] [Times: user=0.13 sys=0.03, real=0.05 secs]
4391.821: [CMS-concurrent-abortable-preclean-start]
4392.511: [CMS-concurrent-abortable-preclean: 0.349/0.690 secs] [Times: user=2.02 sys=0.51, real=0.69 secs]
4392.516: [GC[YG occupancy: 111001 K (235968 K)]4392.516: [Rescan (parallel) , 0.0309960 secs]4392.547: [weak refs processing, 0.0417710 secs] [1 CMS-remark: 655734K(1310720K)] 766736K(1546688K), 0.0932010 secs] [Times: user=0.17 sys=0.00, real=0.09 secs]
4392.609: [CMS-concurrent-sweep-start]
4394.310: [CMS-concurrent-sweep: 1.595/1.701 secs] [Times: user=4.78 sys=1.05, real=1.70 secs]
4394.310: [CMS-concurrent-reset-start]
4394.364: [CMS-concurrent-reset: 0.054/0.054 secs] [Times: user=0.14 sys=0.06, real=0.06 secs]

其中可以看到CMS-initial-mark階段暫停了0.0303050秒,而CMS-remark階段暫停了0.0932010秒,因此兩次暫停的總共時間是0.123506秒,也就是123毫秒左右。兩次短暫停的時間之和在200以下可以稱爲正常現象。

但是你很可能遇到兩種fail引起full gc:Prommotion failed和Concurrent mode failed。

Prommotion failed的日誌輸出大概是這樣:
 [ParNew (promotion failed): 320138K->320138K(353920K), 0.2365970 secs]42576.951: [CMS: 1139969K->1120688K(
2166784K), 
9.2214860 secs] 1458785K->1120688K(2520704K), 9.4584090 secs]

這個問題的產生是由於救助空間不夠,從而向年老代轉移對象,年老代沒有足夠的空間來容納這些對象,導致一次full gc的產生。解決這個問題的辦法有兩種完全相反的傾向:增大救助空間、增大年老代或者去掉救助空間。增大救助空間就是調整-XX:SurvivorRatio參數,這個參數是Eden區和Survivor區的大小比值,默認是32,也就是說Eden區是Survivor區的32倍大小,要注意Survivo是有兩個區的,因此Surivivor其實佔整個young genertation的1/34。調小這個參數將增大survivor區,讓對象儘量在survitor區呆長一點,減少進入年老代的對象。去掉救助空間的想法是讓大部分不能馬上回收的數據儘快進入年老代,加快年老代的回收頻率,減少年老代暴漲的可能性,這個是通過將-XX:SurvivorRatio 設置成比較大的值(比如65536)來做到。在我們的應用中,將young generation設置成256M,這個值相對來說比較大了,而救助空間設置成默認大小(1/34),從壓測情況來看,沒有出現prommotion failed的現象,年輕代比較大,從GC日誌來看,minor gc的時間也在5-20毫秒內,還可以接受,因此暫不調整。

Concurrent mode failed的產生是由於CMS回收年老代的速度太慢,導致年老代在CMS完成前就被沾滿,引起full gc,避免這個現象的產生就是調小-XX:CMSInitiatingOccupancyFraction參數的值,讓CMS更早更頻繁的觸發,降低年老代被沾滿的可能。我們的應用暫時負載比較低,在生產環境上年老代的增長非常緩慢,因此暫時設置此參數爲80。在壓測環境下,這個參數的表現還可以,沒有出現過Concurrent mode failed。

   

    在初步確定CMS參數後,系統運行了幾天,今天嘗試在線上打開了GC日誌,按阿寶同學的說法是gc日誌的開銷比之jstat還小,打開之後發現確實影響很小。打開GC日誌之後又發現幾個隱藏的問題比較有價值,這裏記錄下。

   首先是系統在啓動的時候有一次System.gc()調用引起的full gc,日誌輸出類似這樣:

1.201: [Full GC (System) 1.201: [CMS: 0K->797K(1310720K), 0.1090540 secs] 29499K->797K(1546688K), [CMS Perm : 5550K->5547K(65536K)], 0.1091860 secs] [Times: user=0.05 sys=0.06, real=0.11 secs]
   可以確認的是我們系統裏的代碼絕對沒有調用System.gc()方法,但是不保證第三方代碼有調用,通過搜索代碼引用,後來定位到了mina的ByteBuffer創建上面。Mina 1.1封裝的ByteBuffer的allocate()方法默認創建的是Direct ByteBuffer,而DirectByteBuffer的構造函數裏調用了
Bits.reserveMemory(cap);

這個方法強制調用了System.gc():
static void reserveMemory(long size) {

    
synchronized (Bits.class) {
        
if (!memoryLimitSet && VM.isBooted()) {
        maxMemory 
= VM.maxDirectMemory();
        memoryLimitSet 
= true;
        }
        
if (size <= maxMemory - reservedMemory) {
        reservedMemory 
+= size;
        
return;
        }
    }

    System.gc();
    
try {
        Thread.sleep(
100);
    } 
catch (InterruptedException x) {
        
// Restore interrupt status
        Thread.currentThread().interrupt();
    }
    
synchronized (Bits.class) {
        
if (reservedMemory + size > maxMemory)
        
throw new OutOfMemoryError("Direct buffer memory");
        reservedMemory 
+= size;
    }

    }
    調用這個方法是爲了用戶對Direct ByteBuffer的內存可控。而在我們系統中使用的通訊層初始化Decoder的時候通過Mina 1.1創建了一個Direct ByteBuffer,導致了這一次強制的full gc。這個Buffer因爲是長期持有的,因此創建Direct類型也還可以接受。

    但是在這次GC後,又發現每隔一個小時就有一次System.gc()引起的full gc,這就太難以忍受了,日誌大概是這樣,注意間隔時間都是3600秒左右:
10570.672: [Full GC (System) 10570.672: [CMS: 779199K->107679K(1310720K), 1.2957430 secs] 872163K->107679K(1546688K), [CMS Perm : 23993K->15595K(65536K)], 1.2959630 secs] [Times: user=1.27 sys=0.02, real=1.30 secs] 
14171.971: [Full GC (System) 14171.971: [CMS: 680799K->83681K(1310720K), 1.0171580 secs] 836740K->83681K(1546688K), [CMS Perm : 20215K->15599K(65536K)], 1.0173850 secs] [Times: user=0.97 sys=0.01, real=1.02 secs] 
17774.020: [Full GC (System) 17774.020: [CMS: 676201K->79331K(1310720K), 0.9652670 secs] 817596K->79331K(1546688K), [CMS Perm : 22808K->15619K(65536K)], 0.9655150 secs] [Times: user=0.93 sys=0.02, real=0.97 secs] 
21374.989: [Full GC (System) 21374.989: [CMS: 677818K->78590K(1310720K), 0.9297080 secs] 822317K->78590K(1546688K), [CMS Perm : 16435K->15593K(65536K)], 0.9299620 secs] [Times: user=0.89 sys=0.01, real=0.93 secs] 
24976.948: [Full GC (System) 24976.948: [CMS: 659511K->77608K(1310720K), 0.9255360 secs] 794004K->77608K(1546688K), [CMS Perm : 22359K->15594K(65536K)], 0.9257760 secs] [Times: user=0.88 sys=0.02, real=0.93 secs] 
28578.892: [Full GC (System) 28578.892: [CMS: 562058K->77572K(1310720K), 0.8365500 secs] 735072K->77572K(1546688K), [CMS Perm : 15840K->15610K(65536K)], 0.8367990 secs] [Times: user=0.82 sys=0.00, real=0.84 secs] 
32179.731: [Full GC (System) 32179.732: [CMS: 549874K->77224K(1310720K), 0.7864400 secs] 561803K->77224K(1546688K), [CMS Perm : 16016K->15597K(65536K)], 0.7866540 secs] [Times: user=0.75 sys=0.01, real=0.79 secs]

    搜遍了源碼和依賴庫,沒有再發現顯式的gc調用,問題只能出在運行時上,突然想起我們的系統使用RMI暴露JMX給監控程序,監控程序通過RMI連接JMX監控系統和告警等,會不會是RMI的分佈式垃圾收集導致的?果然,一查資料,RMI的分佈式收集會強制調用System.gc()來進行分佈式GC,server端的間隔恰好是一個小時,這個參數可以通過:
-Dsun.rmi.dgc.server.gcInterval=3600000
來調整。調長時間是一個解決辦法,但是我們更希望能不出現顯式的GC調用,禁止顯式GC調用通過-XX:+DisableExplicitGC是一個辦法,但是禁止了分佈式GC會導致什麼問題卻是心理沒底,畢竟我們的JMX調用還是很頻繁的,幸運的是JDK6還提供了另一個選項-XX:+ExplicitGCInvokesConcurrent,允許System.gc()也併發運行,調整DGC時間間隔加上這個選項雙管齊下徹底解決了full gc的隱患。

    打開GC日誌後發現的另一個問題是remark的時間過長,已經啓用了並行remark,但是時間還是經常超過200毫秒,這個可能的原因有兩個:我們的年老代太大或者觸發CMS的閥值太高了,CMS進行的時候年老代裏的對象已經太多。初步的計劃是調小-XX:SurvivorRatio增大救助空間並且降低-XX:CMSInitiatingOccupancyFraction這個閥值。此外,還找到另一個可選參數-XX:+CMSScavengeBeforeRemark,啓用這個選項後,強制remark之前開始一次minor gc,減少remark的暫停時間,但是在remark之後也將立即開始又一次相對較長時間minor gc,如果你的minor gc很快的話可以考慮下這個選項,暫未實驗。


    本以爲在上篇定稿的參數後應該能有比較好的表現,然後實際的表現大出我的意料,cms回收觸發非常頻繁,雖然每次都只是10-50毫秒,但是次數12個小時內能達到180多次,這顯然不正常。通過gc日誌和jstat可以看到,每次old區還在5%左右就開始進行CMS,此時的perm區也才30%,這兩個數字有浮動並且CMS觸發的時間上也沒有規律,在測試環境和生產環境中都是如此。

    那麼最後是怎麼解決的呢?其實沒有解決。我只是替換了一個參數就沒再發生這個現象,上文提到爲了避免System.gc()調用引起的full gc,使用了jdk6引入的新參數-XX:+ExplicitGCInvokesConcurrent來讓System.gc()併發執行,但是測試表明恰恰是這個參數引起了CMS的頻繁發生,去掉這個參數就沒有那個奇特的現象。重複檢查了代碼,並且再次查看了GC日誌,沒有再發現有System.gc()的調用,我暫時將原因歸結於使用了ExplicitGCInvokesConcurrent參數後其他方法觸發了CMS,如果有知曉的朋友請留言告知,最後的方案還是徹底禁掉了顯式GC調用。最終定稿的參數:

-server -Xms1536m -Xmx1536m -XX:NewSize=256m -XX:MaxNewSize=256m
-XX:PermSize=64m -XX:MaxPermSize=64m -XX:+UseConcMarkSweepGC
-XX:+UseCMSCompactAtFullCollection -XX:CMSInitiatingOccupancyFraction=70
-XX:+CMSParallelRemarkEnabled -XX:SoftRefLRUPolicyMSPerMB=0
-XX:+CMSClassUnloadingEnabled -XX:+DisableExplicitGC
-XX:SurvivorRatio=8

    刪除了+CMSPermGenSweepingEnabled,這個參數在jdk6上跟
-XX:+CMSClassUnloadingEnabled作用重疊了,如果你還跑在jdk5上面,那麼應該使用這個參數。救助空間設置爲NewSize的1/10,也就是25M左右,讓年輕代儘量回收,防止年輕對象跑到年老代過早觸發CMS甚至full gc。CMS的觸發閥值下降到70%,因爲年老代增長較慢,寧願回收次數多一點,降低長暫停的可能。

    24小時內的某臺生產機器的表現,通過jstat觀察:

  S0     S1     E      O      P     YGC     YGCT    FGC    FGCT     GCT   
 
39.70   0.00   5.59  15.15  28.99  20260  326.041    14    0.592  326.633
 
39.70   0.00  65.49  15.15  28.99  20260  326.041    14    0.592  326.633
  
0.00  36.93  19.37  15.16  29.01  20261  326.059    14    0.592  326.650
  
0.00  36.93  93.23  15.16  29.01  20261  326.059    14    0.592  326.650
 
34.04   0.00  59.62  15.17  29.01  20262  326.076    14    0.592  326.668
  
0.00  38.55  12.76  15.19  29.01  20263  326.094    14    0.592  326.686
  
0.00  38.55  65.48  15.19  29.01  20263  326.094    14    0.592  326.686

    CMS兩次暫停時間總和在100ms以下,minor gc平均一次執行花了16ms,平均3-4秒發生一次。暫時來看還不錯,也許還可以適當調小一下NewSize,加快以下minor gc。

    此次調整總共花了大概一週多的時間,由於經驗不足,還是走了不少彎路,幸好最終的結果還可以,也讓自己對cms gc有比較深入的瞭解。我們的系統在周4晚上已經全部更新上線,從內部測試、壓測、日常測試、beta測試以來,每個階段都發現幾個隱蔽的問題,在上線後暫時沒有再發現問題,證明這個流程還是很有意義的,我過去對流程充滿偏見,現在看來是可笑的。總結我在淘寶5個月越來學習到的東西,幾個關鍵詞:認真、負責、細心、快樂。


參考資料:
JDK5.0垃圾收集優化之--Don't Pause》 by 江南白衣
《記一次Java GC調整經歷》1,2 by Arbow
Java SE 6 HotSpot[tm] Virtual Machine Garbage Collection Tuning
Tuning Garbage Collectionwith the 5.0 JavaTM Virtual Machine


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章