垃圾回收之CMS、G1、ZGC對比

邴越發表於2023-04-01

ZGC(The Z Garbage Collector)是JDK 11中推出的一款低延遲垃圾回收器,它的設計目標包括:

  • 停頓時間不超過10ms;
  • 停頓時間不會隨著堆的大小,或者活躍物件的大小而增加;
  • 支援8MB~4TB級別的堆(未來支援16TB)。

從設計目標來看,我們知道ZGC適用於大記憶體低延遲服務的記憶體管理和回收。

 

特性包括:

  • 基於Region記憶體佈局
  • 暫時不設分代
  • 使用了讀屏障、 顏色指標等技術來實現可併發的標記-整理演算法
  • 以低延遲為首要目標。

 

ZGC在JDK15達到production-ready,JDK17是第一個開始推出成熟的ZGC的長期支援的ZGC版本。

 

一、標記清除、複製、標記整理

1、標記清除 Mark-Sweep

Mark-Sweep演算法是現代垃圾回收演算法的思想基礎。

 

 

 

 

  • 標記階段:首先透過根節點,標記所有從根節點開始的可達物件。未被標記的物件就是未被引用的垃圾物件
  • 清除階段:清除所有未被標記的物件。

 

不足:

  • 效率問題:標記和清除兩個過程的效率都不高。
  • 空間問題:標記清除後會產生大量不連續的記憶體碎片,空間碎片太多可能會導致以後在程式執行過程中需要分配較大物件時,無法找到足夠的連續記憶體而不得不提前出發另一次垃圾收集動作。

注意:何為清除?

這裡所謂的清除並不是真的置空,而是把需要清除的物件地址儲存在空閒的地址列表裡。下次有新物件需要載入時,判斷垃圾的位置空間是否夠,如果夠,就存放。

 

2、複製 Copying

  • 將原有的記憶體空間分為兩塊,每次只使用一塊
  • 在垃圾回收時,將正在使用的記憶體中的存活物件複製到未被使用的記憶體塊中,然後清除正在使用的記憶體塊中的所有物件。
  • 交換兩塊記憶體的角色,完成垃圾回收。

 

 

 

 

 

 

 

與標記-清除演算法相比,複製演算法是一種相對高效的回收方法。

  • 不適用於存活物件較多的場合,如老年代。
  • 不用考慮記憶體碎片等複雜情況,只要一動堆頂指標,按順序分配記憶體即可,實現簡單,執行高效。
  • 只是這種演算法的代價是將記憶體縮小為了原來的一半,未免太高了點。

 

複製演算法理論上是不需要標記過程的,從gc roots開始,遇到活物件就複製走了,gc roots找可達物件的過程結束就複製完了。

 

 

3、標記整理演算法 Mark—Compact 

 

Copying複製演算法在物件存活率較高時就要進行較多的複製操作,效率將會變低。更關鍵的是,如果不想浪費50%的空間,就需要額外的空間進行分配擔保,以應對被使用的記憶體中所有物件都100%存活的極端情況,所以老年代一般不能直接選用這種演算法。

 

根據老年代的特點,使用“標記-整理”(Mark-Compact)演算法,標記過程仍然與“標記-清除”演算法一樣,但後續步驟不是直接對可回收物件進行清理,而是讓所有存活物件都向一端移動,然後直接清理掉端邊界以外的記憶體。

 

 

 

 

 

 

 

CMS新生代的Young GC、G1和ZGC都基於標記-複製演算法,但演算法具體實現的不同就導致了巨大的效能差異。

 

4、分代收集 generation collection

現代虛擬機器基本都採用分代收集理論來進行垃圾回收。

分代清理並非是一種單獨的演算法,而是一種收集理論,分代收集結合了以上的 3 種演算法,根據物件的生命週期的不同將記憶體劃分為幾塊,然後根據各塊的特點採用最適當的收集演算法。

 

 

 

 

現在的商業虛擬機器都採用複製演算法來回收新生代,IBM公司的專門研究表明,新生代中的物件98%是“朝生夕死”的,所以並不需要按照1:1的比例來劃分記憶體空間。

 

堆記憶體中的新生代分為一塊較大的Eden空間和兩塊較小的Survivor空間,每次使用Eden和其中一塊From Survivor,當回收時,將Eden和From Survivor中還存活著的物件一次性地複製到另外一塊To Survivor空間上,最後清理掉Eden和剛才用過的From Survivor空間。

 

HotSpot虛擬機器預設Eden:From Survivor:To Survivor = 8:1:1 ,也就是每次新生代中可用記憶體空間為整個新生代容量的90%(80%+10%),只有10%的記憶體會被“浪費”。

 

 

