美團面試官問我: ZGC 的 Z 是什麼意思

yes的練級攻略發表於2020-11-18

本文的閱讀有一定的門檻,請先了解 GC 的基本只知識。

現代垃圾收集器的演進大部分都是往減少停頓方向發展。

像 CMS 就是分離出一些階段使得應用執行緒可以和垃圾回收執行緒併發,當然還有利用回收執行緒的並行來減少停頓的時間。

基本上 STW 階段都是利用多執行緒並行來減少停頓時間,而併發階段不會有太多的回收執行緒工作,這是為了不和應用執行緒爭搶 CPU,反正都併發了慢就慢點(不過還是得考慮記憶體分配速率)。

而 G1 可以認為是開啟了另一個方向的大門:只回收部分垃圾來減少停頓時間

不過為了達到只回收部分 reigon,每個 region 都需要 RememberSet 來記錄各 region 之間的引用。這個記憶體的開銷其實還是挺大的,可能會佔據整堆的20%或以上。

並且 G1 還有寫屏障的開銷,雖說用了 logging wtire barrier,但也還是有開銷的。

當然 CMS 也用了寫屏障,不過邏輯比較簡單,啥都沒判斷就單純的記錄。

其實 G1 相對於 CMS 只有在大堆的場景下才有優勢,CMS 比較傷的是 remark 階段,如果堆太大需要掃描的東西太多。

而 G1 在大堆的時候可以選擇部分收集,所以停頓時間有優勢。

今天的主角 ZGC 和 G1 一樣是基於 reigon 的,幾乎所有階段都是併發的,整堆掃描,部分收集

而且 ZGC 還不分代,就是沒分新生代和老年代。

那它為啥比 G1 要牛皮?今天我們們就來盤一盤。

本文會先介紹 ZGC 的特性,或者說幾個關鍵點,然後再簡述下整體回收流程

基本上看下來對 ZCG 心中就有數了,作為普通的 Javaer,瞭解到這個程度就差不多了。

好了,讓我們進入今天的正題!

ZGC 的目標

垃圾收集器設計出來都有目標的,有些是為了更高的吞吐,有些是為了更低的延遲。

所以我們先看看 ZGC 的目標:

可以看到它的目標就是低延遲,保證最大停頓時間在幾毫秒之內,不管你堆多大或者存活的物件有多少。

可以處理 8MB-16TB 的堆。

我們們就按 openjdk 的 wiki 來展開今天的內容。

關鍵字:併發、基於Region、整理記憶體、支援NUMA、用了染色指標、用了讀屏障,對了 ZGC 用的是 STAB。

Concurrent

這個 Concurrent 的意思是和應用執行緒併發執行,ZGC 一共分了 10 個階段,只有 3 個很短暫的階段是 STW 的。

可以看到只有初始標記、再標記、初始轉移階段是 STW 的。

初始標記就掃描 GC Roots 直接可達的,耗時很短,重新標記一般而言也很短,如果超過 1ms 會再次進入併發標記階段再來一遍,所以影響不大。

初始轉移階段也是掃描 GC Roots 也很短,所以可以認為 ZGC 幾乎是併發的。

而且之所以說停頓時間不會隨著堆的大小和存活物件的數量增加而增加,是因為 STW 幾乎只和 GC Roots 集合大小有關,和堆大小沒啥關係。

這其實就是 ZGC 超過 G1 很關鍵的一個地方, G1 的物件轉移需要 STW 所以堆大需要轉移物件多,停頓的時間就長了,而 ZGC 有併發轉移

不過併發回收有個情況就是回收的時候應用執行緒還是在產生新的物件,所以需要預留一些空間給併發時候生成的新物件。

如果物件分配過快導致記憶體不夠,在 CMS 中是發生 Full gc,而 ZGC 則是阻塞應用執行緒。

所以要注意 ZGC 觸發的時間。

ZGC 有自適應演算法來觸發也有固定時間觸發,所以可以根據實際場景來修改 ZGC 觸發時間,防止過晚觸發而記憶體分配過快導致執行緒阻塞。

還有設定 ParallelGCThreads 和 ConcGCThreads,分別是 STW 並行時候的執行緒數和併發階段的執行緒數來加快回收的速度。

不過 ConcGCThreads 數量需要注意,因為此階段是和應用執行緒併發,如果執行緒數過多會影響應用執行緒

其實 ZGC 的每個階段都是序列的,所以理論上其實可以不需要分兩類執行緒,那為什麼分了這兩類執行緒?

就是為了靈活設定。分成兩類就可以通過配置來調優,達到效能最大值。

對了上面提到 ZGC 的 STW 和 GC Roots 集合大小有關係,所以如果在會生成很多執行緒、動態載入很多 ClassLoader 等情況下會增加 ZGC 的停頓時間。

