關於生產環境改用G1垃圾收集器的思考

程式設計一生發表於2021-04-04

背景

    由於我們的業務量非常大,響應延遲要求高。目前沿用的老的ParNew+CMS已經不能支撐業務的需求。平均一臺機器在1個月內有1次秒級別的stop the world。對系統來說是個巨大的隱患。所以我們採用測試環境壓測和逐漸在一些小的試點專案中生產環境引用G1來驗證是否可以解決問題以及可能會引入的風險。

 

預備知識

    垃圾回收首先要判斷一個物件是不是垃圾,Java裡不用引用計數器演算法,都是用從GC root開始的可達性分析演算法,在實際實現的時候就是標記。所以不管是什麼新生代、老年代回收,都有標記的步驟。因為目前市面上能見到的版本都是從分代垃圾收集器開始的,所以更原始的這裡就不再提了。

    上圖中的Serial、ParNew、Parallel Scavenge都是年輕代演算法,CMS、Serial Old、Parallel Old是老年代演算法。直接連線的線之間才可以配合使用。一般年輕代和老年代的總空間比例是1:2。小的年輕代可以保證更快的進行Young GC。

 

    年輕代演算法都是基於複製演算法,準確的說是標記-複製演算法。因為第一步是要先標記可達物件,然後把可達物件複製到一塊空區域,再把原來的區域清空。區別只是Serial是序列的,Serial工作過程中使用者執行緒都是停掉的。ParNew和Paralled Scavenge是並行的。所謂並行是指多個執行緒同時做垃圾收集的事情,但是仍然是要停下使用者執行緒的工作的。Paralled Scavenge比ParNew的一個優勢在於Paralled Scavenge可以設定自適應調節Eden與Survivor區的比例、晉升老年代的比例。

 

    Serial Old老年代演算法採用的是標記整理演算法,Paralled Old老年代演算法採用的也是標記整理演算法,不同點只是一個是完全序列的,Paralled Old垃圾回收的時候有多個執行緒來跑,但是不可以跟使用者執行緒一起跑。但是不管年輕代、老年代,以及目前市面上的所有演算法都不能避免STW(stop the world停止使用者執行緒)。

    CMS是Concurrent Mark Sweep的縮寫,就是併發標記清除演算法,它與其他兩種老年代演算法不同的是它是隻標記清除,不整理。目標是減少STW。上面的圖中標記了CMS不能配合Paralled Scavenge使用,只能用ParNew。大家想想為啥咧。

    上面說了Paralled Scavenge的優勢在於可以自動調節。而CMS是隻清除操作,不整理。這種演算法沒有辦法應對空間的變化。我看到的文章都沒有對它們為何不能配合使用做解釋。所以這裡強調下。

CMS過程分為下面4步:

 

 

    上面4步中,初始標記和重新標記其實是一個東西執行兩次,就是為了避免在併發標記過程中物件關係有變化。通常來講STW引用執行緒的停頓時間:

Serial Old > Paralled Old > CMS。但是CMS有個致命的弱點,CMS必須要在老程式碼堆記憶體用盡之前完成垃圾回收,否則會觸發擔保機制,退化成Serial Old來垃圾回收,這時會造成較大的STW停頓。所以JDK1.8預設的垃圾收集器是Paralled Scavenge+Paralled Old方式。

 

G1垃圾回收

    G1的設計目標是為了替代CMS,它不存在退化為Serial的問題,聲稱STW時間不超過10ms。主要的特點如下:

    在15年16年的時候,很多公司都有使用G1的需求,但是那時候G1由於演算法複雜,設計開發困難,所以還不成熟。在17年以後,已經被JDK9選為預設垃圾收集器。注意JDK8的預設垃圾收集器是Paralled Scanvenge,不採用CMS是因為CMS不穩定可能會退化成Serial Old。所以能被選為預設收集器說明它的穩定性是受官方認可的。

    G1的原理是分治法,將堆分成若干個等大的區域。優先回收垃圾多的區域。

    但是劃分的區域之間有可能有相互引用。所以引出了Card Table和Rememberd Set的概念。Rememberd Set(RS)裡存的是區域之間的引用。Card Table是把區域進一步細分。搜引用的時候只需要搜尋很小的子區域。RS可以看成是一個雜湊表,就是存引用關係的。是一種典型的空間換時間的做法。

    對於每個區域使用的垃圾收集演算法,實際上G1沒有什麼創新,年輕代還是並行拷貝,老年代主要採用併發標記配合增量壓縮。演算法方面也比較成熟了。

 

 

