Java 垃圾回收權威指北

收藏待读

Java 垃圾回收權威指北

毫無疑問,GC(垃圾回收) 已經是現代編程語言標配,為了研究這個方向之前曾經寫過四篇《深入淺出垃圾回收》博文來介紹其理論,之後也看了不少網絡上關於 JDK GC 原理、優化的文章,質量參差不齊,其中理解有誤的文字以訛傳訛,遍布各地,更是把初學者弄的暈頭轉向。

不僅僅是個人開發者的文章,一些 大廠的官博 也有錯誤。

本文在實驗+閱讀 openjdk 源碼的基礎上,整理出一份相對來說比較靠譜的資料,供大家參考。

預備知識

術語

為方便理解 GC 算法時,需要先介紹一些常見的名詞

  • mutator,應用程序的線程
  • collector,用於進行垃圾回收的線程
  • concurrent(並發),指 collector 與 mutator 可以並發執行
  • parallel(並行),指 collector 是多線程的,可以利用多核 CPU 工作
  • young/old(也稱Tenured) 代,根據大多數對象「朝生夕死」的特點,現代 GC 都是分代

一個 gc 算法可以同時具有 concurrent/parallel 的特性,或者只具有一個。

JDK 版本

  • HotSpot 1.8.0_172
  • openjdk8u (changeset: 2298:1c0d5a15ab4c)

為了方便查看當前版本 JVM 支持的選項,建議配置下面這個 alias

alias jflags='java -XX:+UnlockDiagnosticVMOptions -XX:+PrintFlagsFinal -version'

然後就可以用 jflags | grep XXX 的方式來定位選項與其默認值了。

打印 GC 信息

-verbose:gc
-Xloggc:/data/logs/gc-%t.log
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-XX:+PrintGCCause
-XX:+PrintTenuringDistribution
-XX:+UseGCLogFileRotation
-XX:NumberOfGCLogFiles=10
-XX:GCLogFileSize=50M
-XX:+PrintPromotionFailure

JDK 中支持的 GC

Java 8 中默認集成了哪些 GC 實現呢? jflags 可以告訴我們

$ jflags |  grep "Use.*GC"
     bool UseAdaptiveGCBoundary                     = false                               {product}
     bool UseAdaptiveSizeDecayMajorGCCost           = true                                {product}
     bool UseAdaptiveSizePolicyWithSystemGC         = false                               {product}
     bool UseAutoGCSelectPolicy                     = false                               {product}
     bool UseConcMarkSweepGC                        = false                               {product}
     bool UseDynamicNumberOfGCThreads               = false                               {product}
     bool UseG1GC                                   = false                               {product}
     bool UseGCLogFileRotation                      = false                               {product}
     bool UseGCOverheadLimit                        = true                                {product}
     bool UseGCTaskAffinity                         = false                               {product}
     bool UseMaximumCompactionOnSystemGC            = true                                {product}
     bool UseParNewGC                               = false                               {product}
     bool UseParallelGC                             = false                               {product}
     bool UseParallelOldGC                          = false                               {product}
     bool UseSerialGC                               = false                               {product}
java version "1.8.0_172"
Java(TM) SE Runtime Environment (build 1.8.0_172-b11)
Java HotSpot(TM) 64-Bit Server VM (build 25.172-b11, mixed mode)

肉眼篩選下,就知道有如下幾個相關配置:

  • UseSerialGC
  • UseParNewGC,
  • UseParallelGC
  • UseParallelOldGC
  • UseConcMarkSweepGC
  • UseG1GC

每個配置項都會對應兩個 collector ,表示對 young/old 的不同收集方式。而且由於 JVM 不斷的演化,不同 collector 的組合方式其實很複雜。而且在 Java 7u4 後,UseParallelGC 與 UseParallelOldGC 其實是等價的,openjdk 中有如下代碼:

// hotspot/src/share/vm/runtime/arguments.cpp#set_gc_specific_flags
// Set per-collector flags
if (UseParallelGC || UseParallelOldGC) {
  set_parallel_gc_flags();
} else if (UseConcMarkSweepGC) { // Should be done before ParNew check below
  set_cms_and_parnew_gc_flags();
} else if (UseParNewGC) {  // Skipped if CMS is set above
  set_parnew_gc_flags();
} else if (UseG1GC) {
  set_g1_gc_flags();
}

我們可以用 下面的代碼 測試使用不同配置時,young/old 代默認所使用的 collector:

package gc;
// 省略 import 語句
public class WhichGC {
    public static void main(String[] args) {
        try {
            List gcMxBeans = ManagementFactory.getGarbageCollectorMXBeans();
            for (GarbageCollectorMXBean gcMxBean : gcMxBeans) {
                System.out.println(gcMxBean.getName());
            }
        } catch (Exception exp) {
            System.err.println(exp);
        }
    }
}
$ java gc.WhichGC  # 兩個輸出分別表示 young/old 代的 collector
PS Scavenge
PS MarkSweep

$ java -XX:+UseSerialGC gc.WhichGC
Copy
MarkSweepCompact

$ java -XX:+UseParNewGC gc.WhichGC # 注意提示
Java HotSpot(TM) 64-Bit Server VM warning: Using the ParNew young collector with the Serial old collector is deprecated and will likely be removed in a future release
ParNew
MarkSweepCompact

$ java -XX:+UseParallelGC gc.WhichGC
PS Scavenge
PS MarkSweep # 雖然名為 MarkSweep,但其實現是 mark-sweep-compact

$ java -XX:+UseParallelOldGC gc.WhichGC # 與上面輸出一致,不加 flag 時這樣同樣的輸出
PS Scavenge
PS MarkSweep

$ java -XX:+UseConcMarkSweepGC gc.WhichGC # ParNew 中 Par 表示 parallel,表明採用並行方式收集 young 代
ParNew
ConcurrentMarkSweep  # 注意這裡沒有 compact 過程,也就是說 CMS 的 old 可能會產生碎片

$ java -XX:+UseG1GC gc.WhichGC
G1 Young Generation
G1 Old Generation

PS 開頭的系列 collector 是 Java5u6 開始引入的。按照 R 大的說法 ,這之前的 collector 都是在一個框架內開發的,所以 young/old 代的 collector 可以任意搭配,但 PS 系列與後來的 G1 不是在這個框架內的,所以只能單獨使用。

使用 UseSerialGC 時 young 代的 collector 是 Copy,這是單線程的,PS Scavenge 與 ParNew 分別對其並行化,至於這兩個並行 young 代 collector 的區別呢?這裡再引用 R 大的回復

  1. PS以前是廣度優先順序來遍歷對象圖的,JDK6的時候改為默認用深度優先順序遍歷,並留有一個UseDepthFirstScavengeOrder參數來選擇是用深度還是廣度優先。在JDK6u18之後這個參數被去掉,PS變為只用深度優先遍歷。ParNew則是一直都只用廣度優先順序來遍歷
  2. PS完整實現了adaptive size policy,而ParNew及「分代式GC框架」內的其它GC都沒有實現完(倒不是不能實現,就是麻煩+沒人力資源去做)。所以千萬千萬別在用ParNew+CMS的組合下用UseAdaptiveSizePolicy,請只在使用UseParallelGC或UseParallelOldGC的時候用它。
  3. 由於在「分代式GC框架」內,ParNew可以跟CMS搭配使用,而ParallelScavenge不能。當時ParNew GC被從Exact VM移植到HotSpot VM的最大原因就是為了跟CMS搭配使用。
  4. 在PS成為主要的throughput GC之後,它還實現了針對NUMA的優化;而ParNew一直沒有得到NUMA優化的實現。

如果你對上面所說的 mark/sweep/compact 這些名詞不了解,建議參考下面這篇文章:

其實原理很簡單,和我們整理抽屜差不多,找出沒用的垃圾,丟出去,然後把剩下的堆一邊去。但是別忘了

The evil always comes from details!

