G1GC 是什麼?

G1GC(Garbage First Garbage Collection)是在 OpenJDK 7 中引入的 GC 演算法,其最大的特點就是非常重視實時性

一些基本概念

實時性

程式具有實時性,是指程式必須能在最後期限(deadline)之前完成,其中最後期限可以自由指定。實時性分為兩種:

  • 硬實時性(hard real-time):每次處理的時間都不能超過最後期限,比如醫療機器人控制系統、航空管制系統。
  • 軟實時性(soft real-time):稍微超出幾次最後期限也沒有什麼問題的系統,例如網路銀行系統。

G1GC 具有軟實時性,為了實現軟實時性,必須具備以下功能:

  • 設定期望暫停時間(最後期限)
  • 可預測性:預測下次 GC 會導致應用程式暫停多長時間。根據預測出的結果,G1GC 會通過延遲執行 GC拆分 GC 目標物件等手段來遵守上面設定的期望暫停時間。

G1GC 有什麼特點?

Java 中已經有很多種 GC 演算法了,為什麼還要增加 G1GC 演算法呢?

  • 以往的 GC 都是儘可能縮短最大暫停時間,但是縮短最大暫停時間很容易導致吞吐量下降。
  • 以往的 GC 無法預測暫停時間,GC 時可能會使應用程式長時間暫停的風險。
  • G1GC 的目的就是高效地實現軟實時性,能夠讓使用者設定期望暫停時間。在確保吞吐量比以往的 GC 更好的前提下,實現了軟實時性。
  • G1GC 能最大程度利用伺服器上多處理器的優勢,而且在處理巨大的堆時,也不會降低 GC 的效能。

G1GC 的堆結構是什麼樣的?

G1GC 堆的內部被劃分為大小相等的區域,所有區域排成一排。G1GC 以區域為單位進行 GC。使用者可以隨意設定區域大小,但是內部會將使用者設定的值向上調整為 2 的指數冪,並以該正數作為區域的大小(如下圖)。

G1GC 的執行過程是什麼樣的?

  • 併發標記(concurrent marking):和應用程式併發執行,針對區域內所有的存活物件進行標記。
  • 轉移(evacuation):釋放堆中死亡物件所佔的記憶體空間。

白色區域是空閒區域,灰色區域是使用中的區域。

  • 左圖表示的是在選中區域後開始將存活物件複製到空閒區域的操作
  • 右圖表示的是轉移後堆的狀態。

為了方便演示,圖中的區域以二維的方式排列,但是在記憶體中其實如下圖是排列成一排的。

併發標記

併發標記是什麼

簡單標記,所有可從根直接觸達的物件都會被新增標記。帶標記的是存活物件,不帶標記的是死亡物件。

在併發標記中,存活物件的標記和應用程式幾乎是併發進行的,步驟更加複雜。併發標記並不是直接在物件上新增標記,而是在標記點陣圖上新增標記。

標記點陣圖

下圖表示堆中的一個區域,點陣圖中黑色表示已標記,白色表示未標記。

每個區域有兩個標記點陣圖:

  • next:本次標記的標記點陣圖。
  • prev:上次標記的標記點陣圖,儲存了上次標記的結果。

標記點陣圖中的每個位元都對應關聯區域內的物件的開頭部分。圖中區域部分:

  • bottom:區域內眾多物件的末尾
  • top:區域中物件的開頭
  • nextTAMS:本次標記開始時的 top(TAMS-Top At Marking Start)
  • prevTAMS:上次標記開始時的 top

執行步驟

  1. 初始標記階段:暫停應用程式,標記可由根直接引用的物件。
  2. 併發標記階段:與應用程式併發進行,掃描 1 中標記的物件所引用的物件。
  3. 最終標記階段:暫停應用程式,掃描 2 中沒有標記的物件。本步驟結束後,堆內所有存活物件都會被標記。
  4. 存活物件計數:對每個區域中被標記的物件進行計數,併發執行。
  5. 收尾工作:暫停應用程式,收尾工作,併為下次標記做準備。

步驟 1——初始標記階段

在初始標記階段,GC 執行緒首先建立標記點陣圖 next。其中 nextTAMS 是標記開始時,top 所在的位置。點陣圖的大小也和 top 對齊,是 (top-botton)/8 位元組。

等所有區域的標記點陣圖都建立完成後,標記由根直接引用的物件(根掃描)。此時是需要暫停應用程式的,這是為了防止掃描過程中根被修改。

