天天看點

極緻八股文之JVM垃圾回收器G1&ZGC詳解

極緻八股文之JVM垃圾回收器G1&ZGC詳解

導讀

本文作者分享了一些垃圾回收器的執行過程,希望給大家參考。

垃圾回收器介紹

G1(Garbage First)垃圾回收器

G1垃圾收集器在JDK7被開發出來,JDK8功能基本完全實作。并且成功替換掉了Parallel Scavenge成為了服務端模式下預設的垃圾收集器。對比起另外一個垃圾回收器CMS,G1不僅能提供能提供規整的記憶體,而且能夠實作可預測的停頓,能夠将垃圾回收時間控制在N毫秒内。這種“可預測的停頓”和高吞吐量特性讓G1被稱為"功能最全的垃圾回收器"。

G1同時回收新生代和老年代,但是分别被稱為G1的Young GC模式和Mixed GC模式。這個特性來源于G1獨特的記憶體布局,記憶體配置設定不再嚴格遵守新生代,老年代的劃分,而是以Region為機關,G1跟蹤各個Region的并且維護一個關于Region的優先級清單。在合适的時機選擇合适的Region進行回收。這種基于Region的記憶體劃分為一些巧妙的設計思想提供了解決停頓時間和高吞吐的基礎。接下來我們将詳細講解G1的詳細垃圾回收過程和裡面可圈可點的設計。

Region記憶體劃分

G1将堆劃分為一系列大小不等的記憶體區域,稱為Region(單詞語義範圍,地區,接下來我簡述為分區)。每個region為1-32M,都是2的n次幂。在分代垃圾回收算法的思想下,region邏輯上劃分為Eden,Survivor和老年代。每個分區都可能是eden區,Survivor區也可能是old區,但在一個時刻隻能是一種分區。各種角色的region個數都是不固定的,這說明每個代的記憶體也是不固定的。這些region在邏輯上是連續的,而不是實體上連續,這點和之前的young/old區實體連續很不一樣。

除了前面說的Eden區,Survivor區,old區,G1中還有一種特殊的分區,Humongous區。Humongous用于存放大對象。當一個對象的容量超過了Region大小的一半,就會把這個對象放進Humongous分區,因為如果對一個短期存在的大對象使用複制算法回收的話,複制成本非常高。而直接放進old區則導緻原本應該短期存在的對象占用了老年代的記憶體,更加不利于回收性能。如果一個對象的大小超過了一個Region的大小,那麼就需要找到連續的Humogous分區來存放這個大對象。有時候因為找不到連續的Humogous分區甚至不得不開啟Full GC。

極緻八股文之JVM垃圾回收器G1&ZGC詳解

Region内部結構

Card Table卡表

Card Table是Region的内部結構劃分。每個region内部被劃分為若幹的記憶體塊,被稱為card。這些card集合被稱為card table,卡表。

比如下面的例子,region1中的記憶體區域被劃分為9塊card,9塊card的集合就是卡表card table。

極緻八股文之JVM垃圾回收器G1&ZGC詳解

Rset記憶集合

除了卡表,每個region中都含有Remember Set,簡稱RSet。RSet其實是hash表,key為引用本region的其他region起始位址,value為本region中被key對應的region引用的card索引位置。

這裡必須講解一下RSet存在的原因,RSet是為了解決"跨代引用"。想象一下,一個新生代對象被老年代對象引用,那麼為了通過引用鍊找到這個新生代對象,從GC Roots出發周遊對象時必須經過老年代對象。實際上以這種方式周遊時,是把所有對象都周遊了一遍。但是我們的其實隻想回收新生代的對象,卻把所有對象都周遊了一遍,這無疑很低效。

在YoungGC時,當RSet存在時,順着引用鍊查找引用。如果引用鍊上出現了老年代對象,那麼直接放棄查找這條引用鍊。當整個GC Root Tracing執行完畢後,就知道了除被跨代引用外還存活的新生代對象。緊接着再周遊新生代Region的RSet,如果RSet裡存在key為老年代的Region,就将key對應的value代表的card的對象标記為存活,這樣就标記到了被跨代引用的新生代對象。

