上帝視角看JVM-垃圾回收

語言: CN / TW / HK

theme: channing-cyan highlight: a11y-dark


持續創作,加速成長!這是我參與「掘金日新計劃 · 10 月更文挑戰」的第3天,點擊查看活動詳情

隨着互聯網的飛速發展,我們開發的應用從之前的單體項目到現在的微服務、雲原生項目,以及不斷更新迭代的緩存技術,其最終目的就是讓我們的系統更快,用户體驗更好。

JVM的垃圾收集算法也在不斷的更新迭代,説到垃圾收集器大家應該都知道STW(Stop The World)。JVM在GC時會暫停用户線程,給用户帶來不好的體驗。而垃圾回收算法的不斷更新優化,都是為了縮短STW的時間。可以説STW已經成為阻礙 Java 廣泛應用的一大頑疾。


今天我們就來再認識下JVM的垃圾回收,給後期JVM調優做些前置的準備吧。


Serial收集器

Serial收集器是最基本,歷史最悠久的垃圾收集器了。Serial英文翻譯為串行,從名稱我們大概也能猜出來他是一個單線程收集器。而他的單線程其實是有兩層含義的: - 只會使用一個CPU或者一條收集線程去完成收集工作 - 在進行垃圾收集工作的時候必須暫停其他所有的工作線程(也就是我們上邊説的STW),直到Serial收集結束。

image.png

Serial收集器的新生代採用複製算法老年代採用標記整理算法

優點呢就是在單線程下,比其他收集器的單線程收集要簡單而高效

JVM參數: - -XX:+UseSerialGC - -XX:+UseSerialOldGC

Serial Old收集器是Serial收集器的老年代版本,它同樣是一個單線程收集器。它主要有兩大用途:一種用途是在JDK1.5以及以前的版本中與Parallel Scavenge收集器搭配使用,另一種用途是作為CMS收集器的後備方案。

Parallel Scavenge

從名字可以大概猜出來Parallel是多線程收集器,確實Parallel是Serial收集器的多線程版本,Parallel除了使用多線程進行垃圾收集外,其餘的行為都跟Serial類似,比如控制參數、收集算法、回收策略等。

image.png

Parallel Scavenge收集器關注點是吞吐量(高效率的利用CPU),提供了很多參數供用户找到最合適的停頓時間或最大吞吐量,如果對於收集器運作不太瞭解的話,可以選擇把內存管理優化交給虛擬機去完成也是一個不錯的選擇。

Parallel Scavenge收集器的新生代採用複製算法老年代採用標記-整理算法

JMV參數: - -XX:ParallelGCThreads(指定收集線程數,不建議修改) - -XX:+UseParallelGC(年輕代) - -XX:+UseParallelOldGC(老年代)

Parallel Old收集器是Parallel Scavenge收集器的老年代版本。使用多線程和“標記-整理”算法。在注重吞吐量以及CPU資源的場合,都可以優先考慮 Parallel Scavenge收集器和Parallel Old收集器(JDK8默認的新生代和老年代收集器)。

ParNew

ParNew收集器跟Parallel收集器很類似,主要區別是ParNew可以跟CMS收集器配合使用。

ParNew收集器的新生代採用複製算法老年代採用標記-整理算法

image.png

許多運行在Server模式下的虛擬機的垃圾收集器首先選擇都是ParNew,除了Serial收集器外,只有它能跟CMS收集器配合使用。

JVM參數: - -XX:+UseParNewGC

CMS

終於到CMS了,我們上邊有多次提到他,他到底有什麼特殊之處?

CMS(Concurrent Mark Sweep)收集器是一種以獲取最短回收停頓時間為目標的收集器。它是HotSpot虛擬機第一款真正意義上的併發收集器,它非常適合使用在那些注重用户體驗的系統應用上,CMS第一次實現了讓垃圾收集線程與用户線程基本上同時工作。

同樣,從名稱我們就能看出CMS是一種標記-清除算法實現的收集器。它的工作過程相對於我們前幾個討論的收集器要更加複雜。

CMS作用於老年代

CMS收集器的整個過程主要分為以下幾個步驟: 1. 初始標記 2. 併發標記 3. 重新標記 4. 併發清理

image.png


1.初始標記

暫停所有的其他線程(STW),並記錄下gc roots直接能引用的對象,速度很快

