垃圾收集底層算法--三色標記詳解
一、並發標記的問題
CMS垃圾收集算法使用了三色標記,我們以CMS垃圾收集為例來說明。CMS垃圾收集的流程如下:

一共有5步:初始標記、並發標記、重新標記、並發清除(包括:並發清理、線程重置)。其中初始標記和重新標記都會Stop The World。在並發標記的過程中,因為標記期間應用線程還在繼續跑,對象間的引用可能發生變化,多標和漏標的情況就有可能發生。
二、 什么情況會多標--浮動垃圾?
什么情況下回多標呢?來分析多標的情況。如下圖:
在並發標記的過程中,從棧出發,找到所有的GC Root, 於是我們找到了math對象,此時,math對象在堆中的開辟了一塊空間,堆中這塊空間也都是游泳池的,也就是說他們都不是垃圾。然而就在並發標記的過程中,應用線程也在繼續執行,這時候可能math這個對象已經沒有引用關系了,那么math就變成垃圾了,但是堆中的空間卻沒有標記為垃圾,所以收集的時候就不會被收集走。這就是多標的情況。
多標產生的后果是什么呢?就是產生浮動垃圾。
當有多標的時候,該如何解決呢?其實可以不用特殊解決,等待下一次垃圾會,重新進行標記,這塊空間就會被回收了。
浮動垃圾:在並發標記過程中,會出現由於方法運行結束,導致一部分局部變量(GC Root)被銷毀,而這個GC Root引用的對象之前被垃圾收集器掃描過 ,並且被標記為非垃圾對象,那么本輪GC不會回收這部分內存。這部分本應該回收但是沒有回收到的內存,被稱之為“浮動 垃圾”。
浮動垃圾並不會影響垃圾回收的正確性,只是需要等到下一輪垃圾回收中才被清除。 另外,針對並發標記(還有並發清理)開始后產生的新對象,通常的做法是直接全部當成黑色,本輪不會進行清除。這部分 對象期間可能也會變為垃圾,這也算是浮動垃圾的一部分。
三、什么情況會少標漏標呢 -- 三色標記?
為了處理多標和漏標的情況,我們引入了“三色標記”,在通過可達性分析遍歷對象標記GC Root的過程中所遇到的對象,分為三類。這三類對象分別被標記為不同的顏色,即:“黑色”、“灰色”,“白色”。他們分別代表什么含義呢?
-
黑色: 表示對象已經被垃圾收集器訪問過, 且這個對象的所有引用都已經掃描過。 黑色的對象代表已經掃描 過, 它是安全存活的對象, 如果有其他對象引用指向了黑色對象, 無須重新掃描一遍。 黑色對象不可能直接(不經過 灰色對象) 指向某個白色對象。
-
灰色: 表示對象已經被垃圾收集器訪問過, 但這個對象上至少存在一個引用還沒有被掃描過。
-
白色: 表示對象尚未被垃圾收集器訪問過。 顯然在可達性分析剛剛開始的階段, 所有的對象都是白色的, 若 在分析結束的階段, 仍然是白色的對象, 即代表不可達。
下面通過案例來分析對象的顏色。
public class ThreeColorRemark {
public static void main(String[] args) {
A a = new A();
// 開始做並發標記
D dd = a.b.d;
a.b.d = null;
a.d = dd;
}
}
class A {
B b = new B();
D d = null;
}
class B {
C c = new C();
D d = new D();
}
class C {
}
class D {
}
這里面有四個對象,A, B, C, D。在main方法中,首先new了一個A對象。此時的a對象是一個GC Root,在初始標記的時候會被標記為GC Root。假設,當進入並發階段的時候,剛剛執行完了A a = new A();這句話時,A應該是什么顏色的呢?
分析上面的代碼, 我們要定格時間。
假設:時間定格在執行了A a = new A(); B b = new B(); D d = null; C c = new C(); 但是還沒有執行D d = new D();的階段。
我們這里假設一個極端情況。這句話,A對象中的兩個成員變量b和d,首先執行b,指向了堆中new B()的地址。而d沒有指向任何對象引用,所以,不需要實例化。這樣a對象中兩個成員變量,全部都遍歷完了,所以a對象會被標記為黑色。黑色的含義是垃圾收集器掃描了這個對象和這個對象里的所有的引用對象,很顯然此時a對象不是垃圾。不會被回收。
當執行b對象指向new B()對象的時候,B對象中有兩個引用對象,分別是c和d。假設現在程序剛好執行到C c = new C();這句代碼,那么此時c對象指向了堆中new C()的引用。就在這一刻,也就是執行了C c = new C();而沒有執行D d = new D();這句代碼的時候,B是灰色的。灰色表示已經被垃圾收集器掃描過,但是里面的引用沒有被全部掃描完,這時這個對象就應該成為下一個掃描的目標,也是不能被回收的。而C是黑色的,因為C里面沒有對象,被全部掃描完了。
同樣是剛剛那個時刻(執行了C c = new C();而沒有執行D d = new D();這句代碼的時候),此時因為還沒有執行D d = new D(); 這句話,所以D是白色的,表示還沒有被掃描到。最開始所有對象都是白色的,也就是A,B,C,D都是白色的,當垃圾收集器掃描完所有的對象以后,有些對象還是白色的,就說明垃圾收集器掃描不到它,那么這就是垃圾,會被回收。
來看看此次時間定格時各個對象的狀態。