當然這麼做會存在一個問題,如果部分老年代對象是應該被回收的對象,但還是跨代引用了新生代,會導緻原本應該被回收的新生代對象躲過本輪新生代回收。這部分對象就隻能等到後續的老年代的垃圾回收mixed GC來回收掉。這也是為什麼G1的回收精度比較低的原因之一。

極緻八股文之JVM垃圾回收器G1&ZGC詳解

以這幅圖為例,region1和region2都引用了region3中的對象,那麼region3的RSet中有兩個key,分别是region1的起始位址和region2的起始位址。

在掃描region3的RSet時,發現key為0x6a的region是一個old區region。如果這時第3,5card對應的對象沒有被标記為可達,那麼這裡就會根據RSet再次标記。

同樣的,key為0x9b對應的region是一個young區域的region,那麼0,2号card的對象則不會被标記。

Young GC流程

在了解了region的内部結構之後,我們再來看一下G1的young gc的具體流程。

  1. stop the world,整個young gc的流程都是在stw裡進行的,這也是為什麼young gc能回收全部eden區域的原因。控制young gc開銷的辦法隻有減少young region的個數,也就是減少年輕代記憶體的大小,還有就是并發,多個線程同時進行gc,盡量減少stw時間。
  2. 掃描GCRoots,注意這裡掃描的GC Roots就是一般意義上的GC Roots,是掃描的直接指向young代的對象,那如果GC Root是直接指向老年代對象的,則會直接停止在這一步,也就是不往下掃描了。被老年代對象指向的young代對象會在接下來的利用Rset中key指向老年代的卡表識别出來,這樣就避免了對老年代整個大的heap掃描,提高了效率。這也是為什麼Rset能避免對老年代整體掃描的原因。
  3. 排空dirty card quene,更新Rset。Rset中記錄了哪些對象被老年代跨帶引用,也就是當新生代對象被老年代對象引用時,應該更新這個記錄到RSet中。但更新RSet記錄的時機不是伴随着引用更改馬上發生的。每當老年代引用新生代對象時,這個引用記錄對應的card位址其實會被放入Dirty Card Queue(線程私有的,當線程私有的dirty card queue滿了之後會被轉移到全局的dirty card queue,這個全局是唯一的),原因是如果每次更新引用時直接更新Rset會導緻多線程競争,因為指派操作很頻繁,影響性能。是以更新Rset交由Refinement線程來進行。全局DirtyCardQueue的容量變化分為4個階段
極緻八股文之JVM垃圾回收器G1&ZGC詳解

白色:無事發生

綠色:Refinement線程被激活,-XX:G1ConcRefinementGreenZone=N指定的線程個數。從(全局和線程私有)隊列中拿出dirty card。并更新到對應的Rset中。

黃色:産生dirty card的速度過快,激活全部的Refinement線程,通過參數-XX:G1ConcRefinementYellowZone=N 指定

紅色:産生dirty card的速度過快,将應用線程也加入到排空隊列的工作中。目的是把應用線程拖慢,減慢dirty card産生。

  1. 掃描Rset,掃描所有Rset中Old區到young區的引用。到這一步就确定出了young區域哪些對象是存活的。
  2. 拷貝對象到survivor區域或者晉升old區域。
  3. 處理引用隊列,軟引用,弱引用,虛引用

以上就是young gc的全部流程。

三色标記算法

知道了Young GC的流程後,接下來我們将學習G1針對老年代的垃圾回收過程Mixed GC,但是在正式開始介紹之前我們先講解一下可達性分析算法的具體實作,三色标記算法。以及三色标記算法的缺陷以及G1是如何解決這個缺陷的。