2.併發標記

併發標記階段就是從GC Roots的直接關聯對象開始遍歷整個對象圖的過程, 這個過程耗時較長但是不需要停頓用户線程, 可以與垃圾收集線程一起併發運行。因為用户程序繼續運行,可能會有導致已經標記過的對象狀態發生改變。

3.重新標記

重新標記階段就是為了修正併發標記期間因為用户程序繼續運行而導致標記產生變動的那一部分對象的標記記錄,這個階段的停頓時間一般會比初始標記階段的時間稍長,遠遠比並發標記階段時間短。主要用到三色標記裏的增量更新算法做重新標記。

4.併發清理

開啟用户線程,同時GC線程開始對未標記的區域做清掃。這個階段如果有新增對象會被標記為黑色(三色標記)不做任何處理

5.併發重置

重置本次GC過程中的標記數據。


CMS優點: - 併發收集 - 低停頓

CMS缺點: - 對CPU資源敏感(會和服務搶資源) - 無法處理浮動垃圾(在併發標記和併發清理階段又產生垃圾,這種浮動垃圾只能等到下一次gc再清理了) - 它使用的回收算法-“標記-清除”算法會導致收集結束時會有大量空間碎片產生 - 執行過程中的不確定性,會存在上一次垃圾回收還沒執行完,然後垃圾回收又被觸發的情況,特別是在併發標記和併發清理階段會出現,一邊回收,系統一邊運行,也許沒回收完就再次觸發full gc,也就是"concurrent mode failure",此時會進入stop the world,用serial old垃圾收集器來回收

JVM參數: - -XX:+UseConcMarkSweepGC:啟用cms - -XX:ConcGCThreads:併發的GC線程數(默認啟動的核心線程數:(處理器核心數量+3)/4) - -XX:+UseCMSCompactAtFullCollection:FullGC之後做壓縮整理(減少碎片,缺點中的第三點可使用此參數解決) - -XX:CMSFullGCsBeforeCompaction:多少次FullGC之後壓縮一次,默認是0,代表每次FullGC後都會壓縮一次 - -XX:CMSInitiatingOccupancyFraction: 當老年代使用達到該比例時會觸發FullGC(默認是92,這是百分比) - -XX:+UseCMSInitiatingOccupancyOnly:只使用設定的回收閾值(-XX:CMSInitiatingOccupancyFraction設定的值),如果不指定,JVM僅在第一次使用設定值,後續則會自動調整 - -XX:+CMSScavengeBeforeRemark:在CMS GC前啟動一次minor gc,目的在於減少老年代對年輕代的引用,降低CMS GC的標記階段時的開銷,一般CMS的GC耗時 80%都在標記階段 - -XX:+CMSParallellnitialMarkEnabled:表示在初始標記的時候多線程執行,縮短STW - -XX:+CMSParallelRemarkEnabled:在重新標記的時候多線程執行,縮短STW

G1(Garbage-First)

G1是一款面向服務器的垃圾收集器,主要針對配備多顆處理器及大容量內存的機器. 以極高概率滿足GC停頓時間要求的同時,還具備高吞吐量性能特徵。

G1將Java堆劃分為多個大小相等的獨立區域(Region),JVM最多可以有2048個Region。一般Region大小等於堆大小除以2048,比如堆大小為4096M,則Region大小為2M。G1保留了年輕代和老年代的概念,但不再是物理隔閡了,它們都是(可以不連續)Region的集合。在系統運行中,JVM會不停的給年輕代增加更多的Region,但是最多新生代的佔比不會超過60%。

一個Region可能之前是年輕代,如果Region進行了垃圾回收,之後可能又會變成老年代,也就是説Region的區域功能 可能會動態變化。

G1垃圾收集器對於對象什麼時候會轉移到老年代跟之前講過的原則一樣,唯一不同的是對大對象的處理,G1有專門分配 大對象的Region叫Humongous區,而不是讓大對象直接進入老年代的Region中。在G1中,大對象的判定規則就是一 個大對象超過了一個Region大小的50%,比如按照上面算的,每個Region是2M,只要一個大對象超過了1M,就會被放 入Humongous中,而且一個大對象如果太大,可能會橫跨多個Region來存放。Humongous區專門存放短期巨型對象,不用直接進老年代,可以節約老年代的空間,避免因為老年代空間不夠的GC開銷。 Full GC的時候除了收集年輕代和老年代之外,也會將Humongous區一併回收

