Java技術專題-JVM研究系列(8)CG管理和原理查缺補漏(番外篇)

語言: CN / TW / HK

前提概要

本文主要針對 Hotspot VM 中“CMS + ParNew” 組合的一些使用場景進行總結。


自 Sun 釋出 Java 語言以來,開始使用GC技術來進行記憶體自動管理,避免了手動管理帶來的懸掛指標(Dangling Pointer)問題,很大程度上提升了開發效率,從此 GC 技術也一舉成名。


GC 有著非常悠久的歷史,1960 年有著“Lisp 之父”和“人工智慧之父”之稱的 John McCarthy 就在論文中釋出了 GC 演算法,60 年以來, GC 技術的發展也突飛猛進,但不管是多麼前沿的收集器也都是基於三種基本演算法的組合或應用,也就是說 GC 要解決的根本問題這麼多年一直都沒有變過。

RT 突然上漲,有 GC 耗時增大、執行緒 Block 增多、慢查詢增多、CPU 負載高四個表象,到底哪個是誘因?如何判斷 GC 有沒有問題?使用 CMS 有哪些常見問題?如何判斷根因是什麼?如何解決或避免這些問題?


  • 建立知識體系: JVM的記憶體佈局、物件記憶體結構,垃圾收集的演算法和收集器特點,學習GC的基礎知識。
  • 確定評價指標: 瞭解基本GC的評價方法,摸清如何設定獨立系統的指標,以及在業務場景中判斷 GC 是否存在問題的手段。
  • 場景調優實踐: 運用掌握的知識和系統評價指標,分析與解決九種 CMS 中常見 GC 問題場景。
  • 總結優化經驗:掌握一些常用的 GC 問題分析工具,有著相關的JVM的回收機制功能實現

GC 基礎

基礎概念

GC: GC本身有三種語義,下文需要根據具體場景帶入不同的語義:

  • Garbage Collection :垃圾收集技術,名詞。
  • Garbage Collector :垃圾收集器,名詞。
  • Garbage Collecting :垃圾收集動作,動詞。

Mutator: 生產垃圾的角色,也就是我們的應用程式,垃圾製造者,通過 Allocator 進行 allocatefree


TLABThread Local Allocation Buffer 的簡寫,基於CAS 的獨享執行緒(Mutator Threads)可以優先將物件分配在 Eden 中的一塊記憶體,因為是 Java 執行緒獨享的記憶體區沒有鎖競爭,所以分配速度更快,每個 TLAB 都是一個執行緒獨享的


Card Table: 中文翻譯為卡表,主要是用來標記卡頁的狀態,每個卡表項對應一個卡頁。當卡頁中一個物件引用有寫操作時,寫屏障將會標記物件所在的卡表狀態改為 dirty,卡表的本質是用來解決跨代引用的問題

JVM 記憶體劃分

從JCP(Java Community Process)的官網中可以看到,目前 Java 版本最新已經到了 Java 16,未來的 Java 17 以及現在的 Java 11 和 Java 8 是 LTS 版本,JVM規範也在隨著迭代在變更,由於本文主要討論 CMS,此處還是放 Java 8 的記憶體結構。

GC 主要工作在 Heap 區和 MetaSpace 區(上圖藍色部分),在 Direct Memory 中,如果使用的是 DirectByteBuffer,那麼在分配記憶體不夠時則是 GC 通過 Cleaner#clean 間接管理(因為雖然記憶體分配和回收不歸JVM直接管理,但是引用還存放在JVM的heap中可以間接通過引用清除記憶體物件)


任何自動記憶體管理系統都會面臨的步驟:為新物件分配空間,然後收集垃圾物件空間,下面我們就展開介紹一下這些基礎知識。

