JVM 三色標記 增量更新 原始快照

基本概念

STAB

三色標記法

2.1 基本算法

要找出存活對象,根據可達性分析,從GC Roots開始進行遍歷訪問,可達的則爲存活對象:

img

最終結果:A/D/E/F/G 可達

我們把遍歷對象圖過程中遇到的對象,按“是否訪問過”這個條件標記成以下三種顏色:

  • 白色:尚未訪問過。
  • 黑色:本對象已訪問過,而且本對象 引用到 的其他對象 也全部訪問過了。
  • 灰色:本對象已訪問過,但是本對象 引用到 的其他對象 尚未全部訪問完。全部訪問後,會轉換爲黑色。

img

三色標記遍歷過程

假設現在有白、灰、黑三個集合(表示當前對象的顏色),其遍歷訪問過程爲:

  1. 初始時,所有對象都在 【白色集合】中;
  2. 將GC Roots 直接引用到的對象 挪到 【灰色集合】中;
  3. 從灰色集合中獲取對象:
    3.1. 將本對象 引用到的 其他對象 全部挪到 【灰色集合】中;
    3.2. 將本對象 挪到 【黑色集合】裏面。
  4. 重複步驟3,直至【灰色集合】爲空時結束。
  5. 結束後,仍在【白色集合】的對象即爲GC Roots 不可達,可以進行回收。

注:如果標記結束後對象仍爲白色,意味着已經“找不到”該對象在哪了,不可能會再被重新引用。

當Stop The World (以下簡稱 STW)時,對象間的引用 是不會發生變化的,可以輕鬆完成標記。
而當需要支持併發標記時,即標記期間應用線程還在繼續跑,對象間的引用可能發生變化多標漏標的情況就有可能發生。

  • 浮動垃圾(多標):將原本應該被清除的對象,誤標記爲存活對象。後果是垃圾回收不徹底,不過影響不大,可以在下個週期被回收;
  • 對象消失(漏標):將原本應該存活的對象,誤標記爲需要清理的對象。後果很嚴重,影響程序運行,是不可容忍的。

能不能在併發標記期間,將用戶線程對引用關係的修改都保存起來?併發標記完成後,再將這些保存的修改過程,重新進行標記和調整?能,CMS 就是這麼幹的。它將併發標記期間引用發生變化的對象都暫存起來,併發標記完成後,再重新對這些暫存的對象重新進行一次標記。雖然重新標記的過程是需要 STW 的,但是重新標記的對象數量遠遠小於併發標記階段的對象數量,因此停頓時間也是短暫且相對固定的,因此這個方法可行!

2.2 多標-浮動垃圾

假設已經遍歷到E(變爲灰色了),此時應用執行了 objD.fieldE = null

img

D > E 的引用斷開

此刻之後,對象E/F/G是“應該”被回收的。然而因爲E已經變爲灰色了,其仍會被當作存活對象繼續遍歷下去。最終的結果是:這部分對象仍會被標記爲存活,即本輪GC不會回收這部分內存

這部分本應該回收 但是 沒有回收到的內存,被稱之爲“浮動垃圾”。浮動垃圾並不會影響應用程序的正確性,只是需要等到下一輪垃圾回收中才被清除。

另外,針對併發標記開始後的新對象,通常的做法是直接全部當成黑色,本輪不會進行清除。這部分對象期間可能會變爲垃圾,這也算是浮動垃圾的一部分。

2.3 漏標-讀寫屏障

假設GC線程已經遍歷到E(變爲灰色了),此時應用線程先執行了:s

var G = objE.fieldG; 
objE.fieldG = null;  // 灰色E 斷開引用 白色G 
objD.fieldG = G;  // 黑色D 引用 白色G

img

E > G 斷開,D引用 G

此時切回GC線程繼續跑,因爲E已經沒有對G的引用了,所以不會將G放到灰色集合;儘管因爲D重新引用了G,但因爲D已經是黑色了,不會再重新做遍歷處理。
最終導致的結果是:G會一直停留在白色集合中,最後被當作垃圾進行清除。這直接影響到了應用程序的正確性,是不可接受的。