G1收集器一次GC的運作過程大致分為以下幾個步驟: 1. 初始標記(initial mark) 2. 併發標記(Concurrent Marking) 3. 最終標記(Remark) 4. 篩選回收(Cleanup)

image.png


1.初始標記STW

暫停所有的其他線程,並記錄下gc roots直接能引用的對象,速度很快

2.併發標記

併發標記階段就是從GC Roots的直接關聯對象開始遍歷整個對象圖的過程, 這個過程耗時較長但是不需要停頓用户線程, 可以與垃圾收集線程一起併發運行。因為用户程序繼續運行,可能會有導致已經標記過的對象狀態發生改變。

3.最終標記STW

最終標記階段就是為了修正併發標記期間因為用户程序繼續運行而導致標記產生變動的那一部分對象的標記記錄,這個階段的停頓時間一般會比初始標記階段的時間稍長,遠遠比並發標記階段時間短。主要用到三色標記裏的增量更新算法做重新標記。

4.篩選回收STW

篩選回收階段首先對各個Region的回收價值和成本進行排序根據用户所期望的GC停頓時間來制定回收計劃,比如説老年代此時有1000個Region都滿了,但是因為根據預期停頓時間,本次垃圾回收可能只能停頓200毫秒,那麼通過之前回收成本計算得知,可能回收其中800個Region剛好需要200ms,那麼就只會回收800個Region(Collection Set,要回收的集合),儘量把GC導致的停頓時間控制在我們指定的範圍內。這個階段其實也可以做到與用户程序一起併發執行,但是因為只回收一部分Region,時間是用户可控制的,而且停頓用户線程將大幅提高收集效率。不管是年輕代或是老年代,回收算法主要用的是複製算法,將一個region中的存活對象複製到另一個region中,這種不會像CMS那樣回收完因為有很多內存碎片還需要整理一次,G1採用複製算法回收幾乎不會有太多內存碎片。(注意:CMS回收階 段是跟用户線程一起併發執行的,G1因為內部實現太複雜暫時沒實現併發回收,不過到了Shenandoah就實現了並 發收集,Shenandoah可以看成是G1的升級版本)

G1收集器在後台維護了一個優先列表,每次根據允許的收集時間,優先選擇回收價值最大的Region(這也就是它的名字 Garbage-First的由來),比如一個Region花200ms能回收10M垃圾,另外一個Region花50ms能回收20M垃圾,在回 收時間有限情況下,G1當然會優先選擇後面這個Region回收。這種使用Region劃分內存空間以及有優先級的區域回收 方式,保證了G1收集器在有限時間內可以儘可能高的收集效率。

---

G1收集器的特點:

  • 並行與併發: G1能充分利用CPU、多核環境下的硬件優勢,使用多個CPU(CPU或者CPU核心)來縮短Stop-The-World停頓時間。部分其他收集器原本需要停頓Java線程來執行GC動作,G1收集器仍然可以通過併發的方式讓java程序繼續執行。
  • 分代收集: 雖然G1可以不需要其他收集器配合就能獨立管理整個GC堆,但是還是保留了分代的概念。
  • 空間整合: 與CMS的“標記--清理”算法不同,G1從整體來看是基於“標記整理”算法實現的收集器;從局部上來看是基於“複製”算法實現的。
  • 可預測的停頓: 這是G1相對於CMS的另一個大優勢,降低停頓時間是G1 和 CMS 共同的關注點,但G1 除了追求低停頓外,還能建立可預測的停頓時間模型,能讓使用者明確指定在一個長度為M毫秒的時間片段(通過參數"- XX:MaxGCPauseMillis"指定)內完成垃圾收集。

