Java技術專題-JVM研究系列(7)記憶體佈局及GC原理分析(下卷)

語言: CN / TW / HK

前提概要

目前已經寫了上卷和中卷的針對於GC虛擬機器相關的文章了,詳細可見Java技術專題-JVM研究系列(7)記憶體佈局及GC原理分析(上卷)Java技術專題-JVM研究系列(7)記憶體佈局及GC原理分析(中卷),目前我相信已經會有相關的對GC的原理和虛擬機器的運作機制有了一定的瞭解了,接下來,該講一下重頭戲了ZGC,也是至此為今最牛掰的GC回收器。現在開始我們的旅程吧!與CMS相比,G1 有記憶體整理過程(標記-壓縮),避免了記憶體碎片;STW 時間可控(能預測 GC 停頓時間)。

ZGC (截止目前史上最好的 GC 收集器)

ZGC(The Z Garbage Collector) 是 JDK 11 中推出的一款低延遲垃圾回收器,它的設計目標包括: 在G1的基礎上,做了很多改進(JDK 11 開始引入)

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

從設計目標來看,我們知道 ZGC 適用於大記憶體低延遲服務的記憶體管理和回收。本文主要介紹 ZGC 在低延時場景中的應用和卓越表現,文章內容主要分為四部分:


  • GC 之痛 :介紹實際業務中遇到的 GC 痛點,並分析 CMS 收集器和 G1 收集器停頓時間瓶頸;
  • ZGC 原理 :分析 ZGC 停頓時間比 G1 或 CMS 更短的本質原因,以及背後的技術原理;
  • ZGC 調優實踐 :重點分享對 ZGC 調優的理解,並分析若干個實際調優案例;
  • 升級 ZGC 效果 :展示在生產環境應用 ZGC 取得的效果。

GC 之痛

很多低延遲高可用Java服務的系統可用性經常受 GC 停頓的困擾。GC 停頓指垃圾回收期間 STW(Stop The World),當 STW 時,所有應用執行緒停止活動,等待GC停頓結束。


以美團風控服務為例,部分上游業務要求風控服務 65ms 內返回結果,並且可用性要達到 99.99%。但因為 GC 停頓,我們未能達到上述可用性目標。當時使用的是 CMS 垃圾回收器,單次 Young GC 40ms,一分鐘 10 次,介面平均響應時間 30ms。通過計算可知,有( 40ms + 30ms ) * 10 次 / 60000ms = 1.12%的請求的響應時間會增加 0 ~ 40ms 不等,其中 30ms * 10 次 / 60000ms = 0.5%的請求響應時間會增加 40ms。

可見,GC 停頓對響應時間的影響較大。為了降低 GC 停頓對系統可用性的影響,我們從降低單次 GC 時間和降低 GC 頻率兩個角度出發進行了調優,還測試過G1垃圾回收器,但這三項措施均未能降低 GC 對服務可用性的影響。

CMS 與 G1 停頓時間瓶頸


介紹ZGC之前,首先回顧一下 CMS 和 G1 的 GC 過程以及停頓時間的瓶頸。CMS 新生代的 Young GC、G1 和 ZGC 都基於標記-複製演算法,但演算法具體實現的不同就導致了巨大的效能差異。

標記-複製演算法應用在 CMS 新生代(ParNew 是 CMS 預設的新生代垃圾回收器)和 G1 垃圾回收器中

標記-複製演算法可以分為四個階段:


  1. 標記階段,即從 GC Roots 集合開始,標記活躍物件
  2. 清理階段,即清理所有不活躍的物件
  3. 轉移階段,即把活躍物件複製到新的記憶體地址上
  4. 重定位階段,因為轉移導致物件的地址發生了變化,在重定位階段,所有指向物件舊地址的指標都要調整到物件新的地址上

下面以 G1 為例,通過 G1 中標記-複製演算法過程(G1 的 Young GC 和 Mixed GC 均採用該演算法),分析 G1 停頓耗時的主要瓶頸。G1 垃圾回收週期如下圖所示:

G1 的混合回收過程可以分為標記階段、清理階段和複製階段。

標記階段停頓分析

初始標記階段 :
初始標記階段是指從 GC Roots 出發標記全部直接子節點的過程,該階段是 STW 的。由於 GC Roots 數量不多,通常該階段耗時非常短。
併發標記階段 :
併發標記階段是指從 GC Roots 開始對堆中物件進行可達性分析,找出存活物件。該階段是併發的,即應用執行緒和 GC 執行緒可以同時活動。
併發標記耗時相對長很多,但因為不是 STW,所以我們不太關心該階段耗時的長短。
再標記階段 :
重新標記那些在併發標記階段發生變化的物件。該階段是 STW 的。

清理階段停頓分析

清理階段清點出有存活物件的分割槽和沒有存活物件的分割槽,該階段不會清理垃圾物件,也不會執行存活物件的複製。該階段是 STW 的,因此此階段不會出現浮動物件。

複製階段停頓分析

複製演算法中的轉移階段需要分配新記憶體和複製物件的成員變數。轉移階段是 STW 的,其中記憶體分配通常耗時非常短,但物件成員變數的複製耗時有可能較長,這是因為複製耗時與存活物件數量與物件複雜度成正比。物件越複雜,複製耗時越長。(不會存在記憶體碎片且不適合大物件的賦值遷移)

四個 STW 過程中:
  • 初始標記因為只標記 GC Roots,耗時較短。(STW)
  • 再標記因為物件數少,耗時也較短。
  • 清理階段因為記憶體分割槽數量少,耗時也較短。(STW)
  • 轉移階段要處理所有存活的物件,耗時會較長。(STW)

因此,G1 停頓時間的瓶頸主要是標記-複製中的轉移階段 STW。為什麼轉移階段不能和標記階段一樣併發執行呢?主要是 G1 未能解決轉移過程中準確定位物件地址的問題。

G1 的 Young GC 和 CMS 的 Young GC,其標記-複製全過程 STW,這裡不再詳細闡述

ZGC 原理

全併發的ZGC

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

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

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

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


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

ZGC 關鍵技術

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

大致原理描述如下:併發轉移中“併發”意味著 GC 執行緒在轉移物件的過程中,應用執行緒也在不停地訪問物件。假設物件發生轉移,但物件地址未及時更新,那麼應用執行緒可能訪問到舊地址,從而造成錯誤。ZGC中,應用執行緒訪問物件將觸發“讀屏障”,如果發現物件被移動了,那麼“讀屏障”會把讀出來的指標更新到物件的新地址上,這樣應用執行緒始終訪問的都是物件的新地址。那麼,JVM 是如何判斷物件被移動過呢?就是利用物件引用的地址,即著色指標。下面介紹著色指標和讀屏障技術細節。

著色指標

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

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 位中,這與傳統的垃圾回收並將物件存活資訊放在物件頭中完全不同。

讀屏障

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

讀屏障示例:

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

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

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 不是“銀彈”,需要根據服務的具體特點進行調優。網路上能搜尋到實戰經驗較少,調優理論需自行摸索,我們在此階段也耗費了不少時間,最終才達到理想的效能。本文的一個目的是列舉一些使用 ZGC 時常見的問題,幫助大家使用 ZGC 提高服務可用性。

分享到:
「其他文章」