如果一個物件本身被標記,但是子物件沒有被掃描,我們稱之為未掃描物件,上圖用灰色標識,C 持有子物件 A 和 E,但是 A 和 E 並未被掃描。

步驟 2——併發標記階段

在併發標記階段,GC 執行緒掃描在 1 階段標記過的物件,完成對大部分存活物件的標記。

上圖表示併發標記結束的狀態,物件 C 的子物件 A 和 E 都被標記了。E 對應了標記點陣圖中多個位,只有起始的標記位(mark bit)會被塗成黑色。

因為併發標記是和應用程式併發執行的,所以在這個階段可能會產生的物件,上圖中 J 和 K 就是在併發標記期間新建立的物件,直接會被 GC 當成存活物件。

同時因為是併發執行,應用程式可能會改變了物件之間的引用關係,需要使用寫屏障技術來記錄物件間引用關係的變化。併發標記階段也會標記和掃描被寫屏障感知變化的物件。

STAB

STAB(Snapshot At The Beginning,初始快照)是將併發標記階段開始時物件間的引用關係,以邏輯快照的形式儲存起來。標記過程中新生成的物件是“已完成掃描和標記”的,其子物件不會被標記。那如何區分是標記過程中新生成的物件呢?初始標記階段記錄的 nextTAMS 和 當前 top 之間的物件,所以並不需要專門為新生成的物件建立標記點陣圖。

還有個很重要的問題,在併發標記過程中,物件的域發生了寫操作怎麼辦?此時必須以某種方式記錄被改寫之前的引用關係。

G1GC 使用SATB 專用寫屏障。在一個物件的域發生寫操作時,這個物件會被放入 SATB 本地佇列(SATB 本地佇列滿後,會被新增到全域性的 SATB 佇列結合)。在併發標記階段,GC 執行緒會定期檢查 SATB 佇列集合的大小,對佇列中的全部物件進行標記和掃描。如果獲取到已經被標記的物件,這些物件不會再次被標記和掃描。

步驟 3——最終標記階段

主要掃描 SATB 本地佇列(隊裡裡仍然存放了待掃描物件)。因為 SATB 本地佇列會被應用程式操作,所以需要暫停應用程式。

上圖中 SATB 本地佇列中還有物件 G 和 H 的引用,掃描後物件 G 和 H,以及物件 H 的子物件 I 都會變成黑色。

步驟 4——存活物件計數

掃描各個區域的標記點陣圖 next,統計區域記憶體活物件的位元組數,存到區域內的 next_marked_bytes 中。下圖中存活物件 A、C、E、G、H 和 I,一共 6 個物件,其中 E 真實大小是 16 個位元組,其餘 5 個物件分別是 8 個位元組,所以 next_marked_bytes 是 56 個位元組。

在計數的過程中,又新建立了物件 L 和 M,nextTAMS 和 top 之間的物件都會被當做存活物件處理,沒有特意進行計數。

步驟 5——收尾工作

收尾工作所操作的資料中有些是和應用程式共享的,所以需要暫停應用程式。

收尾階段主要做了兩件事情:

  • GC 執行緒逐個掃描每個區域,將標記點陣圖 next 的併發標記結果移動到標記點陣圖 prev 中,再重置標記,為下次併發做準備。
  • 在掃描過程中,計算每個區域的轉移效率,並按照該效率對區域進行降序排序。

上圖中 prevTAMS 被移動到了 nextTAMS 原來的位置,表示“上次併發標記開始時 top 的位置”。next.next_marked_bytes 也會被重置,同時 nextTAMS 移動到 bottom 的位置,其會在下次併發標記開始時,移動到 top 的最新位置。

轉移效率

指轉移 1 個位元組所需的時間。通俗理解就是,區域內死亡物件越多,存活物件就越少;而存活物件越少,那麼轉移所需的時間就越少。

計算公式為:死亡物件的位元組數 / 轉移所需時間

併發標記總結

併發標記結束後,可以得到:

  • 併發標記完成時,存活物件和死亡物件的區分(此時在標記點陣圖 prev)
  • 存活物件的位元組數(prev_marked_bytes)

如果新的物件是在併發標記結束後被建立的,因為新物件是分配在 prevTAMS 和 top 之間的,所以後被當成存活物件處理。

轉移

轉移是什麼?