怎麼定義「沒用」?丟垃圾時還允不允許同時向抽屜里放新東西?如果允許放,怎麼區別出來,以防止被誤丟?抽屜小時,一個人整理還算快,如果抽屜很大,多個人怎麼協作?

核心流程指北

ParallelGC

SerialGC 採用的收集方式十分簡單,沒有並行、並發,一般用在資源有限的設備中。由於其簡單,對其也沒什麼好說的,畢竟也沒怎麼用過 🙂

ParallelGC 相比之下,使用多線程來回收,這就有些意思了,比如

  • 多個GC線程如何實現同步,需要注意一點,ParallelGC 運行時會 STW,因此不存在與 mutator 同步問題
  • 回收時,並行度如何選擇(也就是 GC 對應用本身的 overhead)

不過比較可惜,cpp 在大二寫完幾個 console 應用後,就一直沒怎麼用過了,因為也就沒發去探究多個 GC 線程如何實現同步,大略掃一下 parNewGeneration.cpp 這個文件,大概是這樣的:

每個 GC 線程對應一個 queue(叫 ObjToScanQueue),然後還支持不同 GC 線程間 steal,保證充分利用 cpu

// ParNewGeneration 構造方法
for (uint i1 = 0; i1 register_queue(i1, q);
}
// do_void 方法
while (true) {

  ......
  // We have no local work, attempt to steal from other threads.

  // attempt to steal work from promoted.
  if (task_queues()->steal(par_scan_state()->thread_num(),
                           par_scan_state()->hash_seed(),
                           obj_to_scan)) {
    bool res = work_q->push(obj_to_scan);
    assert(res, "Empty queue should have room for a push.");

    //   if successful, goto Start.
    continue;

    // try global overflow list.
  } else if (par_gen()->take_from_overflow_list(par_scan_state())) {
    continue;
  }
  .......
}

下面還是重點說一下我們開發者能控制的選項,

  • -XX:MaxGCPauseMillis= 應用停頓(STW)的的最大時間
  • -XX:GCTimeRatio= GC 時間占整個應用的佔比,默認 99。需要注意的是,它是這麼用的 1/(1+N) ,即默認 GC 占應用時間 1%。這麼說來這個選項的意思貌似正好反了!
    其實不僅僅是這個,類似的還有 NewRatio SurvivorRatio ,喜歡八卦的可以看看 《我可能在跑一個假GC》

當然,上面兩個指標是軟限制,GC 會採用後面提到的自適應策略(Ergonomics)來調整 young/old 代大小來滿足。

Ergonomics

每次 gc 後,會記錄一些統計信息,比如 pause time,然後根據這些信息來決定

  1. 目標是否滿足
  2. 是否需要調整代大小

可以通過 -XX:AdaptiveSizePolicyOutputInterval=N 來打印出每次的調整,N 表示每隔 N 次 GC 打印。

默認情況下,一個代增長或縮小是按照固定百分比,這樣有助於達到指定大小。默認增加以 20% 的速率,縮小以 5%。也可以自己設定

-XX:YoungGenerationSizeIncrement=
-XX:TenuredGenerationSizeIncrement=
-XX:AdaptiveSizeDecrementScaleFactor=
# 如果增長的增量是 X,那麼減少的減量則為 X/D

當然,一般情況下是不需要自己設置這三個值的,除非你有明確理由。

使用場景

ParallelGC 另一個名字就表明了它的用途:吞吐量 collector。主要用在對延遲要求低,更看重吞吐量的應用上。

我們公司的數據導入導出、跑報表的定時任務,用的就是這個 GC。(能提供數據導入導出的都是良心公司呀!)

一般利用自適應策略就能滿足需求。線上的日誌大概這樣子:

2018-12-27T22:14:19.006+0800: 5433.841: [GC (Allocation Failure) [PSYoungGen: 606785K->3041K(656896K)] 746943K->143356K(2055168K), 0.0157837 secs] [Times: user=0.03 sys=0.01, real=0.02 secs]
    UseAdaptiveSizePolicy actions to meet  *** reduced footprint ***
                       GC overhead (%)
    Young generation:        0.02         (attempted to shrink)
    Tenured generation:      0.00         (attempted to shrink)
    Tenuring threshold:    (attempted to decrease to balance GC costs) = 1