在可達性分析的思想指導下,我們需要标記對象是否可達,那麼我們采用将對象标記為不同的顔色來區分對象是否可達。可以了解如果一個對象能從GC Roots出發并且周遊到,那麼對象就是可達的,這個過程我們稱為檢查。

  • 白色:對象還沒被檢查。
  • 灰色:對象被檢查了,但是對象的成員Field(對象中引用的其他對象)還沒有被檢查。這說明這個對象是可達的。
  • 黑色:對象被檢查了,對象的成員Fileld也被檢查了。

那麼整個檢測的過程,就是從GC Roots出發不斷地周遊對象,并且将可達的對象标記成黑色的過程。當标記結束時,還是白色的對象就是沒被周遊到的對象,即不可達的對象。

舉個例子

第一輪檢查,找到所有的GC Roots,GC Roots被标記為灰色,有的GC Roots因為沒有成員Field則被标記為黑色。

極緻八股文之JVM垃圾回收器G1&ZGC詳解

第二輪檢查,檢查被GC Roots引用的對象,并标記為灰色

極緻八股文之JVM垃圾回收器G1&ZGC詳解

第三輪檢查,循環之前的步驟,将被标記為灰色對象的子Field檢查。因為這裡就假設了3次循環檢查的對象,是以是最後一次檢查。這一路檢查結束,還是白色的對象就是可以被回收的對象。即圖例裡的objectC

極緻八股文之JVM垃圾回收器G1&ZGC詳解

以上描述的是一輪三色标記算法的工作過程,但是這是一個理想情況,因為标記過程中,标記的線程是和使用者線程交替運作的,是以可能出現标記過程中引用發生變化的情況。試想一下,在第二輪檢查到第三輪檢查之間,假設發生了引用的變化,objectD不再被objectB引用,而是被objectA引用,而且此時ObjectA的成員已經被檢查完畢了,objectB的成員Field還沒被檢查。這時,objectD就永遠不會再被檢查到。這就導緻了漏标。

還有一種漏标的情況,就是新産生一個對象,這個對象被已經被标記為黑色的對象持有。比如圖例中的newObjectF。因為黑色對象已經被認為是檢查完畢了,是以新産生的對象不會再被檢查,這也會導緻漏标。這兩種漏标的解決方式我将仔細講解一下。

極緻八股文之JVM垃圾回收器G1&ZGC詳解

已經存在的對象被漏标

即圖例中被漏标的objectD,要向漏标objectD,必須同時滿足:

  1. 灰色對象不再指向白色對象,即objectB.d = null
  2. 黑色對象指向白色對象,即objectA.d = objectD

要解決漏标,隻要打破這兩個條件的任意一個即可。由此我們引出兩個解決方案。原始快照和增量更新。

  1. 原始快照(Snapshot At The Beginning,簡稱SATB)

    當任意的灰色對象到白色對象的引用被删除時,記錄下這個被删除的引用,預設這個被删除的引用對象是存活的。這也可以了解為整個檢查過程中的引用關系以檢查剛開始的那一刻為準。

  2. 增量更新(Incremental Update)

    當灰色對象被新增一個白色對象的引用的時候,記錄下發生引用變更的黑色對象,并将它重新改變為灰色對象,重新标記。這是CMS采用的解決辦法(沒錯,CMS也是三色标記算法實作的)。

在上面的兩種解決方案裡,我們發現,無論如何,都要記錄下發生更改的引用。是以我們需要一種記錄引用發生更改的手段,寫屏障(write barrier)。寫屏障是一種記錄下引用發生變更的手段,效果類似AOP,但是其實作遠比我們使用的AOP更加底層,大家可以認為是在JVM代碼層面的一段代碼。每當任意的引用變更時,就會觸發這段代碼,并記錄下發生變更的引用。

新産生的對象被漏标