各種垃圾收集器的對比

 

 

怎樣選擇合適自己業務的垃圾收集器

    從理論上,G1是為了替代CMS。我們這邊的本質需求也是降低STW,也已經很成熟了。併發量大穩定性高的公司也在用。公司內部也有使用的經驗。沒有什麼問題。

    那就從實際上試驗一把看看實際執行是否符合預期,並且要測試對G1專門的引數做微調。特別是MaxGCPauseMillis這個引數,因為這個引數設定的是預期每次GC的最大停頓時間。如果設定的不合理,比如太小就會造成GC頻繁。如果太大,業務響應時間會很長。

實際上我有用實際程式碼模擬,但是為了資訊保安這裡自己用demo來說明。

JVM引數設定為:

-Xms4096m  //最大堆設定

-Xmx4096m  //最小堆設定

-XX:+UseG1GC  //使用G1垃圾收集器

-XX:MaxGCPauseMillis=20 //最大GC停頓時間,預設是200ms,這裡設定20ms

-XX:+PrintGCDetails //列印GC詳情日誌

-XX:+PrintStringTableStatistics //列印字串常量、引用常量統計

 -XX:+PrintSafepointStatistics  //列印停頓原因

-XX:+PrintGCApplicationStoppedTime //停頓時間輸出到GC日誌中

上面引數中除了堆大小設定、使用G1和設定預期最大停頓時間外都是便於觀察的統計資訊。設定好之後可以根據自己的業務構造合適的案例。調整引數觀察效果,同時也需要用cms的結果做對比。