分配物件

 Java中物件地址操作主要使用Unsafe呼叫了 C 的 allocate 和 free 兩個方法,分配方法有兩種:
  • 空閒連結串列(free list): 通過額外的儲存記錄空閒的地址,將隨機 IO 變為順序 IO,但帶來了額外的空間消耗,此外還要增加O(1)級別的定址時間
  • 碰撞指標(bump pointer): 通過一個指標作為分界點,需要分配記憶體時,僅需把指標往空閒的一端移動與物件大小相等的距離,分配效率較高,但使用場景有限。(必須規整的記憶體分配機制)

識別垃圾

引用計數法(Reference Counting): 對每個物件的引用進行計數,每當有一個地方引用它時計數器 +1、引用失效則 -1,引用的計數放到物件頭中,大於 0 的物件被認為是存活物件。雖然迴圈引用的問題可通過 Recycler 演算法解決,但是在多執行緒環境下,引用計數變更也要進行昂貴的同步操作,效能較低,早期的程式語言會採用此演算法。

可達性分析,又稱引用鏈法(Tracing GC): 從 GC Root 開始進行物件搜尋,可以被搜尋到的物件即為可達物件,此時還不足以判斷物件是否存活/死亡,需要經過多次標記才能更加準確地確定,整個連通圖之外的物件便可以作為垃圾被回收掉。目前 Java 中主流的虛擬機器均採用此演算法。


收集演算法

自從有自動記憶體管理出現之時就有的一些收集演算法,不同的收集器也是在不同場景下進行組合。

  • Mark-Sweep(標記-清除): 回收過程主要分為兩個階段,第一階段為追蹤(Tracing)階段,即從 GC Root 開始遍歷物件圖,並標記(Mark)所遇到的每個物件,第二階段為清除(Sweep)階段,即回收器檢查堆中每一個物件,並將所有未被標記的物件進行回收,整個過程不會發生物件移動。整個演算法在不同的實現中會使用三色抽象(Tricolour Abstraction)、點陣圖標記(BitMap)等技術來提高演算法的效率,存活物件較多時較高效。

  • Mark-Compact (標記-整理): 這個演算法的主要目的就是解決在非移動式回收器中都會存在的碎片化問題,也分為兩個階段,第一階段與 Mark-Sweep 類似,第二階段則會對存活物件按照整理順序(Compaction Order)進行整理。主要實現有雙指標(Two-Finger)回收演算法、滑動回收(Lisp2)演算法和引線整理(Threaded Compaction)演算法等

  • Copying(複製): 將空間分為兩個大小相同的 From 和 To 兩個半區,同一時間只會使用其中一個,每次進行回收時將一個半區的存活物件通過複製的方式轉移到另一個半區。有遞迴(Robert R. Fenichel 和 Jerome C. Yochelson 提出)和迭代(Cheney 提出)演算法,以及解決了前兩者遞迴棧、快取行等問題的近似優先搜尋演算法。複製演算法可以通過碰撞指標的方式進行快速地分配記憶體,但是也存在著空間利用率不高的缺點,另外就是存活物件比較大時複製的成本比較高。

把mark、sweep、compaction、copying這幾種動作的耗時放在一起看,大致有這樣的關係: 雖然 compaction 與 copying 都涉及移動物件,但取決於具體演算法,compaction 可能要先計算一次物件的目標地址,然後修正指標,最後再移動物件。copying 則可以把這幾件事情合為一體來做,所以可以快一些。另外,還需要留意 GC 帶來的開銷不能只看 Collector 的耗時,還得看 Allocator

如果能保證記憶體沒碎片,分配就可以用 pointer bumping 方式,只需要挪一個指標就完成了分配,非常快。而如果記憶體有碎片就得用 freelist 之類的方式管理,分配速度通常會慢一些。

分代收集器

  • ParNew一款多執行緒的收集器,採用複製演算法,主要工作在 Young 區,可以通過-XX:ParallelGCThreads 引數來控制收集的執行緒數,整個過程都是STW的,常與CMS組合使用。
  • CMS以獲取最短回收停頓時間為目標,採用“標記-清除”演算法,分 4 大步進行垃圾收集,其中初始標記和重新標記會 STW ,多數應用於網際網路站或者 B/S 系統的伺服器端上,JDK9 被標記棄用,JDK14 被刪除,詳情可見 JEP 363。(效能和響應時間為優先)