新産生的對象被漏标的解決方式則簡單一些,在增量更新模式下,這個問題天生就被解決了。在SATB模式下,我們其實是在檢查一開始就确定了一個檢查範圍,是以我們可以将新産生的對象放在檢查範圍之外,預設新産生的對象是存活的。當然這個過程得實際結合卡表來講解才會更加具體形象。接下來在Mixed GC的過程裡再細說。

SATB

Snapshot At The Beginning,G1在配置設定對象時,會在region中有2個top-at-mark-start(TAMS)指針,分别表示prevTAMS和nextTAMS。對應着卡表上即指向表示卡表範圍的的兩個編号,GC是配置設定在nextTAMS位置以上的對象都視為活着的,這是一種隐式的标記(這涉及到G1 MixedGC垃圾回收階段的細節,很複雜,接下來會詳細讨論)。這種解決漏标的方式是有缺陷的,它會造成真正應該被回收的白對象躲過這次GC生存到下一次GC,這就是float garbage(浮動垃圾)。因為SATB的做法精度比較低,是以造成float garbage的情況也會比較多。

極緻八股文之JVM垃圾回收器G1&ZGC詳解

Mixed GC

通過前面的學習,我們已經認識到了G1采用的标記算法-三色标記算法以及解決裡面問題的解決思想,接下來我們将講解Mixed GC的詳細過程,以及怎樣利用G1的卡表來解決裡面的問題。

Mixed GC從步驟上可以分為兩個大步驟,全局并發标記(global concurrent marking),拷貝存活對象(evacuation)。全局并發表的過程涉及到SATB的标記過程,我們将詳細講解。

全局

G1收集器垃圾收集器的全局并發标記(global concurrent marking)分為多個階段

1. 初始标記(initial marking)

這個階段會STW,标記從GC Root開始直接可達的對象,這一步伴随着young gc。之是以要young gc是為了處理跨代引用,老年代獨享也可能被年輕代跨代引用,但是老年代不能使用RSet來解決跨代引用。還有就是young gc也會stw,在第一步young gc可以共用stw的時間,盡量減少stw時間。

這一步還初始化了一些參數,将bottom指針指派給prevTAMS指針,top指針指派給nextTAMS指針,同時清空nextBitMap指針。因為之後的并發标記需要使用到這三個變量。

這裡大家可能被這些變量搞暈了,我解釋一下,top,prevTAMS,nextTAMS,top都是指向卡表的指針,他們的存在是為了辨別哪些對象是可以被回收的,哪些是存活的,這就是SATB機制。而nextBitMap則是記錄下卡表中哪些對象是存活的一個數組,當然現在還沒開始檢查,nextBitMap裡的記錄都是空。

極緻八股文之JVM垃圾回收器G1&ZGC詳解

2. 根分區掃描(root region scan)

這個階段在stw之後,會掃描survivor區域(survivor分區就是根分區),将所有被survivor區域對象引用的老年代對象标記。這也是上一步需要young gc的原因,處理跨代引用時需要知道哪些old區對象被S區對象引用。這個過程因為需要掃描survivor分區,是以不能發生young gc,如果掃描過程中新生代被耗盡,那麼必須等待掃描結束才可以開始young gc。這一步耗時很短。

3. 并發标記(Concurrent Marking)

從GC Roots開始對堆中對象進行可達性分析,找出各個region的存活對象資訊,耗時較長。粗略過程是這樣的,但實際這一步的過程很複雜。因為要考慮在SATB機制之下,各個指針的變化。

假設在根分區掃描後沒有引用的改變,那麼一個region的分區狀态和第一步init marking初始化完一緻。此時如果再繼續配置設定對象,那麼對象會配置設定在nextTAMS之後,随着對象的配置設定,TOP指針會向後移動。

極緻八股文之JVM垃圾回收器G1&ZGC詳解

因為這一步是和mutator(使用者線程)并發運作的,是以從根節點掃描的時候其實是掃描的一個快照snapshot,快照位置就是prevTAMS到nextTAMS(注意快照位置是不變的,但是prevTAMS到nextTAMS之間的對象在掃描過程中會改變)。

