Java Garbage Collection (GC) 簡介

fcamel
fcamel的程式開發心得
9 min readOct 2, 2020

Java 發展多年,有多種不同 Garbage Collection (GC) 的實作,JVM 有提供參數可以換不同實作還有微調各實作的參數。這篇文章說明大方向的觀念,並提供詳細說明的連結。

TL; DR

  • 如果必須限制 GC 暫停時間在 10 ms 內,使用 Java 15 + ZGC。或是改用 Go 吧。
  • Java 9 開始預設用 G1 GC,如果希望多數情況控制在 100ms 內,調整 G1 GC 參數 -XX:MaxGCPauseMillis=100
  • 如果可接受通常 200ms 暫停時間, G1 GC 預設參數已是不錯的配置。

使用者在意的事

從使用者角度來看,我們關心:

  • 需要多少記憶體才能有效率地作 GC?
  • 應用程式是否會有短暫的暫停 (stop-the-world pause)?
  • 多少比例的 CPU 時間花在 GC?

由後兩者的取捨,衍生出兩大陣營的作法:

  • Concurrent GC: 目標是幾乎不會有 stop-the-world (STW)
  • Generational GC: 目標是減少因 GC 而占用的 CPU 時間

Java GC

Java 長期以來是用 Generational GC,自 Java 9 開始預設用 G1 GC。其特色是:

  • 盡可能縮短單次 stop-the-world (STW) 的時間,並提供參數決定期望 stop-the-world 的時間上限,預設 200ms。但這是 soft limit。
  • 參數較少,方便調整。

以我自身的例子來看,G1 滿準地讓 STW 的時間落在 200ms 左右,大概 5s 觸發一次 GC,占用 CPU 的時間 <4%。但數十台機器在一週內會有數次暫停到 2~3s。理論上降低 200ms 為 100ms 可讓程式有更快反應,副作用大概是會更頻繁觸發 GC,不確定占用 CPU 的時間會增加多少。

最新的 ZGC 則是 Concurrent GC,目標是 STW < 10ms:

* Max pause times of a few milliseconds (*)

* Pause times do not increase with the heap or live-set size (*)

* Handle heaps ranging from a 8MB to 16TB in size

由於它不是 generational GC,不確定在有大量長駐 objects (如 cache) 時,是否會浪費太多 CPU 時間。若服務需要提供 <10ms 回應的保證,值得試看看 ZGC。

Java 15 開始,ZGC 已可在產品中使用

Use the -XX:+UseZGC options to enable ZGC.

NOTE! Prior to JDK 15 you also had to supply the -XX:+UnlockExperimentalVMOptions option. As of JDK 15 this is no longer needed, since ZGC is now a production ready (non-experimental) feature.

“JVM Garbage Collectors Benchmarks Report 19.12” 針對許多情境對全部 Java GC 作 benchmark,可以參考看看。

Java GC Metrics

JVM 有參數可開啟 GC logs,Java 9 和 Java 9 之前參數不同。不過要大量觀察的話,用 Prometheus 收集,然後用 Grafana 看比較方便。

重要的 metrics 如下:

  • rate(jvm_gc_collection_seconds_sum[1m]) : GC 占用 CPU 時間比例,顯示一分鐘取樣數據的平均值。
  • rate(jvm_gc_collection_seconds_sum[1m]) / rate(jvm_gc_collection_seconds_count[1m]) :GC 暫停的平均時間。
  • rate(jvm_gc_collection_seconds_count[1m]) :GC 發生的頻率。

其它輔助數據,不一定需要:

  • jvm_threads_current :thread 數量。
  • jvm_memory_bytes_used :使用的記憶體。
  • jvm_memory_pool_bytes_used :GC 有分區時,可看到各區記憶體用量。

注意 Prometheus 是週期性收集的資料,比方說每 30s 向 Java server 收集 GC metrics,資料已是 30s 內的平均值。若在 30s 內有大幅變化,得分析 raw logs 才知道。

調整 GC 參數的問題

有 metrics 可觀察變化後,才知道調整參數是否有改善。但調整參數是很困難的事。舉例來說,對 Generational GC 來說,-XX:MaxTenuringThreshold 設定經過幾次 Young Generation GC 後,會將物件搬到 Old Generation,減少 Young Generation GC 要處理的物件。預設是 15,往上調會造成永遠不會搬到 Old Generation。

假設應用程式的行為如下:

  • 平均 5s 作一次 Young Generation GC。
  • 多數物件存活 30s 後就會繼續使用。

可能會想將 MaxTenuringThreshold 從 15 調成 6。但尖峰和離峰時間使用記憶體的方式不同,可能在最熱門時段 2s 就作一次 GC,調成 6 會變成 12s 就搬到 Old Generation,反而增加 Old Generation GC 發生頻率,造成單次更久的 stop-the-world。

此外,系統變化太多,隨著時間演進,舊的修改可能未來變得有害。一般建議不要在早期調 GC 參數,只有在有問題時再針對問題修改,確保不會造成其它不好的副作用。

Garbage First Garbage Collector (G1 GC)

Generational GC 的假設是大部份物件在 new 出來後很快就沒用了,所以只要回收新出生的物件,就有足夠記憶體可用。偶而再檢查剩下的物件,藉此減少占用 CPU 時間。

G1 GC 在這前提下,目標是盡可能多作 young generation GC,有需要時抽空順便處理一些 old generation GC (mixed GC),逼不得已時才作 full GC。

G1 GC 另外支援移除重覆的 strings (deduplicate duplicate strings),預設沒有開啟。server 接收大量 requests 時,很容易 deserialize 資料後產生重覆字串。可用 -XX:+G1EnableStringDeduplication 啟用。

以我自身的例子來說,抽查三天內數台機器的 raw logs,沒有看到 full GC。大多是 young generation GC,少數 mixd GC。偶而會因 memory fragmantation 必須作 GC (humongous allocations)。String deduplication 效果很好,可以消除 80% 以上 String 物件。

理論上 G1 GC 只需要調整 MaxGCPauseMillis,簡化調整 GC 參數的困難和意外的副作用。

G1 GC 的設計滿有趣的,有興趣的人可以參考以下文章了解:

  • GC 設計時要處理那些事 (例如 memory defragmentation)。
  • 不同作法的取捨。
  • 調整 GC 行為的參數。

Go GC

Go GC 聲稱暫停時間 <1ms,聽起來比 Java GC 強大許多,但這其實是不同取捨的結果。Java 需要考慮多種使用情境 (mobile app, desktop app, server, etc),程式可能占用整個機器,或和其它程式共用資源。

而 Go 的目標是開發 backend server,理論上可占用整個機器資源。在這個前提下,Go 可用最大的資源換取 <1ms 的暫停時間。

這篇 2016 的文章有點舊,不過有點出許多 GC 設計的取捨,滿有意思的:

針對文中一點補充,後來 Go 有在長時間的 loop 裡加中斷點,讓 Go runtime 有機會 context switch,因此像 “base64 decoding a large blob in a single goroutine can cause pause times to go up” 就不再是問題了。

另外 Go compiler 有作 escape analysis,所以局部變數在離開 stack 後就「回收」了,減少 GC 的工作。好奇查了一下,Java 也有作一樣的事,不過我不確定作得如何。

參考資料

如果只看一篇的話,Plumbr 的 Java Garbage Collection handbook 最為完整,PDF 檔的版本有 75 頁,內容淺顯易懂,附有大量圖片說明。

--

--