這點需要注意。

Region-based

為了能更細粒度的控制記憶體的分配,和 G1 一樣 ZGC 也將堆劃分成很多分割槽。

分了三種:2MB、32MB 和 X*MB(受作業系統控制)。

下圖為原始碼中的註釋:

對於回收的策略是優先收集小區,中、大區儘量不回收。

Compacting

和 G1 一樣都分割槽了所以肯定從整體來看像是標記-複製演算法,所以也是會整理的。

因此 ZGC 也不會產生記憶體碎片。

具體的流程下文再做分析。

NUMA-aware

以前的 G1 是不支援的,不過在 JDK14 G1 也支援了。

可能有的同學對 NUMA 不太熟悉,沒事我先來解釋一波。

在早期處理器都是單核的,因為根據摩爾定律,處理器的效能每隔一段時間就可以成指數型增長。

而近年來這個增長的速度逐漸變緩,於是很多廠商就推出了雙核多核的計算機。

早期 CPU 通過前端匯流排到北橋到記憶體匯流排然後才訪問到記憶體。

這個架構被稱為 SMP (Symmetric Multi-Processor),因為任一個 CPU 對記憶體的訪問速度是一致的,不用考慮不同記憶體地址之間的差異,所以也稱一致記憶體訪問(Uniform Memory Access, UMA )。

這個核心越加越多,漸漸的匯流排和北橋就成為瓶頸,那不能夠啊,於是就想了個辦法。

把 CPU 和記憶體整合到一個單元上,這個就是非一致記憶體訪問 (Non-Uniform Memory Access,NUMA)。

簡單的說就是把記憶體分一分,每個 CPU 訪問自己的本地的記憶體比較快,訪問別人的遠端記憶體就比較慢。

當然也可以多個 CPU 享受一塊記憶體或者多塊,如下圖所示:

但是因為記憶體被切分為本地記憶體和遠端記憶體,當某個模組比較“熱”的時候,就可能產生本地記憶體爆滿,而遠端記憶體都很空閒的情況。

比如 64G 記憶體一分為二,模組一的記憶體用了31G,而另一個模組的記憶體用了5G,且模組一隻能用本地記憶體,這就產生了記憶體不平衡問題。

如果有些策略規定不能訪問遠端記憶體的時候,就會出現明明還有很多記憶體卻產生 SWAP(將部分記憶體置換到硬碟中) 的情況。

即使允許訪問遠端記憶體那也比本地記憶體訪問速率相差較大,這是使用 NUMA 需要考慮的問題。

ZGC 對 NUMA 的支援是小分割槽分配時會優先從本地記憶體分配,如果本地記憶體不足則從遠端記憶體分配。

對於中、大分割槽的話就交由作業系統決定。

上述做法的原因是生成的絕大部分都是小分割槽物件,因此優先本地分配速度較快,而且也不易造成記憶體不平衡的情況。

而中、大分割槽物件較大,如果都從本地分配則可能會導致記憶體不平衡的情況。

Using colored pointers

染色指標其實就是從 64 位的指標中,拿幾位來標識物件此時的情況,分別表示 Marked0、Marked1、Remapped、Finalizable。

我們再來看下原始碼中的註釋,非常的清晰直觀:

0-41 這 42 位就是正常的地址,所以說 ZGC 最大支援 4TB (理論上可以16TB)的記憶體,因為就 42 位用來表示地址。

也因此 ZGC 不支援 32 位指標,也不支援指標壓縮。

然後用 42-45 位來作為標誌位,其實不管這個標誌位是啥指向的都是同一個物件。

這是通過多重對映來做的,很簡單就是多個虛擬地址指向同一個實體地址,不過物件地址是 0001.... 還是0010....還是0100..... 對應的都是同一個實體地址即可。

具體這幾個標記位怎麼用的,待下文回收流程分析再解釋。

不過這裡先提個問題,為什麼就支援 4TB,不是還有很多位沒用嗎

首先 X86_64 的地址匯流排只有 48 條 ,所以最多其實只能用 48 位,指令集是 64 位沒錯,但是硬體層面就支援 48 位。

因為基本上沒有多少系統支援這麼大的記憶體,那支援 64 位就沒必要了,所以就支援到 48 位。

那現在物件地址就用了 42 位,染色指標用了 4 位,不是還有 2 位可以用嗎?

是的,理論上可以支援 16 TB,不過暫時認為 4TB 夠了,所以暫做保留,僅此而已沒啥特別的含義。

Using load barriers

在 CMS 和 G1 中都用到了寫屏障,而 ZGC 用到了讀屏障。

寫屏障是在物件引用賦值時候的 AOP,而讀屏障是在讀取引用時的 AOP。

比如 Object a = obj.foo;,這個過程就會觸發讀屏障。