當region中配置設定新對象時,新對象都會配置設定在nextTAMS之後,這導緻top指向的位置也往後移動,nextTAMS和top之間選哪個都是被認為隐式存活。

還有這期間也有可能應該被掃描的位置prevTAMS和nextTAMS之間的位置引用發生了變化,比如白色對象被黑色對象持有了,這就是三色标記算法的缺陷,需要更改白色對象的狀态。這裡會将引用被更改的對象放入satb_mark_queue。satb_mark_queue是一個隊列,裡面記錄所有被改變引用關系的白色對象。這裡指的satb_mark_queue指的全局的queue。除了全局的queue,每個線程也有自己的satb mark queue,全局的queue的引用是由所有其他線程的satb mark queue合并得來的,線程的satb mark queu滿了會被轉移到全局satb mark queue。且并發标記階段會定期檢查全局satb mark queue的容量,超過某個容量就concurrent marker線程就會将全局satb mark que和線程satb mark que的對象都取出來全部标記上,當然也會将這些對象的子field全部壓棧(marking stack)等待接下來被标記到,這個處理類似于全局dirty card quene。這裡注意。

極緻八股文之JVM垃圾回收器G1&ZGC詳解

随着并發标記結束nextBitMap裡也标記了哪些對象是可以回收的,但注意,不一定每個線程裡satb mark queue都被轉移到了全局的satb mark queue,因為合并這個過程也是并發的。是以需要下一步

4. 最終标記(remark)

标記那些并發标記階段發生變化的對象,就是将線程satb mark queue中引用發生更改的對象找出來,放入satb mark queue。這個階段為了保證标記正确必須STW。

5. 清點垃圾(cleanup)

對各個region的回收價值和成本進行排序,根據使用者期待的GC停頓時間指定回收計劃,選中部分old region,和全部的young region,這些被選中的分區稱為Collection Set(Cset),還會把沒有任何對象的region加入到可用來配置設定對象的region集合中。注意這一步不是清除,是清點出哪些region值得回首,不會複制任何對象。清點執行完,一個全局并發标記周期基本就執行完了。這時還會将nextTAMS指針指派給prevTAMS,且nextBitMap指派給prevBitMap。這裡是不是很奇怪為什麼要記錄本輪标記的結果到prevBitMap,難道下次再來檢查本region時還可以再複用這個标記結果嗎。

我們知道G1是可以根據記憶體的變化自己調整記憶體中E區,O區的容量的,如果其中某些分區容量增長比較快,說明這個分區的記憶體通路更頻繁,在未來也可能更快地達到region的容量限制,那麼下次複制轉移時就會優先将這塊region中的對象轉移到更大的region中去。

标記結束剩下的就是轉移evacuation,拷貝存活對象。就是将活着的對象拷貝到空的region,再回收掉部分region。這一步是采用多線程複制清除,整個過程會STW。這也是G1的優勢之一,隻要還有一塊空閑的region,就可以完成垃圾回收。而不用像CMS那樣必須預留太多的記憶體。

G1點評

從G1的設計上來看,它使用了大量的額外結構來存儲引用關系,并以此來減少垃圾回收中标記的耗時。但是這種額外的結構帶來的記憶體浪費也是存在的,極端情況甚至可以額外占用20%的記憶體。而基于region的記憶體劃分規則則讓記憶體配置設定更加複雜,但是這也有好處。就是記憶體的回收後産生的碎片更少,也就更少觸發full gc。

根據經驗,在大部分的大型記憶體(6G以上)伺服器上,無論是吞吐量還是STW時間,G1的性能都是要優于CMS。

ZGC

了解完G1之後,讓我們再來看看大名鼎鼎的ZGC,也是目前号稱"全程并發,能将停頓時間控制在10ms"内的低延遲垃圾回收器。ZGC全程Z Garbage Collector,這裡面的Z不是什麼縮寫。ZGC在JDK11開始實驗性的開放功能,在JDK17開始實裝。在講解ZGC的垃圾回收流程以前,讓我們先介紹一下ZGC裡面的技術設計,這有助于我們了解ZGC的工作過程。

