併發可達性分析:三色標記
原文:三色標記法與讀寫屏障 - 簡書 (jianshu.com)
《深入理解JVM》3.4.6
1. 概述
可達性演算法
引用計數法、可達性分析演算法。
引用計數法:實現簡單、效率高,難以解決迴圈引用問題
本文僅是垃圾收集演算法的標記過程
2. 三色標記法
2.1 基本演算法
要找出存活物件,根據可達性分析,從GC Roots開始進行遍歷訪問,可達的則為存活物件:
我們把遍歷物件圖過程中遇到的物件,按“是否訪問過”這個條件標記成以下三種顏色:
- 白色:尚未訪問過。
- 黑色:本物件已訪問過,而且本物件 引用到 的其他物件 也全部訪問過了。
- 灰色:本物件已訪問過,但是本物件 引用到 的其他物件 尚未全部訪問完。全部訪問後,會轉換為黑色。
假設現在有白、灰、黑三個集合(表示當前物件的顏色),其遍歷訪問過程為:
- 初始時,所有物件都在 【白色集合】中;
- 將GC Roots 直接引用到的物件 挪到 【灰色集合】中;
- 從灰色集合中獲取物件:
3.1. 將本物件 引用到的 其他物件 全部挪到 【灰色集合】中;
3.2. 將本物件 挪到 【黑色集合】裡面。 - 重複步驟3,直至【灰色集合】為空時結束。
- 結束後,仍在【白色集合】的物件即為GC Roots 不可達,可以進行回收。
注:如果標記結束後物件仍為白色,意味著已經“找不到”該物件在哪了,不可能會再被重新引用。當Stop The World (以下簡稱 STW)時,物件間的引用 是不會發生變化的,可以輕鬆完成標記。
而當需要支援併發標記時,即標記期間應用執行緒還在繼續跑,物件間的引用可能發生變化
2.3 浮動垃圾
假設已經遍歷到E(變為灰色了),此時應用執行了objD.fieldE = null
:
此刻之後,物件E/F/G是“應該”被回收的。然而因為E已經變為灰色了,其仍會被當作存活物件繼續遍歷下去。最終的結果是:這部分物件仍會被標記為存活,即本輪GC不會回收這部分記憶體。
這部分本應該回收 但是 沒有回收到的記憶體,被稱之為“浮動垃圾”。浮動垃圾並不會影響應用程式的正確性,只是需要等到下一輪垃圾回收中才被清除。
另外,針對併發標記開始後的新物件,通常的做法是直接全部當成黑色,本輪不會進行清除。這部分物件期間可能會變為垃圾,這也算是浮動垃圾的一部分。
2.4 漏標-讀寫屏障
假設GC執行緒已經遍歷到E(變為灰色了),此時應用執行緒先執行了var G = objE.fieldG; objE.fieldG = null; // 灰色E 斷開引用 白色G objD.fieldG = G; // 黑色D 引用 白色G
此時切回GC執行緒繼續跑,因為E已經沒有對G的引用了,所以不會將G放到灰色集合;儘管因為D重新引用了G,但因為D已經是黑色了,不會再重新做遍歷處理。
最終導致的結果是:G會一直停留在白色集合中,最後被當作垃圾進行清除。這直接影響到了應用程式的正確性,是不可接受的。
此時切回GC執行緒繼續跑,因為E已經沒有對G的引用了,所以不會將G放到灰色集合;儘管因為D重新引用了G,但因為D已經是黑色了,不會再重新做遍歷處理。
最終導致的結果是:G會一直停留在白色集合中,最後被當作垃圾進行清除。這直接影響到了應用程式的正確性,是不可接受的。
不難分析,漏標只有同時滿足以下兩個條件時才會發生:
條件一:灰色物件 斷開了 白色物件的引用(直接或間接的引用);即灰色物件 原來成員變數的引用 發生了變化。
條件二:黑色物件 重新引用了 該白色物件;即黑色物件 成員變數增加了 新的引用。
從程式碼的角度看:
var G = objE.fieldG; // 1.讀 objE.fieldG = null; // 2.寫 objD.fieldG = G; // 3.寫
- 讀取 物件E的成員變數fieldG的引用值,即物件G;
- 物件E 往其成員變數fieldG,寫入 null值。
- 物件D 往其成員變數fieldG,寫入 物件G ;
我們只要在上面這三步中的任意一步中做一些“手腳”,將物件G記錄起來,然後作為灰色物件再進行遍歷即可。比如放到一個特定的集合,等初始的GC Roots遍歷完(併發標記),該集合的物件 遍歷即可(重新標記)。
我們要解決併發掃描時的漏標問題,只需破壞這兩個條件的任意一個即可。由此分別
產生了兩種解決方案:增量更新(Incremental Update)和原始快照(Snapshot At The Beginning, SATB)。
增量更新要破壞的是第一個條件,當黑色物件插入新的指向白色物件的引用關係時,就將這個新插入的引用記錄下來,等併發掃描結束之後,再將這些記錄過的引用關係中的黑色物件為根,重新掃描一次。這可以簡化理解為,黑色物件一旦新插入了指向白色物件的引用之後,它就變回灰色物件了
原始快照要破壞的是第二個條件,當灰色物件要刪除指向白色物件的引用關係時,就將這個要刪除的引用記錄下來,在併發掃描結束之後,再將這些記錄過的引用關係中的灰色物件為根,重新掃描 一次。這也可以簡化理解為,無論引用關係刪除與否,都會按照剛剛開始掃描那一刻的物件圖快照來進行搜尋
重新標記通常是需要STW的,因為應用程式一直在跑的話,該集合可能會一直增加新的物件,導致永遠都跑不完。當然,併發標記期間也可以將該集合中的大部分先跑了,從而縮短重新標記STW的時間,這個是優化問題了。
2.3.1 寫屏障
注意這裡的讀寫屏障和CPU的/volatile 的讀寫屏障語義不同。給某個物件的成員變數賦值時,其底層程式碼大概長這樣:
/** * @param field 某物件的成員變數,如 D.fieldG * @param new_value 新值,如 null */ void oop_field_store(oop* field, oop new_value) { *field = new_value; // 賦值操作 }
所謂的寫屏障,其實就是指在賦值操作前後,加入一些處理(可以參考AOP的概念):
void oop_field_store(oop* field, oop new_value) { pre_write_barrier(field); // 寫屏障-寫前操作 *field = new_value; post_write_barrier(field, value); // 寫屏障-寫後操作 }
(1) 寫屏障 + SATB
當物件E的成員變數的引用發生變化時(objE.fieldG = null;
),我們可以利用寫屏障,將E原來成員變數的引用物件G記錄下來:
void pre_write_barrier(oop* field) { oop old_value = *field; // 獲取舊值 remark_set.add(old_value); // 記錄 原來的引用物件 }
【當原來成員變數的引用發生變化之前,記錄下原來的引用物件】
這種做法的思路是:嘗試保留開始時的物件圖,即原始快照(Snapshot At The Beginning,SATB),當某個時刻 的GC Roots確定後,當時的物件圖就已經確定了。
比如 當時 D是引用著G的,那後續的標記也應該是按照這個時刻的物件圖走(D引用著G)。如果期間發生變化,則可以記錄起來,保證標記依然按照原本的檢視來。
值得一提的是,掃描所有GC Roots 這個操作(即初始標記)通常是需要STW的,否則有可能永遠都掃不完,因為併發期間可能增加新的GC Roots。
SATB破壞了條件一:【灰色物件 斷開了 白色物件的引用】,從而保證了不會漏標。一點小優化:如果不是處於垃圾回收的併發標記階段,或者已經被標記過了,其實是沒必要再記錄了,所以可以加個簡單的判斷:
void pre_write_barrier(oop* field) { // 處於GC併發標記階段 且 該物件沒有被標記(訪問)過 if($gc_phase == GC_CONCURRENT_MARK && !isMarkd(field)) { oop old_value = *field; // 獲取舊值 remark_set.add(old_value); // 記錄 原來的引用物件 } }
(2) 寫屏障 + 增量更新
當物件D的成員變數的引用發生變化時(objD.fieldG = G;
),我們可以利用寫屏障,將D新的成員變數引用物件G記錄下來:
void post_write_barrier(oop* field, oop new_value) { if($gc_phase == GC_CONCURRENT_MARK && !isMarkd(field)) { remark_set.add(new_value); // 記錄新引用的物件 } }
【當有新引用插入進來時,記錄下新的引用物件】
這種做法的思路是:不要求保留原始快照,而是針對新增的引用,將其記錄下來等待遍歷,即增量更新(Incremental Update)。
增量更新破壞了條件二:【黑色物件 重新引用了 該白色物件】,從而保證了不會漏標。
2.3.2 讀屏障(Load Barrier)
oop oop_field_load(oop* field) { pre_load_barrier(field); // 讀屏障-讀取前操作 return *field; }
讀屏障是直接針對第一步:var G = objE.fieldG;
,當讀取成員變數時,一律記錄下來:
void pre_load_barrier(oop* field, oop old_value) { if($gc_phase == GC_CONCURRENT_MARK && !isMarkd(field)) { oop old_value = *field; remark_set.add(old_value); // 記錄讀取到的物件 } }
這種做法是保守的,但也是安全的。因為條件二中【黑色物件 重新引用了 該白色物件】,重新引用的前提是:得獲取到該白色物件,此時已經讀屏障就發揮作用了。
2.4 三色標記法與現代垃圾回收器
現代追蹤式(可達性分析)的垃圾回收器幾乎都借鑑了三色標記的演算法思想,儘管實現的方式不盡相同:比如白色/黑色集合一般都不會出現(但是有其他體現顏色的地方)、灰色集合可以通過棧/佇列/快取日誌等方式進行實現、遍歷方式可以是廣度/深度遍歷等等。
對於讀寫屏障,以Java HotSpot VM為例,其併發標記時對漏標的處理方案如下:
- CMS:寫屏障 + 增量更新
- G1:寫屏障 + SATB
- ZGC:讀屏障
工程實現中,讀寫屏障還有其他功能,比如寫屏障可以用於記錄跨代/區引用的變化,讀屏障可以用於支援移動物件的併發執行等。功能之外,還有效能的考慮,所以對於選擇哪種,每款垃圾回收器都有自己的想法。
值得注意的是,CMS中使用的增量更新,在重新標記階段,除了需要遍歷 寫屏障的記錄,還需要重新掃描遍歷GC Roots(當然標記過的無需再遍歷了),這是由於CMS對於astore_x等指令不新增寫屏障的原因,具體可參考這裡。
《深入理解JVM》3.4.6:CMS是基於增量更新來做併發標記的,G1、Shenandoah則是用原始快照來實現
參考資料
- 維基百科:Tracing_garbage_collection#Tri-color_marking
- R大在談G1時提到的SATB,本文程式碼參考出處
- 《深入理解Java虛擬機器》第三版
- 《新一代垃圾回收器ZGC設計與實現》