不管卷不卷,面試還是得問問你G1原理!

艾小仙發表於2021-06-23

所有的垃圾回收器的目的都是朝著減少STW的目的而前進,G1(Garbage First)回收器的出現顛覆了之前版本CMS、Parallel等垃圾回收器的分代收集方式,從2004年Sun釋出第一篇關於G1的論文後,直到2012年JDK7釋出更新版本,花了將近10年的時間G1才達到商用的程度,而到JDK9釋出之後,G1成為了預設的垃圾回收器,CMS也變相地相當於被淘汰了。

G1結構

G1拋棄了之前的分代收集的方式,面向整個堆記憶體進行回收,把記憶體劃分為多個大小相等的獨立區域Region。

一共有4種Region:

  1. 自由分割槽Free Region
  2. 年輕代分割槽Young Region,年輕代還是會存在Eden和Survivor的區分
  3. 老年代分割槽Old Region
  4. 大物件分割槽Humongous Region

每個Region的大小通過-XX:G1HeapRegionSize來設定,大小為1~32MB,預設最多可以有2048個Region,那麼按照預設值計算G1能管理的最大記憶體就是32MB*2048=64G。

對於大物件的儲存,存在Humongous概念,對G1來說,超過一個Region一半大小的物件都被認為大物件,將會被放入Humongous Region,而對於超過整個Region的大物件,則用幾個連續的Humongous來儲存(如下圖H區域)。

G1優勢

上面我們也提到,垃圾回收器的最終目的都是為了減少STW造成的停頓,比如之前老的垃圾回收器CMS這種帶來的停頓時間是不可預估的。

而G1最大的優勢就在於可預測的停頓時間模型,我們可以自己通過引數-XX:MaxGCPauseMillis來設定允許的停頓時間(預設200ms),G1會收集每個Region的回收之後的空間大小、回收需要的時間,根據評估得到的價值,在後臺維護一個優先順序列表,然後基於我們設定的停頓時間優先回收價值收益最大的Region。

那麼,這個可預測的停頓時間模型怎麼計算和建立的?主要是基於衰減平均值的理論基礎,衰減平均是一種數學方法,用來計算一個數列的平均值,給近期的資料更高的權重,強調近期資料對結果的影響,程式碼如下:

hotspot/src/share/vm/gc_implementation/g1/g1CollectorPolicy.hpp
double get_new_prediction(TruncatedSeq* seq) {
  return MAX2(seq->davg() + sigma() * seq->dsd(),
              seq->davg() * confidence_factor(seq->num()));
}

davg表示衰減值

sigma表示一個係數,代表信貸度,預設值為0.5

dsd表示衰減標準偏差

confidence_factor表示可信度係數,用於當樣本資料不足(小於5個)時取一個大於1的值,樣本資料越少該值越大。

基於這個模型,G1希望根據使用者設定的停頓時間(只是期望時間,儘量努力在這個範圍內完成GC)來選擇需要對哪些Region進行回收,能回收多大空間。

比如過去10次回收10G記憶體花費1s,如果預設的停頓時間是200ms,那麼就最多可以回收2G的記憶體空間。

空間分配&擴充套件

既然G1還是存在新生代和老年代的概念,那麼新生代和老年代的空間是怎麼劃分的呢?

在G1中,新增了兩個引數G1MaxNewSizePercentG1NewSizePercent,用來控制新生代的大小,預設的情況下G1NewSizePercent為5,也就是佔整個堆空間的5%,G1MaxNewSizePercent預設為60,也就是堆空間的60%。

假設現在我們的堆空間大小是4G,按照預設最大2048個Region計算,每個Region的大小就是2M。

初始新生代的大小那麼就是200M,大約100個Region格子,動態擴充套件最大就是60%*4G=2.4G大小。

不過顯然,事情不是這麼簡單,實際上初始化新生代的空間大小邏輯還是挺複雜的。

首先,我們通過原有引數-Xms設定初始堆的大小,-Xmx設定最大堆的大小還是生效的,可以設定堆的大小。

  1. 可以通過原有引數-Xmn或者新的引數G1NewSizePercentG1MaxNewSizePercent來設定年輕代的大小,如果設定了-Xmn相當於設定G1NewSizePercent=G1MaxNewSizePercent

  2. 接著看是不是設定了-XX:NewRatio(表示年輕代與老年代比值,預設值為2,代表年輕代老年代大小為1:2),如果1都設定了,那麼忽略NewRatio,反之則代表G1NewSizePercent=G1MaxNewSizePercent,並且分配規則還是按照NewRatio的規則。

  3. 如果只是設定了G1NewSizePercentG1MaxNewSizePercent中的一個,那麼就按照這兩個引數的預設值5%和60%來設定。

  4. 如果設定了-XX:SurvivorRatio,預設為8,那麼Eden和Survivor還是按照這個比例來分配