G1垃圾收集分類:

  • YoungGC: YoungGC並不是説現有的Eden區放滿了就會馬上觸發,G1會計算下現在Eden區回收大概要多久時間,如果回收時 間遠遠小於參數 -XX:MaxGCPauseMills 設定的值,那麼增加年輕代的region,繼續給新對象存放,不會馬上做YoungGC,直到下一次Eden區放滿,G1計算回收時間接近參數 -XX:MaxGCPauseMills 設定的值,那麼就會觸發Young GC

  • MixedGC:不是FullGC,老年代的堆佔有率達到參數(-XX:InitiatingHeapOccupancyPercent)設定的值則觸發,回收所有的Young和部分Old(根據期望的GC停頓時間確定old區垃圾收集的優先順序)以及大對象區,正常情況G1的垃圾收集是先做MixedGC,主要使用複製算法,需要把各個region中存活的對象拷貝到別的region裏去,拷貝過程中如果發現沒有足夠的空region能夠承載拷貝對象就會觸發一次Full GC

  • Full GC:停止系統程序,然後採用單線程進行標記、清理和壓縮整理,好空閒出來一批Region來供下一次MixedGC使用,這個過程是非常耗時的。(Shenandoah優化成多線程收集了)

JVM參數: - -XX:+UseG1GC:使用G1收集器 - -XX:ParallelGCThreads:指定GC工作的線程數量 - -XX:G1HeapRegionSize:指定分區大小(1MB~32MB,且必須是2的N次冪),默認將整堆劃分為2048個分區 - -XX:MaxGCPauseMillis:目標暫停時間(默認200ms) - -XX:G1NewSizePercent:新生代內存初始空間(默認整堆5%) - -XX:G1MaxNewSizePercent:新生代內存最大空間 - -XX:TargetSurvivorRatio:Survivor區的填充容量(默認50%),Survivor區域裏的一批對象(年齡1+年齡2+年齡n的多個年齡對象)總和超過了Survivor區域的50%,此時就會把年齡n(含)以上的對象都放入老年代 - -XX:MaxTenuringThreshold:最大年齡閾值(默認15) - -XX:InitiatingHeapOccupancyPercent:老年代佔用空間達到整堆內存閾值(默認45%),則執行新生代和老年代的混合收集(MixedGC),比如我們之前説的堆默認有2048個region,如果有接近1000個region都是老年代的region,則可能就要觸發MixedGC了 - -XX:G1MixedGCLiveThresholdPercent(默認85%) region中的存活對象低於這個值時才會回收該region,如果超過這個值,存活對象過多,回收的的意義不大。 - -XX:G1MixedGCCountTarget:在一次回收過程中指定做幾次篩選回收(默認8次),在最後一個篩選回收階段可以回收一會,然後暫停回收,恢復系統運行,一會再開始回收,這樣可以讓系統不至於單次停頓時間過長。 - -XX:G1HeapWastePercent(默認5%): gc過程中空出來的region是否充足閾值,在混合回收的時候,對Region回收都是基於複製算法進行的,都是把要回收的Region裏的存活對象放入其他Region,然後這個Region中的垃圾對象全部清理掉,這樣的話在回收過程就會不斷空出來新的Region,一旦空閒出來的Region數量達到了堆內存的5%,此時就會立即停止混合回收,意味着本次混合回收就結束了。

什麼場景適合使用G1 1. 50%以上的堆被存活對象佔用 2. 對象分配和晉升的速度變化非常大 3. 垃圾回收時間特別長,超過1秒 4. 8GB以上的堆內存(建議值) 5. 停頓時間是500ms以內

ZGC

ZGC是一款JDK 11中新加入的具有實驗性質的低延遲垃圾收集器,ZGC可以説源自於是Azul System公司開發的 C4(Concurrent Continuously Compacting Collector) 收集器。

ZGC的設計目標

  • 停頓時間不超過10ms(JDK16已經達到不超過1ms)
  • 停頓時間不會隨着堆的大小,或者活躍對象的大小而增加
  • 支持8MB~4TB級別的堆,JDK15後已經可以支持16TB

ZGC的內存佈局

ZGC收集器是一款基於Region內存佈局的, 暫時不設分代的,Region可以具有大、 中、 小三類容量: - 小型Region(Small Region) : 容量固定為2MB, 用於放置小於256KB的小對象 - 中型Region(Medium Region) : 容量固定為32MB, 用於放置大於等於256KB但小於4MB的對象。 - 大型Region(Large Region) : 容量不固定, 可以動態變化, 但必須為2MB的整數倍, 用於放置4MB或以上的大對象。 每個大型Region中只會存放一個大對象, 這也預示着雖然名字叫作“大型Region”, 但它的實際容量完全有可能小於中型Region, 最小容量可低至4MB。 大型Region在ZGC的實現中是不會被重分配(重分配是ZGC的一種處理動作,用於複製對象的收集器階段)的, 因為複製一個大對象的代價非常高昂。