[GC pause (G1 Humongous Allocation) (young), 0.0021237 secs]
   [Parallel Time: 1.4 ms, GC Workers: 8]
      [GC Worker Start (ms): Min: 3885.1, Avg: 3885.5, Max: 3886.4, Diff: 1.3]
      [Ext Root Scanning (ms): Min: 0.0, Avg: 0.2, Max: 0.5, Diff: 0.5, Sum: 1.3]
      [Update RS (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
         [Processed Buffers: Min: 0, Avg: 0.0, Max: 0, Diff: 0, Sum: 0]
      [Scan RS (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
      [Code Root Scanning (ms): Min: 0.0, Avg: 0.0, Max: 0.4, Diff: 0.4, Sum: 0.4]
      [Object Copy (ms): Min: 0.0, Avg: 0.7, Max: 1.0, Diff: 1.0, Sum: 5.3]
      [Termination (ms): Min: 0.0, Avg: 0.0, Max: 0.1, Diff: 0.1, Sum: 0.3]
         [Termination Attempts: Min: 1, Avg: 6.5, Max: 14, Diff: 13, Sum: 52]
      [GC Worker Other (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
      [GC Worker Total (ms): Min: 0.0, Avg: 0.9, Max: 1.3, Diff: 1.3, Sum: 7.4]
      [GC Worker End (ms): Min: 3886.4, Avg: 3886.4, Max: 3886.4, Diff: 0.0]
   [Code Root Fixup: 0.0 ms]
   [Code Root Purge: 0.0 ms]
   [Clear CT: 0.1 ms]
   [Other: 0.6 ms]
      [Choose CSet: 0.0 ms]
      [Ref Proc: 0.1 ms]
      [Ref Enq: 0.0 ms]
      [Redirty Cards: 0.1 ms]
      [Humongous Register: 0.2 ms]
      [Humongous Reclaim: 0.1 ms]
      [Free CSet: 0.0 ms]
   [Eden: 4096.0K(200.0M)->0.0B(202.0M) Survivors: 4096.0K->2048.0K Heap: 3405.5M(4096.0M)->3402.0M(4096.0M)]
 [Times: user=0.00 sys=0.00, real=0.00 secs] 
Total time for which application threads were stopped: 0.0027839 seconds, Stopping threads took: 0.0000253 seconds
[Full GC (Allocation Failure)  3401M->3401M(4096M), 0.0487029 secs]
   [Eden: 0.0B(202.0M)->0.0B(204.0M) Survivors: 2048.0K->0.0B Heap: 3402.0M(4096.0M)->3401.4M(4096.0M)], [Metaspace: 5853K->5853K(1056768K)]
 [Times: user=0.06 sys=0.00, real=0.05 secs] 
[Full GC (Allocation Failure)  3401M->3401M(4096M), 0.0376090 secs]
   [Eden: 0.0B(204.0M)->0.0B(204.0M) Survivors: 0.0B->0.0B Heap: 3401.4M(4096.0M)->3401.4M(4096.0M)], [Metaspace: 5853K->5849K(1056768K)]
 [Times: user=0.03 sys=0.00, real=0.04 secs] 
Total time for which application threads were stopped: 0.0868891 seconds, Stopping threads took: 0.0000192 seconds


Heap
 garbage-first heap   total 4194304K, used 3483005K [0x00000006c0000000, 0x00000006c0204000, 0x00000007c0000000)
  region size 2048K, 1 young (2048K), 0 survivors (0K)
 Metaspace       used 5924K, capacity 6074K, committed 6144K, reserved 1056768K
  class space    used 687K, capacity 722K, committed 768K, reserved 1048576K
         vmop                    [threads: total initially_running wait_to_block]    [time: spin block sync cleanup vmop] page_trap_count
1.121: no vm operation                  [      12          0              1    ]      [     0     7     7     0     0    ]  0   
1.226: Deoptimize                       [      12          0              0    ]      [     0     0     0     0     0    ]  0   
1.326: Deoptimize                       [      12          0              0    ]      [     0     0     0     0     0    ]  0   
1.399: Deoptimize                       [      12          0              0    ]      [     0     0     0     0     0    ]  0   
1.497: Deoptimize                       [      12          0              0    ]      [     0     0     0     0     0    ]  0   
2.409: G1IncCollectionPause             [      12          0              0    ]      [     0     0     0     0     4    ]  0   
2.417: CGC_Operation                    [      12          0              1    ]      [     0     1     1     0     5    ]  0   
2.425: CGC_Operation                    [      12          0              1    ]      [     0     4     4     0     1    ]  0   
3.431: no vm operation                  [      12          0              1    ]      [     0    17    17     0     0    ]  0   
3.885: G1IncCollectionPause             [      12          0              0    ]      [     0     0     0     0     2    ]  0   
3.888: G1CollectForAllocation           [      12          0              0    ]      [     0     0     0     0    86    ]  0   
4.037: Exit                             [      12          0              1    ]      [     0     0     0     0   339    ]  0   


Polling page always armed
Deoptimize                         4
CGC_Operation                      2
G1CollectForAllocation             1
G1IncCollectionPause               2
Exit                               1
    0 VM operations coalesced during safepoint
Maximum sync time     17 ms
Maximum vm operation time (except for Exit VM operation)     86 ms
SymbolTable statistics:
Number of buckets       :     20011 =    160088 bytes, avg   8.000
Number of entries       :     22112 =    530688 bytes, avg  24.000
Number of literals      :     22112 =    932040 bytes, avg  42.151
Total footprint         :           =   1622816 bytes
Average bucket size     :     1.105
Variance of bucket size :     1.111
Std. dev. of bucket size:     1.054
Maximum bucket size     :         8
StringTable statistics:
Number of buckets       :     60013 =    480104 bytes, avg   8.000
Number of entries       :      2944 =     70656 bytes, avg  24.000
Number of literals      :      2944 =    238320 bytes, avg  80.951
Total footprint         :           =    789080 bytes
Average bucket size     :     0.049
Variance of bucket size :     0.049
Std. dev. of bucket size:     0.222
Maximum bucket size     :         3

  

 

    G1日誌會列印GC的詳細過程,便於觀察分析。PrintStringTableStatistics這個JVM引數列印出字串常量資訊,在JDK7之後,字串常量從永久代被移到堆記憶體中了,所以也會影響GC。

 

    建議做完調優之後,再用優化後的引數重跑用例,並jvisualvm這個jdk自帶工具觀察一段時間的GC情況。

 

總結

    我總結是否採用一個工具或技術,常規思路是這樣:

  1. 明確目標。這裡的目標就是要降低STW造成的延遲。

  2. 調查學習。要理解原理、優缺點,多個技術之間對比。

  3. 測試驗證。至少要用試驗報告的形式給出測試過程和結論。

  4. 做出調整。根據測試結果做出可能的是大局上的調整,比如和目前的系統不相容,或者是細節調整比如修改引數。

    上面這個思路也就是完整的PDCA的過程。

 

 

    一句話總結就是:目標先行,迴繞目標來做事。

 

相關閱讀

JAVA SPI(Service Provider Interface)原理、設計及原始碼解析

專治不會看原始碼的毛病--spring原始碼解析AOP篇

線上問題排查的四類方法

穩定性的海因裡希法則

相關文章