將所選區域內的所有存活物件都轉移到空閒區域,因此被轉移區域就只剩下死亡物件。重置之後,該區域就會成為空閒區域。

轉移專用記憶集合

上節介紹的SATB 佇列集合是記錄標記過程中物件之間引用關係的變化,這裡的轉移專用記憶集合記錄區域間的引用關係,這樣不用掃描所有區域的物件,也能查到待轉移物件所佔區域內的物件被其他區域引用的情況。

G1GC 是通過卡表(card table)來實現轉移專用記憶集合的。

卡表

是元素大小為 1B 的陣列,堆中大小適當的一段儲存空間(通常是 512B)對應卡表中的 1 個元素。在堆大小是 1GB 時,卡表大小為 2MB。

堆中物件所對應的卡片在卡表的索引值 = (物件的地址 - 堆的頭部地址) / 512

因為卡片的大小是 1B,所有可以表示很多狀態,狀態有很多,在後面只介紹兩種:

  • 淨卡片
  • 髒卡片

轉移專用記憶集合的構造

每個區域都有一個轉移專用記憶集合,是通過散列表實現的:

  • 鍵:引用本區域的其他區域的地址
  • 值:陣列,陣列元素是引用方的物件所對應的卡片索引

在上圖中,區域 B 中的物件 b 引用了區域 A 中的物件 a。因為物件 b 不是區域 A 中的物件,所以必須記錄這個引用關係。在轉移記憶集合 A 中,以區域 B 的地址為鍵記錄了卡片的索引 2048(物件 b 對應的卡片索引),此時物件 b 對物件 a 的引用被準確記錄了下來。

轉移專用寫屏障

那 GC 是如何感知域的變化呢?是通過轉移專用寫屏障,當物件修改時,會被轉移專用寫屏障記錄到轉移專用記憶集合中。

每個應用程式執行緒都持有一個轉移專用記憶集合日誌的緩衝區,其中存放的是卡片索引的陣列。當物件 b 的域被修改時,寫屏障就會感知,並會將物件 b 所對應的卡片索引新增到轉移專用記憶集合日誌中。

轉移專用記憶集合維護執行緒

是和應用程式併發執行的執行緒,是基於上述日誌維護轉移專用記憶集合。主要步驟:

  • 從轉移專用記憶集合日誌的集合中取出轉移專用記憶集合日誌,從頭開始掃描
  • 將卡片變為淨卡片
  • 檢查卡片所對應儲存空間內的所有物件的域
  • 向域中地址所指向的區域的記憶集合中新增卡片

熱卡片

頻繁發生修改的儲存空間所對應的卡片就是熱卡片。熱卡片可能會多次進入轉移專用記憶集合日誌,被多次處理成髒卡片,增加維護執行緒的負擔。

可以通過卡片計數器,發現熱卡片,當某個卡片變成髒卡片的次數超過閾值,可以等到轉移的時候再處理。

轉移的執行步驟

  • 選擇回收集合:參考併發標記提供的資訊,選擇要轉移的區域。
  • 根轉移:將回收集合內由根直接引用的物件,及被其他區域引用的物件轉移到空閒區域中。
  • 轉移:以根轉移的物件為起點,掃描子孫物件,將所有存活物件一併轉移。此時回收集合內所有存活物件都轉移完成了。

步驟 1——選擇回收集合

選擇待回收區域的標準:

  • 轉移效率要高
  • 轉移的預測停頓時間在使用者的容忍範圍內

在併發標記階段結束時,堆中區域已經按照轉移效率降序了。這裡就是按照排好的順序依次計算各個區域內的預測暫停時間,當所有已選區域預測的暫停時間和快要超過使用者的容忍範圍時,後續區域的選擇就會停止,當前所選的區域就是 1 個回收集合。

步驟 2——根轉移

根轉移的物件包括:

  • 由根直接引用的物件
  • 併發標記處理中的物件
  • 由其他區域物件直接引用的回收集合內的物件

  1. 物件 a 轉移到空閒區域。
  2. 物件 a 在空閒區域中的新地址寫入到轉移前所在區域中的舊位置。
  3. 將物件 a 引用的所有位於回收集合內的物件,都新增到轉移佇列中。轉移佇列臨時儲存待轉移物件的引用方。因為物件 a 引用了物件 b,兩個都是要轉移的物件,地址都會變化。
  4. 針對物件 a 引用的位於回收集合外的物件,更新轉移專用記憶集合。物件 c 所在區域不在回收集合內,但是區域 C 的轉移專用記憶集合記錄了 a 對應的卡片,在 a 轉移之後,需要更新區域 C 的轉移專用記憶集合。
  5. 針對物件 a 的引用方,更新轉移專用記憶集合。