Page記憶體布局

zgc也是将堆記憶體劃分為一系列的記憶體分區,稱為page(深入了解JVM原書上管這種分區叫做region,但是官方文檔還是叫做的page,我們這裡引用官方文檔的稱呼以免和G1搞混),這種管理方式和G1 GC很相似,但是ZGC的page不具備分代,準确的說應該是ZGC目前不具備分代的特點(目前JDK17版本下的ZGC還是沒有分代的)。原因是如果将ZGC設計為支援分代的垃圾回收,設計太複雜,是以先将ZGC設計為無分代的GC模式,後續再疊代。ZGC的page有3種類型。

  • 小型page(small page)

    容量2M,存放小于256k的對象

  • 中型page(medium page)

    容量32M,存放大于等于256k,但是小于4M的page

  • 大型page(large page)

    容量不固定,但是必須是2M的整數倍。存放4M以上的對象,且隻能存放一個對象。

極緻八股文之JVM垃圾回收器G1&ZGC詳解

記憶體回收算法

ZGC的回收算法也遵循先找到垃圾,然後回收掉垃圾的步驟,其中最複雜的過程也是找到垃圾這個過程。要了解ZGC的并發回收過程先了解3個概念。

染色指針

Colored Pointer,染色指針是一種讓指針存儲額外資訊的技術。我們知道在64位作業系統裡,一個記憶體的位址總共64位,但是受限于實際實體記憶體的大小,我們其實并不是真正的使用所有64位。這裡如果小夥伴了解linux的虛拟記憶體管理會很好了解,我這裡大概解釋一下。我們平時所說作業系統的"實體記憶體位址"并不是真正的"實體記憶體位址",也就是說,并不是實體上,記憶體顆粒對應的位址。而是作業系統為我們虛拟的一個"虛拟位址",這個技術被稱為虛拟記憶體管理。虛拟記憶體基本在所有的linux伺服器上都有使用,除了少部分嵌入式裝置,因為記憶體太小不需要使用這種技術。在虛拟記憶體的幫助下,我們可以做到兩個虛拟記憶體位址對應一個真實的實體位址。虛拟記憶體的知識不是我們的重點,大家有個印象即可。

極緻八股文之JVM垃圾回收器G1&ZGC詳解

對于JVM來說,一個對象的位址隻使用前42位,而第43-46位用來存儲額外的資訊,即GC對象處于ZGC那個階段。隻使用46位的客觀原因是linux系統隻支援46位的實體位址空間,即64T的記憶體,如果一定想要使用更大的記憶體,需要linux額外的設定。但是這個記憶體設定在主流的伺服器上都夠用了。

在引用位址的劃分上,對象引用第43位表示marked0标記,44位marked1标記,45位remapped标記,46位finalizable标記。指針染色就是給對應的位置為1,當然這三個位同一個時間隻能有一個位生效。這些标記分别表示對象處于GC的那個階段裡。在下面ZGC的詳細過程裡我們會介紹染色指針怎麼幫助GC的。指針的引用位址在各個标記之間切換也被稱為指針的自愈。

極緻八股文之JVM垃圾回收器G1&ZGC詳解

讀屏障

read barrier,就是JVM向應用代碼插入一小段代碼的技術,僅當線程從堆中讀取對象引用時觸發。效果上類似于寫屏障,不過是在對象被讀取時觸發。

讀屏障主要是用來改寫每個位址的命名空間。這裡還涉及到指針的自愈self healing。指針的自愈是指的當通路一個正處于重配置設定集中的對象時會被讀屏障攔截,然後通過轉發記錄表forwarding table将通路轉發到新複制的對象上,并且修正并更新該引用的值,使其直接指向新對象。也是因為這個能力,ZGC的STW時間大大縮短,其他的GC則需要修複所有指向本對象的指針後才能通路。這裡的内容可能看不明白,沒關系先放在這裡後續看完ZGC的詳細流程就會明白。