漏標必須要同時滿足以下兩個條件:

  1. 賦值器插入了一條或者多條從黑色對象到白色對象的新引用;
  2. 賦值器刪除了全部從灰色對象到該白色對象的直接或間接引用。

這兩個條件必須全部滿足,纔會出現對象消失的問題。那麼我們只需要對上面條件進行破壞,破壞其中的任意一個,都可以防止對象消失問題的產生。這樣就產生了兩種解決方案:

  • 增量更新:Incremental Update。
  • 原始快照:Snapshot At The Beginning,SATB。

增量更新破壞的是第一個條件,當黑色對象插入新的指向白色對象的引用時,就將這個新加入的引用記錄下來,待併發標記完成後,重新對這種新增的引用記錄進行掃描;原始快照破壞的是第二個條件,當灰色對象要刪除指向白色對象的引用關係時,也是將這個記錄下來,併發標記完成後,對該記錄進行重新掃描。

HotSpot 虛擬機中,不管是新增還是刪除,這種記錄的操作都是通過寫屏障實現的。我們可以將寫屏障理解爲 JVM 對引用修改操作的一層 AOP,注意它與內存屏障是兩個不同的東西。

增量更新與原始快照在 HotSpot 中都有實際應用,其中增量更新用在 CMS 中,原始快照用在了 G1、Shenandoah 等回收器中。

增量更新

增量更新破壞的是第一個條件,在新增一條引用時,將該記錄保存。實際的實現中,通常是將引用相關的節點進行重新標記。考慮下圖中的例子:

上面就是一次引用關係修改導致的對象消失問題。增量更新進行的處理,就是將由 A 到 C 的這條新增的引用關係進行保存。首先看下 Dijkstra 等人提出的方式:

write_barrier(obj, field, newobj) {
    if (newobj.mark == FALSE) {
        newobj.mark = TRUE;
        push(newobj, $mark_stack);
    }
    *field = newobj;
}

如果新引用的對象 newobj 沒有被標記,那麼就將其標記後堆到標記棧裏。換句話說, 如果 newobj 是白色對象,就把它塗成灰色。這樣操作後的結果如下圖所示:

此時 C 被塗成了灰色,它將在後續被重新掃描,阻止了對象消失。

Steele 提出了一種更嚴厲的方法,它相比 Dijkstra 的方法,可以減少錯誤標記的對象數量。

write_barrier(obj, field, newobj) {
    if($gc_phase == GC_MARK && obj.mark == TRUE && newobj.mark == FALSE) {
        obj.mark = FALSE;
        push(obj, $mark_stack);
    }
    *field = newobj;
}

如果在標記過程中發出引用的對象是黑色對象,且新的引用的目標對象爲灰色或白色,那麼我們就把發出引用的對象塗成灰色。這樣操作後的結果如下圖:

此時 A 由原來的黑色變成了灰色,將在後續被重新掃描。

原始快照

原始快照破壞的是第二個條件,當灰色對象要刪除指向白色對象的引用關係時,就將這個要刪除的引用記錄下來,併發掃描結束後,在將這些記錄重新掃描一次。

write_barrier(obj, field, newobj) {
    oldobj = *field;
    if(gc_phase == GC_MARK && oldobj.mark == FALSE) {
        oldobj.mark = TRUE;
        push(oldobj, $mark_stack);
    }
    *field = newobj;
}

當 GC 進入到標記階段且 oldobj 沒被標記時,則標記 oldobj,並將其記錄。也就是說,在標記階段中如果指針更新前引用的 oldobj 是白色對象,就將其塗成灰色。

1上圖依舊是對象消失的例子。a 到 b 中,產生了一條由 A 到 C 的引用關係,這裏並沒有像增量更新那樣將 A 或者 C 標爲灰色,相反原始快照中允許出現從黑色指向白色的引用。而在從 b 到 c 中,刪除了由 B 到 C 的引用關係。這時候就需要進行處理,將 C 塗爲灰色。

2.4 三色標記法與現代垃圾回收器

現代追蹤式(可達性分析)的垃圾回收器幾乎都借鑑了三色標記的算法思想,儘管實現的方式不盡相同:比如白色/黑色集合一般都不會出現(但是有其他體現顏色的地方)、灰色集合可以通過棧/隊列/緩存日誌等方式進行實現、遍歷方式可以是廣度/深度遍歷等等。