需要注意的是:上面是定格在gc過程中的某一個時刻。整個GC並沒有結束,所以,b是灰色,d是白色只是在那定格的一瞬間。
總結:黑色表示GC已經分析完了,灰色對象表示還沒有分析完,白色對象表示沒有對其進行分析過。當所有的GC都完成了,還是有對象是白色的,那么這些對象就是不能被觸達的對象,就是我們要回收的目標對象。
就在這個時候,又執行了另外一句代碼a.d=a.b.d; a.b.d=null; 也就是這時候a對象增加了對d的引用。而對象b對d的引用斷開了。如下圖:

這時候會發生什么呢?垃圾收集器在掃描的時候,黑色對象a是不會被再次掃描的,再次掃描的目標對象是灰色對象b。這時候,b已經不再引用d了,所以b此時所有對象都已經掃描過,也會變成黑色。而d呢?這時候d其實還被a引用着,但是,垃圾收集器不會去掃描黑色對象了,所以,也不會知道d還被a引用着。這時候,d就還是白色對象,一直是白色對象,不會被垃圾收集器掃描到。這樣,d會被當做垃圾清理掉。d其實不是垃圾對象啊,被清理掉還能行?這就是誤刪除。jvm早期版本會有這樣的情況發生,現在基本不會出現了。
其實這種漏標的問題也可以通過代碼解決:
// 開始做並發標記
D dd = a.b.d;
a.b.d = null;
a.d = dd;
首先,我們先把a.b.d拿出來賦值給一個新的對象,然后再去掉a.b對d的引用關系,並設置a.d=d.這樣d就不會被當做一個垃圾對象回收掉了,因為有一個根對象引用了對象d。

上面是從代碼層面解決的,有沒有辦法從jvm底層解決這種漏標的問題呢?
四、從jvm底層解決漏標問題
漏標會導致被引用的對象被當成垃圾給清理掉,這會產生嚴重的bug,對於這種漏標的問題,jvm底層利用了CPU的讀寫屏障來實現的解決方案主要有兩種:
- 一種是增量更新(Incremental Update) ;
- 另一種是原始快照(Snapshot At The Beginning,SATB) 。
4.1 增量更新
從名字來看,增量更新, 是對新增引用進行處理。下面來看看定義:
當黑色對象插入新的指向白色對象的引用關系時, 就將這個新插入的引用記錄下來, 等並發掃描結束之 后, 再將這些記錄過的引用關系中的黑色對象為根, 重新掃描一次。 這可以簡化理解為, 黑色對象一旦新插入了指向白色對象的引用之后, 它就變回灰色對象了。
也就是說,在黑色對象新增了一個指向白色對象的引用時,會將這個引用記錄下來,會有一個集合,專門用來放黑色對象新增的對白色對象的引用,在並發標記的時候並不處理,等並發標記技術以后,進入重新標記階段,重新標記過程會Stop The World,在處理這個集合,將集合中引用關系中的黑色對象為根,進行重新掃描一次,這次掃描,白色對象就會變成黑色對象或者灰色對象,不會被垃圾回收掉了。
4.2 原始快照
原始快照,不是對新增對象的處理,而是對原始對象的處理,下面來看看定義:
就是當灰色對象要刪除指向白色對象的引用關系時, 就將這個要刪除的引用記錄下來, 在並發掃描結束之后, 再將這些記錄過的引用關系中的灰色對象為根, 重新掃描一次,這樣就能掃描到白色的對象,將白色對象直接標記為黑 色(目的就是讓這種對象在本輪gc清理中能存活下來,待下一輪gc的時候重新掃描,這個對象也有可能是浮動垃圾) ,無論是對引用關系記錄的插入還是刪除, 虛擬機的記錄操作都是通過寫屏障實現的。
來看這張圖說明:

當掃描到對象b對d的引用刪除的之前, 會將這個要被刪掉的引用保存一個快照,然后放到集合里。上圖b到d的引用時如何被清掉的呢?做了一個賦值操作:
a.b.d = null;
也就是,當執行到這句賦值操作的時候,會先暫停賦值,執行另一個操作--寫屏障操作,將這個即將要刪除的引用提取出來,保存到一個集合里,然后在執行賦值操作。然后再下一次重新標記的時候,將集合中這些引用關系中的灰色對象作為根,進行重新掃描,這樣就可以掃描到白色對象了,將這些白色對象全部標記為黑色對象。標記為黑色對象的目的就是在本輪垃圾回收的時候存活下來,等待下一輪gc的時候重新掃描,這個對象有可能是浮動垃圾。
4.3 寫屏障
無論是增量更新還是原始快照,都是通過寫屏障來實現的。
增量更新和原始快照都是對引用的操作,一個是新增引用,一個是刪除引用,不管是新增還是刪除,最終都要把他們收集到集合里去。那么如何收集呢?其實就是在賦值操作之前或者賦值操作之后,把引用丟到集合中去。 在賦值操作的前面或者后面做一些事情,這個過程我們把它叫做代碼的操作屏障。
下面來看看賦值屏障的偽代碼,以給某個對象的成員變量賦值為例,底層代碼大概是這樣的::
/**
* @param field 某對象的成員變量,如 a.b.d
* @param new_value 新值,如 null
*/
voidoop_field_store(oop*field,oopnew_value){
*field = new_value; // 賦值操作
}
所謂的寫屏障,其實就是指在賦值操作前后,加入一些處理(可以參考AOP的概念):
voidoop_field_store(oop*field,oopnew_value){
pre_write_barrier(field); // 寫屏障‐寫前操作
*field = new_value;
post_write_barrier(field, value); // 寫屏障‐寫后操作
}
- 寫屏障實現原始快照
原始快照是記錄對引用的刪除。比如在執行a.b.d=null的時候,利用寫屏障,將原來B成員變量的引用 對象D記錄下來:
// 寫屏障代碼
void pre_write_barrier(oop*field){
oop old_value = *field; // 獲取舊值
remark_set.add(old_value); // 記錄原來的引用對象
}
- 寫屏障實現增量更新
當對象A的成員變量的引用發生變化時,比如新增引用(a.d = d),我們可以利用寫屏障,將A新的成員變量引用對象D 記錄下來:
void post_write_barrier(oop*field,oopnew_value){
remark_set.add(new_value); // 記錄新引用的對象
}
這兩塊都是屏障代碼,一個是在寫前執行,一個是在寫后執行。 刪除操作要在寫前執行, 賦值操作要在寫后執行。
下面來看看hotspot源碼是如何實現寫屏障的,找到oop.inline.hpp文件
/**
* c++底層調用的賦值方法
*/
template <class T> inline void oop_store(volatile T* p, oop v) {
update_barrier_set_pre((T*)p, v); // cast away volatile
// Used by release_obj_field_put, so use release_store_ptr.
oopDesc::release_encode_store_heap_oop(p, v);
update_barrier_set((void*)p, v); // cast away type
}
這就是一個賦值操作。update_barrier_set_pre((T)p, v);是一個寫前屏障,update_barrier_set((void)p, v);是一個寫后屏障。也就是說在賦值之前和之后增加了一段操作代碼。其實可以看出來這段代碼和我們的偽代碼差不多。名字雖不同,但是含義是一樣的。
再看看SATB在hotspot源碼中是如何實現寫屏障的。
void G1SATBCardTableModRefBS::enqueue(oop pre_val) {
// Nulls should have been already filtered.
assert(pre_val->is_oop(true), "Error");
if (!JavaThread::satb_mark_queue_set().is_active()) return;
Thread* thr = Thread::current();
if (thr->is_Java_thread()) {
JavaThread* jt = (JavaThread*)thr;
jt->satb_mark_queue().enqueue(pre_val);
} else {
MutexLockerEx x(Shared_SATB_Q_lock, Mutex::_no_safepoint_check_flag);
JavaThread::satb_mark_queue_set().shared_satb_queue()->enqueue(pre_val);
}
}
我們看到這句話satb_mark_queue_set().shared_satb_queue()->enqueue(pre_val); 將舊值放到隊列里。這時為什么會放到隊列里面呢?為了提高效率。因為是寫操作,在寫操作之前和之后增加邏輯,是會影響原來代碼的效率的,為了避免對源代碼的影響,放入到隊列中進行處理。
4.4 讀屏障
oopoop_field_load(oop*field){
pre_load_barrier(field); // 讀屏障‐讀取前操作
return *field;
}
讀屏障是直接針對第一步:D d = a.b.d,當讀取成員變量時,一律記錄下來:
voidpre_load_barrier(oop*field){
oop old_value = *field;
remark_set.add(old_value); // 記錄讀取到的對象
}
現代追蹤式(可達性分析)的垃圾回收器幾乎都借鑒了三色標記的算法思想,盡管實現的方式不盡相同:比如白色/黑色 集合一般都不會出現(但是有其他體現顏色的地方)、灰色集合可以通過棧/隊列/緩存日志等方式進行實現、遍歷方式可 以是廣度/深度遍歷等等。
4.5 為什么G1使用SATB,而CMS使用增量更新?
SATB相對增量更新效率會高(當然SATB可能造成更多的浮動垃圾),因為不需要在重新標記階段再次深度掃描 被刪除引用對象,而CMS對增量引用的根對象會做深度掃描,G1因為很多對象都位於不同的region,CMS就一塊老年代 區域,重新深度掃描對象的話G1的代價會比CMS高,所以G1選擇SATB不深度掃描對象,只是簡單標記,等到下一輪GC 再深度掃描。
五、各種垃圾收集器對漏標的處理方案
對於讀寫屏障,以Java HotSpot VM為例,其並發標記時對漏標的處理方案如下:
- CMS:采用的是寫屏障 + 增量更新
- G1: 采用的是寫屏障 + 原汁快照(SATB)
- ZGC:采用的是讀屏障
工程實現中,讀寫屏障還有其他功能,比如寫屏障可以用於記錄跨代/區引用的變化,讀屏障可以用於支持移動對象的並 發執行等。功能之外,還有性能的考慮,所以對於選擇哪種,每款垃圾回收器都有自己的想法。
六、記憶集和卡表
1.記憶集(Remember Set)
在新生代觸發Minor GC進行GC Root可達性掃描的時候,可能會碰到跨代引用。比如:新生代的一個對象被老年代引用了,這個時候,在垃圾回收的時候,我們不應該把這塊空間回收掉。那怎么辦呢?要去掃描一遍老年代么?這顯然不行,效率太低了。為了解決這個問題,GC在掃描的時候,會把老年代引用的對象放在一個叫做記憶集的集合中。
這樣在垃圾回收的時候,除了會掃描GC Root下的對象,還會掃描一遍記憶集中的引用。記憶集是存儲在新生代的空間,保存着老年代對新生代內存的引用關系。記憶集就是為了解決對象的跨代引用問題。
垃圾收集過程中, 收集器只需要通過記憶集來判斷某一塊非收集區域是否存在指向收集區域的指針即可,無需了解跨代引用指針的全部細節。
2.卡表(Card Table)
hotspot使用的是卡表(cardtable)來實現記憶集。卡表其實就是記憶集的一個實現,卡表和記憶集的關系就像HashMap和Map的關系。記憶集相當於一個概念,而jdk中是通過卡表來實現的。到底是如何實現的呢?

卡表是使用字節數組實現的,卡表的每一個元素對應着其標志的內存區域里一塊待定大小的內存塊。這些待定的內存塊就是“卡頁”。堆空間分為新生代和老年代,卡表會把老年代划分為一塊一塊小的格子,這些小格子就是“卡頁”。卡頁划分是按照512字節大小進行划分的。如果有一個卡頁引用了新生代的對象,那么就將這個卡頁就會被標記為“dirty”。卡表是一個數組,里面記錄了所有卡頁的狀態,用010101來標記卡頁是否引用了新生代對象。如果是就標記為1,不是就保持原來的0. 數組里除了存放卡頁的狀態,還有卡頁的地址。在垃圾收集器進行掃描的時候,除了掃描GC Root之外,還會掃描卡表里那些狀態為1的卡頁里的對象。 卡頁是在老年代,維護卡頁的卡表是在年輕代。