NUMA

numa不是ZGC在垃圾回收器上的創新,但是是ZGC的一大特點。大家了解就可以了。了解NUMA先了解UMA。

  • uma(Uniform Memory Access Architeture)

    統一記憶體通路,也是一般電腦的正常架構,即一塊記憶體多個CPU通路,是以在多核CPU在通路一塊記憶體時會出現競争。作業系統為了為了鎖住某一塊記憶體會限制總線上對某個記憶體的通路,當CPU變多時總線就會變成瓶頸。

  • numa(non Uniform Memory Access Architeture)

    非統一記憶體通路,每塊CPU都有自己的一塊對應記憶體,一般是距離CPU比較近的,CPU會優先通路這塊記憶體。因為CPU之間通路各自的記憶體這樣就減少了競争,效率更高。numa技術允許将多台機器組成一個服務供外部使用,這種技術在大型系統上比較流行,也是一種高性能解決方案,而且堆空間也可以由多台機器組成。ZGC對numa的适配就是ZGC能夠自己感覺numa架構。

ZGC流程

接下來我們詳細學習ZGC的流程,這裡引入一個概念,good_mask。good_mask是記錄JVM垃圾回收階段的标志,随着GC進行不斷切換,并且讓JVM根據good_mask和對象的标記位的關系識别對象是不是本輪GC裡産生的,good_mask可以說是記錄目前JVM視圖空間的變量。ZGC流程如下。

  1. 初始标記(Init Mark)

    初始标記,這一步和G1類似,也是記錄下所有從GC Roots可達的對象。除此之外,還切換了good_mask的值,good_mask初始化出來是remapped,經過這次切換,就變為了marked1(這裡很多人認為第一次是切換到0,其實不是的)。需要注意的是,對象的指針,因為還沒有參加過GC,是以對象的标志位是出于Remapped。經過這一步,所有GC Roots可達的對象就被标記為了marked1。

  2. 并發标記(Concurrent Mark)

    第一執行标記時,視圖為marked1,GC線程從GCRoots出發,如果對象被GC線程通路,那麼對象的位址視圖會被Remapped切換到marked1,在标記結束時,如果對象的位址空間是marked1,那麼說明對象是活躍的,如果是Remapped,那麼說明對象是不活躍的。同時還會記錄下每個内頁中存活的對象的位元組數大小,以便後續做頁面遷移。這個階段新配置設定的對象的染色指針會被置為marked1。

    這個階段還有一件事,就是修複上一次GC時被标記的指針,這也是為什麼染色指針需要兩個命名空間的原因。如果命名空間隻有一個,那麼本次标記時就區分不出來一個已經被标記過的指針是本次标記還是上次标記的。

  3. 重新标記(Remark)

    這個階段是處理一些并發标記階段未處理完的任務(少量STW,控制在1ms内)如果沒處理完還會再次并發标記,這裡其實主要是解決三色标計算法中的漏标的問題,即白色對象被黑色對象持有的問題。并發标記階段發生引用更改的對象會被記錄下來(觸發讀屏障就會記錄),在這個階段标記引用被更改的對象。這裡我就不畫圖了,大家了解意思就行。

  4. 并發預備重配置設定(Concurrent Prepare for Relocate)

    這一步主要是為了之後的遷移做準備,這一步主要是處理軟引用,弱引用,虛引用對象,以及重置page的Forwarding table,收集待回收的page資訊到Relocation Set

    Forwarding table是記錄對象被遷移後的新舊引用的映射表。Relocation Set是存放記錄需要回收的存活頁集合。這個階段ZGC會掃描所有的page,将需要遷移的page資訊存儲到Relocation Set,這一點和G1很不一樣,G1是隻選擇部分需要回收的Region。在記錄page資訊的同時還會初始化page的Forwarding table,記錄下每個page裡有哪些需要遷移的對象。這一步耗時很長,因為是全量掃描所有的page,但是因為是和使用者線程并發運作的,是以并不會STW,而且對比G1,還省去了維護RSet和SATB的成本。

  5. 初始遷移(Relocate Start)

    這個階段是為了找出所有GC Roots直接可達的對象,并且切換good_mask到remapped,這一步是STW的。這裡注意一個問題,被GC Roots直接引用的對象可能需要遷移。如果需要,則會将該對象複制到新的page裡,并且修正GC Roots指向本對象的指針,這個過程就是"指針的自愈"。當然這不是重點重點是切換good_mask。

  6. 并發遷移(Concurrent Relocate)
  7. 這個階段需要周遊所有的page,并且根據page的forward table将存活的對象複制到其他page,然後再forward table裡記錄對象的新老引用位址的對應關系。page中的對象被遷移完畢後,page就會被回收,注意這裡并不會回收掉forward table,否則新老對象的映射關系就丢失了。

    這個階段如果正好使用者線程通路了被遷移後的對象,那麼也會根據forward table修正這個對象被持有的引用,這也是"指針的自愈"。