分割槽收集器

  • G1:一種伺服器端的垃圾收集器,應用在多處理器和大容量記憶體環境中,在實現高吞吐量的同時,儘可能地滿足垃圾收集暫停時間的要求。
  • ZGC: JDK11 中推出的一款低延遲垃圾回收器,適用於大記憶體低延遲服務的記憶體管理和回收,SPECjbb 2015 基準測試,在 128G 的大堆下,最大停頓時間才 1.68 ms,停頓時間遠勝於 G1 和 CMS。
  • Shenandoah: 由 Red Hat 的一個團隊負責開發,與 G1 類似,基於 Region 設計的垃圾收集器,但不需要 Remember Set 或者 Card Table 來記錄跨 Region 引用,停頓時間和堆的大小沒有任何關係。停頓時間與 ZGC 接近

常用收集器

目前使用最多的是 CMS 和 G1 收集器,二者都有分代的概念,主要記憶體結構如下:

其他收集器

除此之外還有很多收集器,如 Metronome、Stopless、Staccato、Chicken、Clover 等實時回收器,Sapphire、Compressor、Pauseless 等併發複製/整理回收器,Doligez-Leroy-Conthier 等標記整理回收器。

GC的兩個核心指標

  • 延遲(Latency): 也可以理解為最大停頓時間,即垃圾收集過程中一次 STW 的最長時間,越短越好,一定程度上可以接受頻次的增大,GC 技術的主要發展方向。
  • 吞吐量(Throughput): 應用系統的生命週期內,由於 GC 執行緒會佔用 Mutator 當前可用的 CPU 時鐘週期,吞吐量即為 Mutator 有效花費的時間佔系統總執行時間的百分比,例如系統運行了 100 min,GC 耗時 1 min,則系統吞吐量為 99%,吞吐量優先的收集器可以接受較長的停頓。

總結概括

目前各大網際網路公司的系統基本都更追求低延時,避免一次 GC 停頓的時間過長對使用者體驗造成損失,衡量指標需要結合一下應用服務的 SLA,主要如下兩點來判斷:

簡而言之,即為 一次停頓的時間不超過應用服務的 TP9999,GC 的吞吐量不小於 99.99% 。舉個例子,假設某個服務 A 的 TP9999 為 80 ms,平均GC停頓為30ms,那麼該服務的最大停頓時間最好不要超過 80 ms,GC 頻次控制在 5 min 以上一次。

如果滿足不了,那就需要調優或者通過更多資源來進行並聯冗餘。(大家可以先停下來,看看監控平臺上面的 gc.meantime分鐘級別指標,如果超過了 6 ms 那單機 GC 吞吐量就達不到4個9了。)

備註:除了這兩個指標之外還有 Footprint資源量大小測量)、反應速度等指標,網際網路這種實時系統追求低延遲,而很多嵌入式系統則追求 Footprint。

重點需要關注的幾個 GC Cause:

  • System.gc(): 手動觸發 GC 操作。
  • CMS: CMS GC 在執行過程中的一些動作,重點關注 CMS Initial Mark 和 CMS Final Remark 兩個 STW 階段。
  • Promotion Failure: Old 區沒有足夠的空間分配給 Young 區晉升的物件(即使總可用記憶體足夠大)。
  • Concurrent Mode Failure: CMS GC 執行期間,Old 區預留的空間不足以分配給新的物件,此時收集器會發生退化,嚴重影響 GC 效能,下面的一個案例即為這種場景。
  • GCLocker Initiated GC: 如果執行緒執行在 JNI 臨界區時,剛好需要進行 GC,此時 GC Locker 將會阻止 GC 的發生,同時阻止其他執行緒進入 JNI 臨界區,直到最後一個執行緒退出臨界區時觸發一次 GC。
分享到:
「其他文章」