2018-12-27T22:21:36.581+0800: 5871.417: [GC (Allocation Failure) [PSYoungGen: 615905K->3089K(654848K)] 756220K->143504K(2053120K), 0.0157796 secs] [Times: user=0.02 sys=0.00, real=0.01 secs]
    UseAdaptiveSizePolicy actions to meet  *** reduced footprint ***
                       GC overhead (%)
    Young generation:        0.01         (attempted to shrink)
    Tenured generation:      0.00         (attempted to shrink)
    Tenuring threshold:    (attempted to decrease to balance GC costs) = 1
2018-12-27T22:28:51.669+0800: 6306.505: [GC (Allocation Failure) [PSYoungGen: 615953K->3089K(660992K)] 756368K->143664K(2059264K), 0.0178418 secs] [Times: user=0.03 sys=0.01, real=0.02 secs]
    UseAdaptiveSizePolicy actions to meet  *** reduced footprint ***
                       GC overhead (%)
    Young generation:        0.01         (attempted to shrink)
    Tenured generation:      0.00         (attempted to shrink)
    Tenuring threshold:    (attempted to decrease to balance GC costs) = 1
2018-12-27T22:36:17.738+0800: 6752.573: [GC (Allocation Failure) [PSYoungGen: 624145K->2896K(658944K)] 764720K->143576K(2057216K), 0.0144179 secs] [Times: user=0.02 sys=0.01, real=0.01 secs]
    UseAdaptiveSizePolicy actions to meet  *** reduced footprint ***
                       GC overhead (%)
    Young generation:        0.01         (attempted to shrink)
    Tenured generation:      0.00         (attempted to shrink)
    Tenuring threshold:    (attempted to decrease to balance GC costs) = 1
2018-12-27T22:43:40.208+0800: 7195.043: [GC (Allocation Failure) [PSYoungGen: 623952K->2976K(665088K)] 764632K->143720K(2063360K), 0.0135656 secs] [Times: user=0.03 sys=0.01, real=0.02 secs]
    UseAdaptiveSizePolicy actions to meet  *** reduced footprint ***
                       GC overhead (%)
    Young generation:        0.01         (attempted to shrink)
    Tenured generation:      0.00         (attempted to shrink)
    Tenuring threshold:    (attempted to decrease to balance GC costs) = 1
2018-12-27T22:48:59.110+0800: 7513.945: [GC (Allocation Failure) [PSYoungGen: 632224K->5393K(663040K)] 772968K->146241K(2061312K), 0.0230613 secs] [Times: user=0.05 sys=0.01, real=0.02 secs]
    UseAdaptiveSizePolicy actions to meet  *** reduced footprint ***
                       GC overhead (%)
    Young generation:        0.01         (attempted to shrink)
    Tenured generation:      0.00         (attempted to shrink)
    Tenuring threshold:    (attempted to decrease to balance GC costs) = 1
2018-12-27T22:54:05.871+0800: 7820.706: [GC (Allocation Failure) [PSYoungGen: 634641K->4785K(669696K)] 775489K->147601K(2067968K), 0.0173448 secs] [Times: user=0.04 sys=0.01, real=0.02 secs]
    UseAdaptiveSizePolicy actions to meet  *** reduced footprint ***
                       GC overhead (%)
    Young generation:        0.01         (attempted to shrink)
    Tenured generation:      0.00         (attempted to shrink)
    Tenuring threshold:    (attempted to decrease to balance GC costs) = 1

CMS

CMS 相比於 ParallelGC,支持並髮式的回收,雖然個別環節還是需要 STW,但相比之前已經小了很多;另一點不同是 old 代在 sweep 後,沒有 compact 過程,而是通過 freelist 來將空閑地址串起來。CMS 具體流程還是參考下面的文章:

上述文章會針對 gc 日誌裏面的每行含義做解釋,務必弄清楚每一個數字含義,這是今後調試優化的基礎。網站找了個 比較詳細的圖 供大家參考:

Java 垃圾回收權威指北

之前在有贊的同事阿杜寫過一篇 《不可錯過的CMS學習筆記》 推薦大家看看,主要是文章的思路比較欣賞,帶着問題去探索。重申下 CMS 的特點:

  • CMS 作用於 old 區,與 mutator 並發執行(因為是多線程的,所以也是並行的);默認與 young 代 ParNew 算法一起工作

下面重點說一下 CMS 中誤傳最廣的 CMF 與內存碎片問題。

Concurrent mode failure

在每次 young gc 開始前,collector 都需要確保 old 代有足夠的空間來容納新晉級的對象(通過之前GC的統計估計),如果判斷不足,或者當前判斷足夠,但是真正晉級對象時空間不夠了(即發生 Promotion failure),那麼就會發生 Concurrent mode failure(後面簡寫 CMF),CMF 發生時,不一定會進行 Full GC,而是這樣的:

如果這時 CMS 會正在運行,則會被中斷,然後根據 UseCMSCompactAtFullCollection、CMSFullGCsBeforeCompaction 和當前收集狀態去決定後面的行為

有兩種選擇:

  1. 使用跟Serial Old GC一樣的LISP2算法的mark-compact來做 Full GC,或
  2. 用CMS自己的mark-sweep來做不並發的(串行的)old generation GC (這種串行的模式在 openjdk 中稱為 foreground collector,與此對應,並發模型的 CMS 稱為 background collector)

UseCMSCompactAtFullCollection默認為true,CMSFullGCsBeforeCompaction默認是0,這樣的組合保證CMS默認不使用foreground collector,而是用Serial Old GC的方式來進行 Full GC,而且在 JDK9 中,徹底去掉了這兩個參數以及 foreground GC 模式,具體見: JDK-8010202: Remove CMS foreground collection ,所以這兩個參數就不需要再去用了。

這裡還需要注意,上述兩個備選策略的異同,它們所採用的算法與作用範圍均不同:

  1. Serial Old GC的算法是mark-compact(也可以叫做mark-sweep-compact,但要注意它不是「mark-sweep」)。具體算法名是LISP2。它收集的範圍是整個GC堆,包括Java heap的young generation和old generation,以及non-Java heap的permanent generation。因而其名 Full GC
  2. CMS的foreground collector的算法就是普通的mark-sweep。它收集的範圍只是CMS的old generation,而不包括其它generation。因而它在HotSpot VM里不叫做Full GC

解決 CMF 的方式,一般是儘早執行 CMS,可以通過下面兩個參數設置:

-XX:CMSInitiatingOccupancyFraction=60
-XX:+UseCMSInitiatingOccupancyOnly

上述兩個參數缺一不可,第一個表示 old 區佔用量超過 60% 時開始執行 CMS,第二個參數禁用掉 JVM 的自適應策略,如果不設置這個 JVM 可能會忽略第一個參數。

上述關於 CMF 解釋主要參考

內存碎片

Promotion failure 一般是由於 heap 內存碎片過多導致檢測空間足夠,但是真正晉級時卻沒有足夠連續的空間,監控 old 代碎片可以用下面的選項

-XX:+PrintGCDetails
-XX:+PrintPromotionFailure
-XX:PrintFLSStatistics=1

這時的 gc 日誌大致是這樣的

592.079: [ParNew (0: promotion failure size = 2698)  (promotion failed): 135865K->134943K(138240K), 0.1433555 secs]
Statistics for BinaryTreeDictionary:
------------------------------------
Total Free Space: 40115394
Max   Chunk Size: 38808526
Number of Blocks: 1360
Av.  Block  Size: 29496
Tree      Height: 22

重點是 Max Chunk Size 這個參數,如果這個值一直在減少,那麼說明碎片問題再加劇。解決碎片問題可以按照下面步驟:

  1. 儘可能提供較大的 old 空間,但是最好不要超過 32G, 超過了就沒法用壓縮指針了
  2. 儘早執行 CMS,即修改 initiating occupancy 參數
  3. 減少 PLAB,我具體還沒試過,可參考 Java GC, HotSpot』s CMS promotion buffers 這篇文章
  4. 應用盡量不要去分配巨型對象