步驟 3——轉移

完成根轉移後,被轉移佇列引用的物件會依次轉移。當轉移佇列清空後,轉移就完成了。此時回收集合內所有存活物件都轉移完成了。

分代 G1GC 模式

G1GC 有 2 中模式:

  • 純 G1GC 模式:pure garbage-first mode
  • 分代 G1GC 模式:generational garbage-first mode

本文上面講的都是純 G1GC 模式。

兩種 GC 的區別

和純 G1GC 模式相比,分代 G1GC 模式主要有以下兩個不同點。

  • 區域是分代的
  • 回收集合的選擇是分代的

在分代 G1GC 模式中,區域被分為新生代區域老年代區域兩類。 和其他分代 GC 演算法一樣,分代 G1GC 的物件也儲存了自身在各次轉移中存活下來的次數。新生代區域用來存放新生代物件,老年代區域用來存放老年代物件。

G1GC 中新生代 GC 是完全新生代 GC,老年代 GC 是部分新生代 GC。二者區別在於完全新生代 GC 將所有新生代區域選入回收集合,而部分新生代 GC 將所有新生代區域,以及一部分老年代區域選入回收集合。

新生代區域

新生代區域可以進一步分為兩類:

  • 建立區域:存放剛剛生成,一次也沒有轉移過的物件
  • 存活區域:存放至少轉移過一次的物件

轉移專用寫屏障不會應用在新生代區域的物件上。為什麼這樣做是可以的呢?因為轉移專用記憶集合維護的是區域之間的引用關係,所以在轉移時不用掃描整個區域就能找到待轉移物件所在區域的存活物件。而在分代 G1GC 模式中,所有新生代區域都會被選入回收集合,所有物件的引用都會被檢查,這些資訊就沒有記錄在轉移專用記憶集合中了。

分代物件轉移

存活物件儲存了自己被轉移的次數,這個次數就是物件的年齡

  • 年齡<閾值:轉移到存活區域
  • 年齡>=閾值:轉移到老年代區域

執行過程

如上圖,完全新生代 GC 不會選擇老年代區域,而是將所有新生代區域都選入回收集合,然後統一轉移回收集合的物件。晉升的物件會被轉移到老年代區域,其餘的轉移到存活區域。

如上圖,部分新生代 GC 除了所有新生代區域外,還會選擇一些老年代區域進入回收集合。其餘都和完全新生代 GC 一樣。

GC 的切換

如果新生代的區域數太多,可能導致 GC 暫停時間上限的增加,無法保證軟實時性。分代 G1GC 模式需要計算出合理的最大新生代區域。該值的設定是在併發標記結束後。

參考併發標記中標記出的死亡物件個數,預測出下次部分新 生代 GC 的轉移效率。然後,根據過去的完全新生代 GC 的轉移效率, 預測出下次完全新生代 GC 的轉移效率。如果預測出完全新生代 GC 的 轉移效率更高,則切換為完全新生代 GC。

GC 的執行時機

當新生代區域數達到上限時,會觸發轉移的執行。,當轉移完成並通過以下 4 項檢查,會執行併發標記:

  • 不在併發標記執行過程中
  • 併發標記的結果已被上次轉移使用完
  • 已經使用了一定量的堆記憶體
  • 相比上次轉移完成後,堆記憶體的使用量有所增加

G1 演算法總結

關係圖

圖中並列的箭頭表示可能會並行執行。

優點

  • 軟實時性
  • 充分發揮高配置機器的效能,縮減 GC 暫停時間
  • 區域內不會產生記憶體碎片

缺點

  • 被限定為“搭載多核處理器、擁有大容量記憶體的機器”,適用受限。
  • 儘管區域內不會出現碎片化,但是會出現以區域為單位(整個堆)的碎片化。

參考

《深入 Java 虛擬機器-JVM G1GC 的演算法與實現》

GitHub LeetCode 專案

專案 GitHub LeetCode 全解,歡迎大家 star、fork、merge,共同打造最全 LeetCode 題解!

Java 程式設計思想-最全思維導圖-GitHub 下載連結,需要的小夥伴可以自取~!!!

原創不易,希望大家轉載時請先聯絡我,並標註原文連結。