極緻八股文之JVM垃圾回收器G1&ZGC詳解
  1. 并發重映射(Concurrent Remap)

    這個階段是為了修正所有的被遷移後的對象的引用。嚴格來說并發重映射并不屬于本輪GC階段要采取的操作。因為在第6步執行後,我們就得到了所有的需要重新映射的對象被遷移前後位址映射關系,有了這個關系,在以後的通路時刻,都可以根據這個映射關系重新修正對象的引用,即"指針自愈"。如果這裡直接了當的再重新根據GC Roots周遊所有對象,當然可以完成所有對象的"指針自愈",但是實際是額外的産生了一次周遊所有對象的操作。是以ZGC采取的辦法是将這個階段推遲到下輪ZGC的并發标記去完成,這樣就共用了周遊所有對象的過程。而在下次ZGC開始之前,任何的線程通路被遷移後的對象的引用,則可以觸發讀屏障,并根據forward table自己識别出對象被遷移後的位址,自行完成"指針自愈"。

ZGC點評

以我的能力來點評ZGC的設計似乎有點不妥,但是我還是想結合自己的了解,評價一下ZGC的的優缺點。ZGC的優勢很明顯,幾乎全程并發的回收過程帶來了無與倫比的低暫停時間,這也是ZGC的設計思路。低暫停時間加上JAVA本身的支援高并發的特點,假以時日ZGC将來一定是能在伺服器領域的展現它大殺器級别的威力。但是為了達到這個設計目标,ZGC其實也犧牲了一些東西,比如吞吐量。我知道在很多地方,比如《深入了解JVM》這本書上,都把ZGC描述成全方位碾壓G1的姿态。但是并不是的,至少在JDK21之前的不分代的ZGC不是的,具體的測試,大家可以看一下一篇Oracle的文章。連結我貼在下面。

https://cr.openjdk.org/~pliden/slides/ZGC-OracleDevLive-2020.pdf

當然這些缺點随着ZGC的成熟,以及JDK21在ZGC裡加入分代的特性,都會一點點的好轉。總而言之ZGC還是設計非常優秀的一款垃圾回收器。大家要好好學,尤其是現在ZGC還不是特别流行時,面試時多吹一吹,說不定能唬住一般的面試官。

END

JVM垃圾回收器的知識實在太多了,寫起來非常費勁,關于GC日志相關的知識我就放到後面再講了,後續應該還有一點點JVM垃圾回收器的收尾知識,有機會會分享給大家。

作者:矩矢

來源-微信公衆号:阿裡雲開發者

出處:https://mp.weixin.qq.com/s/Ywj3XMws0IIK-kiUllN87Q

繼續閱讀