調優

說到優化,讓很多人望而卻步,一方便有人不斷在說「不要過早優化」,另一方面在真正有問題時,不知道如何入手。這裡說個人的一些經驗供大家參考。

既然提到 GC 優化,首先要明確衡量 GC 的幾個指標,LinkedIn 在這方面值得借鑒,在 Tuning Java Garbage Collection for Web Services 提出了從 gc 日誌中可以獲知的 5 個指標:

  1. Allocation Rate: the size of the young generation divided by the time between young generation collections
  2. Promotion Rate: the change in usage of the old gen over time (excluding collections)
  3. Survivor Death Ratio: when looking at a log, the size of survivors in age N divided by the size of survivors in age N-1 in the previous collection
  4. Old Gen collection times: the total time between a CMS-initial-mark and the next CMS-concurrent-reset. You』ll want both your 『normal』 and the maximum observed
  5. Young Gen collection times: both normal and maximum. These are just the 「total collection time」 entries in the logs Old Gen Buffer:
    the promotion rate*the maximum Old Gen collection time*(1 + a little bit)
    

直接從純文本的 gc 日誌中得出這 5 項指標比較困難,還好有個比較好用的開源工具 gcplot ,藉助 docker,一行命令即可啟動

docker run -d -p 8080:80 gcplot/gcplot

實戰

利用 gcplot,我對公司內部 API 服務進行了一次優化,效果較為明顯:

優化前的配置:Xmx/Xms 均為 4G,CMSInitiatingOccupancyFraction=60,下面是使用 gcplot 得到的一些數據

PercentilesSTW Pause (ms)
50%22.203
90%32.872
95%40.255
99%76.724
99.9%317.584
  • STW Pause per Minute: 3.396 secs
  • STW Events per Minute: 133
Promoted Total17.313 GB
Promotion Rate (MB/Sec)5.99
Allocated Total5.053 TB
Allocation Rate (MB/Sec)1273.73

優化後的配置:Xmx/Xms 均為 4G, NewRatio 為 1, CMSInitiatingOccupancyFraction=80。

這麼修改主要是增加 young 區空間,因為對於 Web 服務來說,除了一些 cache 外,沒什麼常駐內存的對象;通過把 OccupancyFraction 調大,延遲 CMS 發生頻率,還是基於前面的推論,大多數對象不會晉級到 old 代,所以發生碎片的概率也不會怎麼大。下面是優化後的相關參數,也證明了上面的猜想

percentilesSTW pause(ms)
50%19.75
90%30.334
95%35.441
99%53.5
99.9%120.008
  • STW Pause per Minute: 826.607 ms
  • STW Events per Minute: 38
Promoted Total6.182 GB
Promotion Rate (MB/Sec)0.29
Allocated Total28.254 TB
Allocation Rate (MB/Sec)1121.29

參考資料

雖然本文一開始指出 LinkedIn 文章中存在理解誤差,但是那篇文章的思路還是值得解決,下面再次給出鏈接

總結

上面基本把 ParallelGC 與 CMS 核心點過了一遍,然後順帶介紹了下優化,主要還是熟悉 GC 日誌中的每個指標含義,理解透後再去決定是否需要優化。關於 G1 本文沒有過多介紹,主要是用的確實不多,後面會嘗試把服務升級到 G1 後再來寫寫它。

本文一開始就說網絡上關於 GC 的誤解很多,本文可能也是這樣的,雖然我已經儘可能保證「正確」,但還是需要大家帶着辯證的眼光來看。元芳,你怎麼看?

擴展閱讀

原文 : Keep Writing Codes

相關閱讀

免责声明:本文内容来源于Keep Writing Codes,已注明原文出处和链接,文章观点不代表立场,如若侵犯到您的权益,或涉不实谣言,敬请向我们提出检举。