image.png

ZGC核心概念

以前的垃圾回收器的GC信息都保存在對象頭中,而ZGC的GC信息使用指針着色技術保存在指針中。顏色指針可以説是ZGC的核心概念。因為他在指針中借了幾個位出來做事情,所以它必須要求在64位的機器上 才可以工作。並且因為要求64位的指針,也就不能支持壓縮指針。 - ZGC中低42位表示使用中的堆空間 - ZGC借幾位高位來做GC相關的事情(快速實現垃圾回收中的併發標記、轉移和重定位等)

image.png

(取自官方截圖)

每個對象有一個64位指針,這64位被分為:

  • 18位:預留給以後使用;
  • 1位:Finalizable標識,此位與併發引用處理有關,它表示這個對象只能通過finalizer才能訪問;
  • 1位:Remapped標識,設置此位的值後,對象未指向relocation set中(relocation set表示需要GC的Region集合)
  • 1位:Marked1標識;
  • 1位:Marked0標識,和上面的Marked1都是標記對象用於輔助GC;
  • 42位:對象的地址(所以它可以支持2^42=4T內存):

為什麼有2個mark標記?
每一個GC週期開始時,會交換使用的標記位,使上次GC週期中修正的已標記狀態失效,所有引用都變成未標記。 GC週期1:使用mark0, 則週期結束所有引用mark標記都會成為01。 GC週期2:使用mark1, 則期待的mark標記10,所有引用都能被重新標記。

顏色指針的三大優勢: 1. 一旦某個Region的存活對象被移走之後,這個Region立即就能夠被釋放和重用掉,而不必等待整個堆中所有指 向該Region的引用都被修正後才能清理,這使得理論上只要還有一個空閒Region,ZGC就能完成收集。 2. 顏色指針可以大幅減少在垃圾收集過程中內存屏障的使用數量,ZGC只使用了讀屏障。 3. 顏色指針具備強大的擴展性,它可以作為一種可擴展的存儲結構用來記錄更多與對象標記、重定位過程相關的數 據,以便日後進一步提高性能。

ZGC工作流程

  1. 初始標記(Mark Start)
    這個階段需要暫停(STW),初始標記只需要掃描所有GC Roots,其處理時間和GC Roots的數量成正比,停頓時間不會隨着堆的大小或者活躍對象的大小而增加。

  2. 併發標記(Concurrent Mark)
    這個階段不需要暫停(沒有STW),掃描剩餘的所有對象,這個處理時間比較長,所以走併發,業務線程與GC線程同時運行。但是這個階段會產生漏標問題。

  3. 最終標記(Mark End)
    這個階段需要暫停(STW),主要處理漏標對象,通過SATB算法解決(G1中的解決漏標的方案)。

  4. 併發重分配準備(Concurrent Prepare For Relocate, 分析最有價值GC分頁<無STW>)
    這個階段需要根據特定的查詢條件統計得出本次收集過程要清理哪些Region,將這些Region組成重分配集(Relocation Set)。ZGC每次回收都會掃描所有的Region,用範圍更大的掃描成本換取省去G1中記憶集的維護成本。

  5. 初始轉移(重分配Relocate Start)(轉移初始標記的存活對象同時做對象重定位<有STW>)

  6. 併發轉移(重分配Concurrent Relocate)(對轉移併發標記的存活對象做轉移<無STW>) 重分配是ZGC執行過程中的核心階段,這個過程要把重分配集中的存活對象複製到新的Region上,併為重分配集中的每個Region維護一個轉發表(Forward Table),記錄從舊對象到新對象的轉向關係。

  7. 併發重映射(Concurrent Remap) 重映射所做的就是修正整個堆中指向重分配集中舊對象的所有引用,但是ZGC中對象引用存在“自愈”功能,所以這個重映射操作並不是很迫切。ZGC很巧妙地把併發重映射階段要做的工作,合併到了下一次垃圾收集循環中的併發標記階段裏去完成,反正它們都是要遍歷所有對象的,這樣合併就節省了一次遍歷對象圖的開銷。一旦所有指針都被修正之後, 原來記錄新舊對象關係的轉發表就可以釋放掉了。