也正是用了讀屏障,ZGC 可以併發轉移物件,而 G1 用的是寫屏障,所以轉移物件時候只能 STW。

簡單的說就是 GC 執行緒轉移物件之後,應用執行緒讀取物件時,可以利用讀屏障通過指標上的標誌來判斷物件是否被轉移。

如果是的話修正物件的引用,按照上面的例子,不僅 a 能得到最新的引用地址,obj.foo 也會被更新,這樣下次訪問的時候一切都是正常的,就沒有消耗了。

下圖展示了讀屏障的效果,其實就是轉移的時候找地方記一下即 forwardingTable,然後讀的時候觸發引用的修正。

這種也稱之為“自愈”,不僅賦值的引用時最新的,自身引用也修正了。

染色指標和讀屏障是 ZGC 能實現併發轉移的關鍵所在

ZGC 回收流程解析

ZGC 的步驟大致可分為三大階段分別是標記、轉移、重定位。

  • 標記:從根開始標記所有存活物件
  • 轉移:選擇部分活躍物件轉移到新的記憶體空間上
  • 重定位:因為物件地址變了,所以之前指向老物件的指標都要換到新物件地址上。

並且這三個階段都是併發的。

這是意識上的階段,具體的實現上重定位其實是糅合在標記階段的

在標記的時候如果發現引用的還是老的地址則會修正成新的地址,然後再進行標記。

簡單的說就是從第一個 GC 開始經歷了標記,然後轉移了物件,這個時候不會重定位,只會記錄物件都轉移到哪裡了。

在第二個 GC 開始標記的時候發現這個物件是被轉移了,然後發現引用還是老的,則進行重定位,即修改成新的引用。

所以說重定位是糅合在下一步的標記階段中。

我再簡單說一下十個步驟。

不過步驟裡有些不影響整體回收流程的,我就不多加分析了。

這篇文章的目的不是深入 ZGC 實現的細節,而是瞭解 ZGC 大致的突出點和簡單流程即可

因此想知道細節的自行查閱,或者可以看看我文末推薦的書籍。

初始標記

這個階段其實大家應該很熟悉,CMS、G1 都有這個階段,這個階段是 STW 的,僅標記根直接可達的物件,壓到標記棧中

當然還有其他動作,比如重置 TLAB、判斷是否要清除軟引用等等,不做具體分析。

併發標記

就是根據初始標記的物件開始併發遍歷物件圖,還會統計每個 region 的存活物件的數量。

這個併發標記其實有個細節,標記棧其實只有一個,但是併發標記的執行緒有多個。

為了減少之間的競爭每個執行緒其實會分到不同的標記帶來執行。

你就理解為標記棧被分割為好幾塊,每個執行緒負責其中的一塊進行遍歷標記物件,就和1.7 Hashmap 的segment 一樣。

那肯定有的執行緒標記的快,有的標記的慢,那麼先空閒下來的執行緒會去竊取別人的任務來執行,從而實現負載均衡。

看到這有沒有想到啥?沒錯就是 ForkJoinPool 的工作竊取機制!

再標記階段

這一階段是 STW 的,因為併發階段應用執行緒還是在執行的,所以會修改物件的引用導致漏標的情況。

因此需要個再標記階段來標記漏標的那些物件。

如果這個階段執行的時間過長,就會再次進入到併發標記階段,因為 ZGC 的目標就是低延遲,所以一有高延遲的苗頭就得扼制。

這個階段還會做非強根並行標記,非強根指的是:系統字典、JVMTI、JFR、字串表。

有些非強根可以併發,有些不行,具體不做分析。

非強引用併發標記和引用併發處理

就是上一步非強根的遍歷,然後引用就軟引用、弱引用、虛引用的一些處理。

這個階段是併發的。

重置轉移集

還記得標記時候的重定位麼?在寫讀屏障時候提到的 forwardingTable 就是個對映集,你可以理解為 key 就是物件轉移前的地址,value 是物件轉移後的地址。

不過這個對映集在標記階段已經用了,也就是說標記的時候已經重定位完了,所以現在沒用了。

但新一輪的垃圾回收需要還是要用到這個對映集的。

因此在這個階段對那些轉移分割槽的地址對映集做一個復位的操作。

回收無效分割槽

回收那些實體記憶體已經被釋放的無效的虛擬記憶體頁面。

就是在記憶體緊張的時候會釋放實體記憶體,如果同時釋放虛擬空間的話也不能釋放分割槽,因為分割槽需要在新一輪標記完成之後才能釋放

所以就會有無效的虛擬記憶體頁面存在,在這個階段回收。

選擇待回收的分割槽

這和 G1 一樣,因為會有很多可以回收的分割槽,會篩選垃圾較多的分割槽,來作為這次回收的分割槽集合。