對於讀寫屏障,以Java HotSpot VM爲例,其併發標記時對漏標的處理方案如下:

  • CMS:寫屏障 + 增量更新
  • G1:寫屏障 + SATB(原始快照)
  • ZGC:讀屏障

工程實現中,讀寫屏障還有其他功能,比如寫屏障可以用於記錄跨代/區引用的變化,讀屏障可以用於支持移動對象的併發執行等。功能之外,還有性能的考慮,所以對於選擇哪種,每款垃圾回收器都有自己的想法。

值得注意的是,CMS中使用的增量更新,在重新標記階段,除了需要遍歷 寫屏障的記錄,還需要重新掃描遍歷GC Roots(當然標記過的無需再遍歷了),這是由於CMS對於astore_x等指令不添加寫屏障的原因,具體可參考這裏

爲什麼G1用SATB?CMS用增量更新?

增量更新:黑色對象新增一條指向白色對象的引用,那麼要進行深入掃描白色對象及它的引用對象。

原始快照:灰色對象刪除了一條指向白色對象的引用,實際上就產生了浮動垃圾,好處是不需要像 CMS 那樣 remark,再走一遍 root trace 這種相當耗時的流程。

我的理解:SATB相對增量更新效率會高(當然SATB可能造成更多的浮動垃圾),因爲不需要在重新標記階段再次深度掃描被刪除引用對象,而CMS對增量引用的根對象會做深度掃描,G1因爲很多對象都位於不同的region,CMS就一塊老年代區域,重新深度掃描對象的話G1的代價會比CMS高,所以G1選擇SATB不深度掃描對象,只是簡單標記,等到下一輪GC再深度掃描。

記憶集與卡表

在新生代做GCRoots可達性掃描過程中可能會碰到跨代引用的對象,這種如果又去對老年代再去掃描效率太低了。爲此,在新生代可以引入記錄集(Remember Set)的數據結構(記錄從非收集區到收集區的指針集合),避免把整個老年代加入GCRoots掃描範圍。事實上並不只是新生代、 老年代之間纔有跨代引用的問題, 所有涉及部分區域收集(Partial GC) 行爲的垃圾收集器, 典型的如G1、 ZGC和Shenandoah收集器, 都會面臨相同的問題。

垃圾收集場景中,收集器只需通過記憶集判斷出某一塊非收集區域是否存在指向收集區域的指針即可,無需瞭解跨代引用指針的全部細節。

跨代引用

所謂跨代引用就是老年代的對象引用了新生代的對象,或者新生代的對象引用了老年代的對象。那對於這種情況我們的GC在進行掃描的時候不可能直接把我們的整個堆都掃描完,那這樣效率也太低了。所以這時候就需要開闢了一小塊空間,維護這種引用,而不必讓GC掃描整個堆區域。

記憶集

記憶集也叫rememberSet,垃圾收集器在新生代中建立了記憶集這樣的數據結構,用來避免把整個老年代加入到GC ROOTS的掃描範圍中。對於記憶集來說,我們可以理解爲他是一個抽象類,那麼具體實現它的方法將由子類去完成。這裏我們簡單列舉一下實現記憶集的三種方式:
1.字長精度
2.對象精度
3.卡精度(卡表)

卡表

卡表(Card Table)是一種對記憶集的具體實現。主要定義了記憶集的記錄精度、與堆內存的映射關係等。卡表中的每一個元素都對應着一塊特定大小的內存塊,這個內存塊我們稱之爲卡頁(card page),當存在跨帶引用的時候,它會將卡頁標記爲dirty。那麼JVM對於卡頁的維護也是通過寫屏障的方式,這也就是爲什麼剛剛我們跟進寫屏障操作到最後會發現它會對卡表進行一系列的操作。

參考:

JVM垃圾回收器之CMS三色標記、增量更新與跨代引用

三色標記法與讀寫屏障

CMS與三色標記算法

面試官不講武德,竟然問了我18個JVM問題!

爲什麼JVM的CMS垃圾回收過程中需要寫屏障?

併發的可達性分析

JVM-G1算法和數據結構那些事

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