按照這個規則,我們新生代和老年代的空間分配基本就完成,如果說新生代走預設的規則,每次動態擴充套件空間大小怎麼辦?

有一個引數叫做-XX:GCTimeRatio表示GC時間與應用耗費時間比,預設為9,就是說GC時間和應用時間佔比超過10%才進行擴充套件,擴充套件比例為20%,最小不能小於1M。

回收過程

G1的回收過程分為以下四個步驟:

  1. 初始標記:標記GC ROOT能關聯到的物件,需要STW
  2. 併發標記:從GCRoots的直接關聯物件開始遍歷整個物件圖的過程,掃描完成後還會重新處理併發標記過程中產生變動的物件
  3. 最終標記:短暫暫停使用者執行緒,再處理一次,需要STW
  4. 篩選回收:更新Region的統計資料,對每個Region的回收價值和成本排序,根據使用者設定的停頓時間制定回收計劃。再把需要回收的Region中存活物件複製到空的Region,同時清理舊的Region。需要STW。

總的來說這是一個偏向記憶的回收過程,知道就行了。

相對於之前我們存在分代概念的GC來說,G1其實也是類似的過程,總體可以分為這兩種:

  1. 年輕代GC,年輕代Region在超過我們預設設定的最大大小之後就會觸發GC,還是用的我們熟悉的複製演算法,Eden和Survivor來回倒騰,這裡不再贅述。
  2. Mixed GC混合回收,混合回收類似於之前我們的Full GC概念,既會回收年輕代的Region,也會回收老年代的Region,還有我們新的Humongous大物件區域。觸發規則根據引數-XX:InitiatingHeapOccupancyPercent(預設45%)值,也就是說老年代Region達到整個堆記憶體的45%時觸發Mixed GC。

其他問題

上面應該把基本概念都解釋完了。

比如什麼是G1?G1有什麼特點?他的優點是什麼?劃分Region後怎麼分配空間?怎麼進行垃圾回收?什麼時候進行YGC?什麼時候進行FGC?可靠的停頓時間模型建立方式?

除此之外,其實還有一些較為複雜的問題,比如之前我們說分代收集有跨代引用的問題,劃分Region之後應該也有對不對,那怎麼解決的?

還有之前我們說併發收集階段怎麼解決使用者執行緒和收集執行緒互不干擾的?

這些更深一點的問題其實在現在已經卷到需要問三色標記了嗎?已經說到了很多了,下面我們再詳細點說明下在G1中的一些不同點。

記憶集

在這篇文章中我們提到過一次關於Remembered Set的概念,為了避免GC時掃描整個堆記憶體,用來標誌哪些區域存在跨代引用,對於G1來說也一樣,只不過G1的記憶集會更復雜一點。

每個Region中都存在一個Hash Table結構的記憶集,Key為其他Region的起始地址,Value是其他Card Table卡表的索引集合。

原來我們的卡表指向的是卡頁的記憶體地址段,代表我引用了誰,現在的記憶集則是代表著誰引用了我,因此收集的過程會更復雜一點,並且需要額外的10%~20%的堆記憶體空間來維持。

維護記憶集的方式也和卡表類似,通過寫屏障來實現。

原始快照SATB

在三色標記中我們也提到過,併發標記使用者執行緒和收集執行緒一起工作會產生問題,解決方案CMS使用的是增量更新,G1則是用原始快照。

總結

寫這些東西比較費勁,因為總在想在理解的基礎上怎麼寫的更通俗易懂,但是發現好像並不容易,因為自己也都是看完沒過多久就忘記了,所以記錄下來,能看懂就行了,實在不行就去看書。

周老師的深入Java虛擬機器寫的比較簡單,很多東西要去搜資料和書結合看才能看明白,另外一本書寫的也不是很好,作者感覺只是堆砌知識點,看起來很費勁,美團寫的那篇文章也是一大堆名詞,不知道的人看的簡直蛋疼。

我應該,比他們寫的更通俗一點就好了?

參考:

彭成寒《JVM G1原始碼分析和調優》

周志明《深入理解Java虛擬機器第三版》

美團:Java Hotspot G1 GC的一些關鍵技術

相關文章