初始化待轉移集合的轉移表

這一步就是初始化待回收的分割槽的 forwardingTable。

初始轉移

這個階段其實就是從根集合出發,如果物件在轉移的分割槽集合中,則在新的分割槽分配物件空間。

如果不在轉移分割槽集合中,則將物件標記為 Remapped。

注意這個階段是 STW,只轉移根直接可達的物件。

併發轉移

這個階段和併發標記階段就很類似了,從上一步轉移的物件開始遍歷,做併發轉移。

這一步很關鍵。

G1 的轉移物件整體都需要 STW,而 ZGC 做到了併發轉移,所以延遲會低很多。

至此十個步驟就完畢了,一次 GC 結束。

可以還能同學對染色指標的幾個標記位有點懵,沒事看了下文就懂了。

染色指標的標記位

來分析下幾個標記位,M0、M1、Remapped。

先來介紹個名詞,地址檢視:指的就是此時地址指標的標記位。

比如標記位現在是 M0,那麼此時的檢視就是 M0 檢視。

在垃圾回收開始前檢視是 Remapped 。

進入標記標記時。

標記執行緒訪問發現物件地址檢視是 Remapped 這時候將指標標記為 M0,即將地址檢視置為 M0,表示活躍物件。

如果掃描到物件地址檢視是 M0 則說明這個物件是標記開始之後新分配的或者已經標記過的物件,所以無需處理。

應用執行緒 如果建立新物件,則將其地址檢視置為 M0,如果訪問的物件地址檢視是 Remapped 則將其置為 M0,並且遞迴標記其引用的物件。

如果訪問到的是 M0 ,則無需操作。

標記階段結束後,ZGC 會使用一個物件活躍表來儲存這些物件地址,此時活躍的物件地址檢視是 M0。

併發轉移階段,地址檢視被置為 Remapped 。

也就是說 GC 執行緒如果訪問到物件,此時物件地址檢視是 M0,並且存在或活躍表中,則將其轉移,並將地址檢視置為 Remapped 。

如果在活躍表中,但是地址檢視已經是 Remapped 說明已經被轉移了,不做處理。

應用執行緒此時建立新物件,地址檢視會設為 Remapped 。

此時訪問物件如果物件在活躍表中,且地址檢視為 Remapped 說明轉移過了,不做處理。

如果地址檢視為 M0,則說明還未轉移,則需要轉移,並將其地址檢視置為 Remapped 。

如果訪問到的物件不在活躍表中,則不做處理。

那 M1 什麼用

M1 是在下一次 GC 時候用的,下一次的 GC 就用 M1來標記,不用 M0。

再下一次再換過來。

簡單的說就是 M1 標識本次垃圾回收中活躍的物件,而 M0 是上一次回收被標記的物件,但是沒有被轉移,在本次回收中也沒有被標記活躍的物件。

其實從上面的分析以及得知,如果沒有被轉移就會停留在 M0 這個地址檢視。

而下一次 GC 如果還是用 M0 來標識那混淆了這兩種物件。

所以搞了個 M1。

至此染色指標這幾個標誌位應該就很清晰了,我在用圖來示意一下。

不清晰的同學建議再多看幾遍標記位的變更,不復雜的。

最後

簡單的總結下,ZGC 就是通過多階段的併發和幾個短暫的 STW 階段來達到低延遲的特性。

利用指標染色技術和讀屏障實現併發轉移物件,利用 STAB 保證併發階段不會漏標物件。

這一波一下相信大家對於 ZGC 有了一定的瞭解。

我個人認為重點就掌握官網羅列的那幾個要點就行,畢竟我們們也不是寫 GC 的,作為了解即可。

到時候和學妹呀,或者在面試官前面呀都可以小吹一下。

如果想深入瞭解當然可以,可先看看《新一代垃圾回收器ZGC設計與實現》這本書,然後再原始碼走起。

ZGC 的不分代其實是它的缺點,因為分代比較難實現,不過以後應該會加上吧。​

其實從現代垃圾收集器的演進可以看出就是往併發上面靠,目標就是減少停頓的時間。

不過併發需要注意記憶體分配的速率,因為併發導致一次垃圾回收總的時間變長了

如果記憶體分配過快那就回收不過來了,因此都需要預留記憶體空間或者說要更大的記憶體空間來應對快速的分配速率。

可能大夥還惦記這標題吧?ZGC 的 Z 是什麼意思?

其實沒啥意思,就是個名字而已。

巨人的肩膀

https://www.iteye.com/blog/user/rednaxelafx R大的部落格
https://malloc.se/blog/zgc-jdk15
https://wiki.openjdk.java.net/display/zgc/Main
《新一代垃圾回收器ZGC設計與實現》

我是 yes,從一點點到億點點,我們下篇見。

相關文章