image.png

ZGC中GC觸發機制(JAVA16)

  • 預熱規則:服務剛啟動時出現,一般不需要關注。日誌中關鍵字是“Warmup”.JVM啟動預熱,如果從來沒有發生過GC,則在堆內存使用超過10%、20%、30%時,分別觸發一次GC,以收集GC數據.
  • 基於分配速率的自適應算法:最主要的GC觸發方式(默認方式),其算法原理可簡單描述為”ZGC根據近期的對象分配速率以及GC時間,計算出當內存佔用達到什麼閾值時觸發下一次GC”。通過ZAllocationSpikeTolerance參數控制閾值大小,該參數默認2,數值越大,越早的觸發GC。日誌中關鍵字是“Allocation Rate”。
  • 基於固定時間間隔:通過ZCollectionInterval控制,適合應對突增流量場景。流量平穩變化時,自適應算法可能在堆使用率達到95%以上才觸發GC。流量突增時,自適應算法觸發的時機可能會過晚,導致部分線程阻塞。我們通過調整此參數解決流量突增場景的問題,比如定時活動、秒殺等場景。
  • 主動觸發規則:類似於固定間隔規則,但時間間隔不固定,是ZGC自行算出來的時機,我們的服務因為已經加了基於固定時間間隔的觸發機制,所以通過-ZProactive參數將該功能關閉,以免GC頻繁,影響服務可用性。
  • 阻塞內存分配請求觸發:當垃圾來不及回收,垃圾將堆佔滿時,會導致部分線程阻塞。我們應當避免出現這種觸發方式。日誌中關鍵字是“Allocation Stall”。
  • 外部觸發:代碼中顯式調用System.gc()觸發。 日誌中關鍵字是“System.gc()”。
  • 元數據分配觸發:元數據區不足時導致,一般不需要關注。 日誌中關鍵字是“Metadata GC Threshold”。

JVM參數:(大致可以分為三類) - 堆大小:Xmx。當分配速率過高,超過回收速率,造成堆內存不夠時,會觸發 Allocation Stall,這類 Stall 會減緩當前的用户線程。因此,當我們在 GC 日誌中看到 Allocation Stall,通常可以認為堆空間偏小或者 concurrent gc threads 數偏小。 - GC觸發時機:ZAllocationSpikeTolerance, ZCollectionInterval。ZAllocationSpikeTolerance 用來估算當前的堆內存分配速率,在當前剩餘的堆內存下,ZAllocationSpikeTolerance 越大,估算的達到OOM 的時間越快,ZGC 就會更早地進行觸發 GC。ZCollectionInterval 用來指定 GC 發生的間隔,以秒為單位觸發 GC。 - GC線程:ParallelGCThreads, ConcGCThreads。ParallelGCThreads 是設置 STW 任務的 GC 線程數目,默認為 CPU 個數的 60%;ConcGCThreads 是併發階段 GC 線程的數目,默認為 CPU 個數的12.5%。增加 GC 線程數目,可以加快 GC 完成任務,減少各個階段的時間,但也會增加 CPU 的搶佔開銷,可根據生產情況調整

ZGC典型應用場景

  • 超大堆應用。超大堆(百 G 以上)下,CMS 或者 G1 如果發生 Full GC,停頓會在分鐘級別,可能 會造成業務的終端,強烈推薦使用 ZGC。
  • 當業務應用需要提供高服務級別協議(Service Level Agreement,SLA),例如 99.99% 的響應時間不能超過 100ms,此類應用無論堆大小,均推薦採用低停頓的 ZGC。

---


介紹了這麼多垃圾收集器,那我們在實際開發中應該如何選擇呢?後期我們通過JVM調優實戰再結合實際情況來具體分析,下面先給出一個大致的建議: 1. 優先調整堆的大小讓服務器自己來選擇 2. 如果內存小於100M,使用串行收集器 3. 如果是單核,並且沒有停頓時間的要求,串行或JVM自己選擇 4. 如果允許停頓時間超過1秒,選擇並行或者JVM自己選 5. 如果響應時間最重要,並且不能超過1秒,使用併發收集器 6. 4G以下可以用parallel,4-8G可以用ParNew+CMS,8G以上可以用G1,幾百G以上用ZGC