二、CMS和G1回顧

 

1、CMS 收集器

CMS(Concurrent Mark Sweep)收集器是一種以獲取最短回收停頓時間為目標的收集器。這是因為 CMS 收集器工作時,GC 工作執行緒與使用者執行緒可以併發執行,以此來達到降低收集停頓時間的目的。

 

CMS 收集器僅作用於老年代的收集,是基於標記-清除演算法的,它的運作過程分為 4 個步驟:

 

  • 初始標記(CMS initial mark)
  • 併發標記(CMS concurrent mark)
  • 重新標記(CMS remark)
  • 併發清除(CMS concurrent sweep)

 

其中,初始標記、重新標記這兩個步驟仍然需要 Stop-the-world。初始標記僅僅只是標記一下 GC Roots 能直接關聯到的物件,速度很快,併發標記階段就是進行 GC Roots Tracing 的過程,而重新標記階段則是為了修正併發標記期間因使用者程式繼續運作而導致標記產生變動的那一部分物件的標記記錄,這個階段的停頓時間一般會比初始階段稍長一些,但遠比並發標記的時間短。

 

CMS 以流水線方式拆分了收集週期,將耗時長的操作單元保持與應用執行緒併發執行。只將那些必需 STW 才能執行的操作單元單獨拎出來,控制這些單元在恰當的時機執行,並能保證僅需短暫的時間就可以完成。這樣,在整個收集週期內,只有兩次短暫的暫停(初始標記和重新標記),達到了近似併發的目的。

 

CMS 收集器優點:併發收集、低停頓。

 

CMS 收集器缺點:

 

  • CMS 收集器對 CPU 資源非常敏感。
  • CMS 收集器無法處理浮動垃圾(Floating Garbage)。
  • CMS 收集器是基於標記-清除演算法,該演算法的缺點都有(記憶體碎片)。
  • 停頓時間是不可預期的。

 

CMS 收集器之所以能夠做到併發,根本原因在於採用基於“標記-清除”的演算法並對演算法過程進行了細粒度的分解。前面篇章介紹過標記-清除演算法將產生大量的記憶體碎片這對新生代來說是難以接受的,因此新生代的收集器並未提供 CMS 版本。

 

2、G1收集器

G1 在 1.9 版本後成為 JVM 的預設垃圾回收演算法,G1 的特點是保持高回收率的同時,減少停頓。

G1 演算法取消了堆中年輕代與老年代的物理劃分,但它仍然屬於分代收集器。G1 演算法將堆劃分為若干個區域,稱作 Region,如下圖中的小方格所示。一部分割槽域用作年輕代,一部分用作老年代,另外還有一種專門用來儲存巨型物件的分割槽。

 

 

 

 

G1 也和 CMS 一樣會遍歷全部的物件,然後標記物件引用情況,在清除物件後會對區域進行復制移動整合碎片空間。

G1 回收過程如下。

  • G1 的年輕代回收,採用複製演算法,並行進行收集,收集過程會 STW。
  • G1 的老年代回收時也同時會對年輕代進行回收。主要分為四個階段:
  • 依然是初始標記階段完成對根物件的標記,這個過程是STW的;
  • 併發標記階段,這個階段是和使用者執行緒並行執行的;
  • 最終標記階段,完成三色標記週期;
  • 複製/清除階段,這個階段會優先對可回收空間較大的 Region 進行回收,即 garbage first,這也是 G1 名稱的由來。

  

G1 採用每次只清理一部分而不是全部的 Region 的增量式清理,由此來保證每次 GC 停頓時間不會過長。

G1 是邏輯分代不是物理劃分,需要知道回收的過程和停頓的階段。

此外還需要知道,G1 演算法允許透過 JVM 引數設定 Region 的大小,範圍是 1~32MB,可以設定期望的最大 GC 停頓時間等。

 

 

三、ZGC核心原理

 

ZGC和其他GC演算法的對比,來自RednaxelaFX:

這種併發演算法的核心思想就是:

  • 在標記階段,與其說是標記物件(記錄物件是否已經被標記),不如說是標記指標(記錄GC堆裡的每個指標是否已經被標記)。這就與傳統的三色標記物件的GC演算法有非常大的區別,雖然兩者從收斂性上看是等價的——最終所有物件以及所有指標都會被遍歷過。
  • 在標記和移動物件的階段,每次從GC堆裡的物件的引用型別欄位裡讀取一個指標的時候,這個指標都會經過一個“Loaded Value Barrier”(LVB)。這是一種“Read Barrier”(讀屏障),會在不同階段做不同的事情。最簡單的事情就是,在標記階段它會把指標標記上並把堆裡的這個指標給“修正”到新的標記後的值;而在移動物件的階段,這個屏障會把讀出的指標更新到物件的新地址上,並且把堆裡的這個指標“修正”到原本的欄位裡。這樣就算GC把物件移動了,讀屏障也會發現並修正指標,於是應用程式碼就永遠都會持有更新後的有效指標,而不需要透過stop-the-world這種最粗粒度的同步方式來讓GC與應用之間同步。
  • LVB中有一點很重要,就是“self healing”性質:如果堆上有指標當前處於“尚未更新”的狀態,一旦經過LVB之後就會被就地更新,於是在同一個GC週期內再次訪問這個欄位的話就不需要再修正了。這樣LVB帶來的效能開銷(吞吐量的下降)就是非常短暫的,而不像Shenandoah GC所使用的Brooks indirection pointer那樣一直都慢。

 

 

1、更全面的併發

與CMS中的ParNew和G1類似,ZGC也採用標記-複製演算法,不過ZGC對該演算法做了重大改進:ZGC在標記、轉移和重定位階段幾乎都是併發的,這是ZGC實現停頓時間小於10ms目標的最關鍵原因。

 

ZGC垃圾回收週期如下圖所示:

 

 

 

 

 

 

ZGC只有三個STW階段:初始標記,再標記,初始轉移。

 

其中,初始標記和初始轉移分別都只需要掃描所有GC Roots,其處理時間和GC Roots的數量成正比,一般情況耗時非常短;再標記階段STW時間很短,最多1ms,超過1ms則再次進入併發標記階段。即,ZGC幾乎所有暫停都只依賴於GC Roots集合大小,停頓時間不會隨著堆的大小或者活躍物件的大小而增加。與ZGC對比,G1的轉移階段完全STW的,且停頓時間隨存活物件的大小增加而增加。

 

 

2、指標著色和讀屏障

ZGC透過著色指標和讀屏障技術,解決了轉移過程中準確訪問物件的問題,實現了併發轉移。

 

大致原理描述如下:併發轉移中“併發”意味著GC執行緒在轉移物件的過程中,應用執行緒也在不停地訪問物件。

 

假設物件發生轉移,但物件地址未及時更新,那麼應用執行緒可能訪問到舊地址,從而造成錯誤。

 

而在ZGC中,應用執行緒訪問物件將觸發“讀屏障”,如果發現物件被移動了,那麼“讀屏障”會把讀出來的指標更新到物件的新地址上,這樣應用執行緒始終訪問的都是物件的新地址。

 

那麼,JVM是如何判斷物件被移動過呢?就是利用物件引用的地址,即著色指標。

 

下面介紹著色指標和讀屏障技術細節。

 

(1)著色指標

著色指標是一種將資訊儲存在指標中的技術。

ZGC僅支援64位系統,它把64位虛擬地址空間劃分為多個子空間,如下圖所示:

 

 

其中,[0~4TB) 對應Java堆,[4TB ~ 8TB) 稱為M0地址空間,[8TB ~ 12TB) 稱為M1地址空間,[12TB ~ 16TB) 預留未使用,[16TB ~ 20TB) 稱為Remapped空間。

當應用程式建立物件時,首先在堆空間申請一個虛擬地址,但該虛擬地址並不會對映到真正的實體地址。ZGC同時會為該物件在M0、M1和Remapped地址空間分別申請一個虛擬地址,且這三個虛擬地址對應同一個實體地址,但這三個空間在同一時間有且只有一個空間有效。ZGC之所以設定三個虛擬地址空間,是因為它使用“空間換時間”思想,去降低GC停頓時間。“空間換時間”中的空間是虛擬空間,而不是真正的物理空間。後續章節將詳細介紹這三個空間的切換過程。

與上述地址空間劃分相對應,ZGC實際僅使用64位地址空間的第0~41位,而第42~45位儲存後設資料,第47~63位固定為0。

 

 

ZGC將物件存活資訊儲存在42~45位中,這與傳統的垃圾回收並將物件存活資訊放在物件頭中完全不同。

 

 

(2)讀屏障

讀屏障是JVM嚮應用程式碼插入一小段程式碼的技術。當應用執行緒從堆中讀取物件引用時,就會執行這段程式碼。需要注意的是,僅“從堆中讀取物件引用”才會觸發這段程式碼。

讀屏障示例:

Object o = obj.FieldA   // 從堆中讀取引用,需要加入屏障
<Load barrier>
Object p = o  // 無需加入屏障,因為不是從堆中讀取引用
o.dosomething() // 無需加入屏障,因為不是從堆中讀取引用
int i =  obj.FieldB  //無需加入屏障,因為不是物件引用

ZGC中讀屏障的程式碼作用:在物件標記和轉移過程中,用於確定物件的引用地址是否滿足條件,並作出相應動作。

 
 

3、ZGC併發處理演示

接下來詳細介紹ZGC一次垃圾回收週期中地址檢視的切換過程:

 

  • 初始化:ZGC初始化之後,整個記憶體空間的地址檢視被設定為Remapped。程式正常執行,在記憶體中分配物件,滿足一定條件後垃圾回收啟動,此時進入標記階段。
  • 併發標記階段:第一次進入標記階段時檢視為M0,如果物件被GC標記執行緒或者應用執行緒訪問過,那麼就將物件的地址檢視從Remapped調整為M0。所以,在標記階段結束之後,物件的地址要麼是M0檢視,要麼是Remapped。如果物件的地址是M0檢視,那麼說明物件是活躍的;如果物件的地址是Remapped檢視,說明物件是不活躍的。
  • 併發轉移階段:標記結束後就進入轉移階段,此時地址檢視再次被設定為Remapped。如果物件被GC轉移執行緒或者應用執行緒訪問過,那麼就將物件的地址檢視從M0調整為Remapped。

 

其實,在標記階段存在兩個地址檢視M0和M1,上面的過程顯示只用了一個地址檢視。之所以設計成兩個,是為了區別前一次標記和當前標記。也即,第二次進入併發標記階段後,地址檢視調整為M1,而非M0。

 

著色指標和讀屏障技術不僅應用在併發轉移階段,還應用在併發標記階段:將物件設定為已標記,傳統的垃圾回收器需要進行一次記憶體訪問,並將物件存活資訊放在物件頭中;而在ZGC中,只需要設定指標地址的第42~45位即可,並且因為是暫存器訪問,所以速度比訪問記憶體更快。

 

 

 

四、ZGC有什麼缺點

 

雖然ZGC屬於最新的GC技術, 但優點不一定真的出眾。

ZGC只在特定情況下具有絕對的優勢, 如巨大的堆和極低的暫停需求,而實際上大多數開發在這兩方面都不太成問題(尤其是在伺服器端), 而對GC的效能/效率更在意。

GC技術這些年其實並沒有很大的發展, 也就是說沒有銀彈, 某些方面具有優勢肯定是犧牲其它方面換來的, ZGC也很明顯, 官方的設定目標是不損失超過15%的G1GC效能, 也就是說從吞吐速率上肯定無法跟G1相比了, 更沒法跟完全STW的GC去比.

ZGC執行的時候能觀察到下面的一些問題:

  1. 單代GC吞吐低:最顯著的問題是Concurrent Mark階段都需要全堆標記(耗時長),導致回收速度跟不上物件分配速度:
  2. 會出現分配停頓(Allocation Stall),需要啟動一次新的ZGC,這次ZGC週期內所有應用執行緒都要暫停下來;
  3. 最壞情況甚至發生OOM:Concurrent Relocate階段如果剩餘的空間依然不夠,就會丟擲OOM;
  4. GC執行緒併發執行導致CPU偏高;
  5. 由於ZGC採用colored pointer技術,因此不支援UseCompressedOops(相比之下ShenandoahGC卻能支援),一定程度上影響小堆(32GB以下)的效能;
  6. 不過JDK15後可以支援UseCompressedOops關閉時依然開啟UseCompressedClassPointers,這樣一定程度上緩解了效能上的缺憾;
  7. 物件分配卡頓,除了ZGC的暫停階段之外,還受到下面的一些因素的影響:
  8. Page Cache Flush問題影響分配速度:ZGC把堆分為不同大小的page(對應G1的Region)——small/medium/large page(不同大小的object分配到不同型別的page中),如果各種大小物件分配速度不穩定(比如medium大小的object突然變多,那麼就需要把large/small page轉換成medium page,比較耗時),JDK15 production-ready之後有所緩解;
  9. 只有單個medium page:應用執行緒較多的情形下,如果多個執行緒同時分配medium大小的object且當前medium page空閒大小不夠時,那麼就會同時請求分配新的medium page,undo多餘的分配會延遲分配,還有可能導致上述Page Cache Flush發生;
  10. RSS特別高,可達3倍Xmx,這是由ZGC的multi-mapping機制導致的。

 

 

 

相關文章