JVM G1GC的算法與實現

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 的指數冪,並以該正數作爲區域的大小(如下圖)。

圖 1.1

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 一樣。

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 下載鏈接,需要的小夥伴可以自取~!!!

原創不易,希望大家轉載時請先聯繫我,並標註原文鏈接。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章