12.垃圾收集底層演算法--三色標記詳解

盛開的太陽發表於2021-11-04

垃圾收集底層演算法--三色標記詳解

一、併發標記的問題

CMS垃圾收集演算法使用了三色標記,我們以CMS垃圾收集為例來說明。CMS垃圾收集的流程如下:

12.垃圾收集底層演算法--三色標記詳解

一共有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都是白色的,當垃圾收集器掃描完所有的物件以後,有些物件還是白色的,就說明垃圾收集器掃描不到它,那麼這就是垃圾,會被回收。

來看看此次時間定格時各個物件的狀態。

12.垃圾收集底層演算法--三色標記詳解

需要注意的是:上面是定格在gc過程中的某一個時刻。整個GC並沒有結束,所以,b是灰色,d是白色只是在那定格的一瞬間。

總結:黑色表示GC已經分析完了,灰色物件表示還沒有分析完,白色物件表示沒有對其進行分析過。當所有的GC都完成了,還是有物件是白色的,那麼這些物件就是不能被觸達的物件,就是我們要回收的目標物件。

就在這個時候,又執行了另外一句程式碼a.d=a.b.d; a.b.d=null; 也就是這時候a物件增加了對d的引用。而物件b對d的引用斷開了。如下圖:

12.垃圾收集底層演算法--三色標記詳解

這時候會發生什麼呢?垃圾收集器在掃描的時候,黑色物件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。

12.垃圾收集底層演算法--三色標記詳解

上面是從程式碼層面解決的,有沒有辦法從jvm底層解決這種漏標的問題呢?

四、從jvm底層解決漏標問題

漏標會導致被引用的物件被當成垃圾給清理掉,這會產生嚴重的bug,對於這種漏標的問題,jvm底層利用了CPU的讀寫屏障來實現的解決方案主要有兩種:

  • 一種是增量更新(Incremental Update) ;
  • 另一種是原始快照(Snapshot At The Beginning,SATB) 。

4.1 增量更新

從名字來看,增量更新, 是對新增引用進行處理。下面來看看定義:

當黑色物件插入新的指向白色物件的引用關係時, 就將這個新插入的引用記錄下來, 等併發掃描結束之 後, 再將這些記錄過的引用關係中的黑色物件為根, 重新掃描一次。 這可以簡化理解為, 黑色物件一旦新插入了指向白色物件的引用之後, 它就變回灰色物件了。

也就是說,在黑色物件新增了一個指向白色物件的引用時,會將這個引用記錄下來,會有一個集合,專門用來放黑色物件新增的對白色物件的引用,在併發標記的時候並不處理,等併發標記技術以後,進入重新標記階段,重新標記過程會Stop The World,在處理這個集合,將集合中引用關係中的黑色物件為根,進行重新掃描一次,這次掃描,白色物件就會變成黑色物件或者灰色物件,不會被垃圾回收掉了。

4.2 原始快照

原始快照,不是對新增物件的處理,而是對原始物件的處理,下面來看看定義:

就是當灰色物件要刪除指向白色物件的引用關係時, 就將這個要刪除的引用記錄下來, 在併發掃描結束之後, 再將這些記錄過的引用關係中的灰色物件為根, 重新掃描一次,這樣就能掃描到白色的物件,將白色物件直接標記為黑 色(目的就是讓這種物件在本輪gc清理中能存活下來,待下一輪gc的時候重新掃描,這個物件也有可能是浮動垃圾) ,無論是對引用關係記錄的插入還是刪除, 虛擬機器的記錄操作都是通過寫屏障實現的。

來看這張圖說明:

12.垃圾收集底層演算法--三色標記詳解

當掃描到物件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); // 記錄讀取到的物件
}

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

五、各種垃圾收集器對漏標的處理方案

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

  • CMS:採用的是寫屏障 + 增量更新
  • G1: 採用的是寫屏障 + 原汁快照(SATB)
  • ZGC:採用的是讀屏障

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

相關文章