垃圾回收演算法|GC標記-清除演算法

goodspeed發表於2019-03-03

本文是《垃圾回收的演算法與實現》讀書筆記

垃圾回收演算法|GC標記-清除演算法

什麼是GC標記-清除演算法(Mark Sweep GC)

GC 標記-清除演算法由標記階段清除階段構成。在標記階段會把所有的活動物件都做上標記,然後在清除階段會把沒有標記的物件,也就是非活動物件回收。

名詞解釋:

在 GC 的世界裡物件指的是通過應用程式利用的資料的集合。是 GC 的基本單位。一般由頭(header)和域(field)構成。

活動物件:能通過引用程式引用的物件就被稱為活動物件。(可以直接或間接從全域性變數空間中引出的物件)

非活動物件:不能通過程式引用的物件唄稱為非活動物件。(這就是被清除的目標)

標記-清除演算法的虛擬碼如下所示:

func mark_sweep(){
    mark_phase()   // 標記階段
    sweep_phase()  // 清除階段
} 
複製程式碼

標記階段

標記階段就是遍歷物件並標記的處理過程。

標記階段虛擬碼如下:

func mark_phase(){
    for (r : $roots)  // 在標記階段,會給所有的活動物件打上標記
        mark(*r)
}

func mark(){
    if (obj.mark == False)
        obj.mark = True            // 先標記找出的活動物件
        for (child: children(obj)) // 然後遞迴的標記通過指標陣列能訪問到的物件
            mark(*child)
}
複製程式碼

這裡 $root是指標物件的起點,通過$root 可以遍歷全部活動物件。

下圖是標記前和標記後記憶體中堆的狀態

執行 GC 前堆的狀態

執行 GC 後堆的狀態

清除階段

在清除階段,collector 會遍歷整個堆,回收沒有打上標記的物件(垃圾),使其能再次利用。

sweep_phase() 函式虛擬碼實現如下:

func sweep_phase(){
    sweeping = $heap_start            // 首先將堆的首地址賦值給 sweeping
    while(sweeping < $head_end){
        if(sweeping.mark == TRUE)
            // 如果是標記狀態就設為 FALSE,如果是活動物件,還會在標記階段被標記為 TRUE
            sweeping.mark == FALSE    
        else:
            sweeping.next = $free_list   // 將非活動物件 拼接到 $free_list 頭部位置
            $free_list = sweeping
        sweeping += sweeping.size
    }     
}
複製程式碼

size 域指的是儲存物件大小的域,在物件頭中事先定義。

next 域只在生成空閒連結串列以及從空閒連結串列中取出分塊時才會用到。

分塊(chunk) 這裡是指為利用物件而事先準備出來的空間。

記憶體中區塊的塊生路線為 分塊-->活動物件-->垃圾—>分塊-->...

在清除階段我們會把非活動回收再利用。回收物件就是把物件作為分塊,連線到被稱為空閒連結串列的單向連結串列。之後再分配空間時只需遍歷這個空閒連結串列就可以了找到分塊了。

下圖是清除階段結束後堆的狀態:

清除階段結束後堆的狀態

分配

回收垃圾的目的是為了能再次分配

當程式申請分塊時,怎樣才能把大小合適的分塊分配給程式呢?

分配虛擬碼如下:

func new_obj(size){  // size 是需要的分塊大小
    chunk = pickup_chunk(size, $free_list)  // 遍歷 $free_list 尋找大於等於 size 的分塊
    if(chunk != NULL)  
        return chunk
    else
        allocation_fail()   // 如果沒找到大小合適的分塊 提示分配失敗
}
複製程式碼

pickup_chunk()函式不止返回和 size 大小相同的分塊,也會返回大於 size 大小的分塊(這時會將其分割成 size 大小的分塊和去掉 size 後剩餘大小的分塊,並把剩餘部分還給空閒連結串列)。

分配策略有三種 First-fit,Best-fit,Worst-fit

First-fit:發現大於等於 size的分塊立刻返回

Best-fit:找到大小和 size 相等的分塊再返回

``Worst-fit`:找到最大的分塊,然後分割成 size 大小和剩餘大小(這種方法容易產生大量小的分塊

合併

根據分配策略的不同,分配過程中會出現大量小的分塊,如果分塊是連續的,我們就可以把小分塊合併成一個大的分塊,合併是在清除階段完成的,包含了合併策略的清除程式碼如下:

func sweep_phase(){
    sweeping = $heap_start            // 首先將堆的首地址賦值給 sweeping
    while(sweeping < $head_end){
        if(sweeping.mark == TRUE)
            // 如果是標記狀態就設為 FALSE,如果是活動物件,還會在標記階段被標記為 TRUE
            sweeping.mark == FALSE    
        else:
            if(sweeping == $free_list + $free_list.size)  // 堆的地址正好和空閒連結串列大小相同
                $free_list.size += sweeping.size
            else
                sweeping.next = $free_list   // 將非活動物件 拼接到 $free_list 頭部位置
                $free_list = sweeping
        sweeping += sweeping.size
    }     
}
複製程式碼

$heap_end = $heap_start + HEAP_SIZE

所以這裡sweeping == $free_list + $free_list.size可以理解為需要清除的堆的地址正好和空閒連結相鄰

優/缺 點

優點

  • 實現簡單
  • 保守式 GC 演算法相容

缺點

  • 碎片化嚴重(由上面描述的分配演算法可知,容易產生大量小的分塊
  • 分配速度慢(由於空閒區塊是用連結串列實現,分塊可能都不連續,每次分配都需要遍歷空閒連結串列,極端情況是需要遍歷整個連結串列的。
  • 寫時複製技術不相容

寫時複製(copy-on-write)是眾多 UNIX 作業系統用到的記憶體優化的方法。比如在 Linux 系統中使用 fork() 函式複製程式時,大部分記憶體空間都不會被複制,只是複製程式,只有在記憶體中內容被改變時才會複製記憶體資料。

但是如果使用標記清除演算法,這時記憶體會被設定標誌位,就會頻繁發生不應該發生的複製。

多個空閒連結串列

上面所說的標記清除演算法只用到了一個空閒連結串列對大小不一的分塊統一處理。但這樣做每次都需要遍歷一遍來尋找大小合適的分塊,非常浪費時間。

這裡我們使用多個空閒連結串列的方法來儲存非活動物件。比如:將兩個字的分塊組成一個空閒連結串列,三個字的分塊組成另一個空閒連結串列,等等。。

這時,如果需要分配三個字的分塊,那我們只需要查詢對應的三個字的空閒連結串列就可以了。

到底需要製造多少個空閒連結串列呢?

因為通常程式不會 申請特別大的分塊,所以我們通常給分塊大小設定一個上限,比如100,大於這個上限的組成一個特殊的空閒連結串列。這樣101 個空閒連結串列就夠了。

點陣圖標記

在單純的 GC 標記-清除演算法中,用於標記的位是被分配到物件頭中的。演算法是把物件和頭一併處理,但這和寫時複製不相容。

點陣圖標記法是隻收集各個物件的標誌位並表格化,不喝物件一起管理。在標記的時候不在物件的頭裡設定位置,而是在特定的表格中置位。

點陣圖標記

在點陣圖標記中重要的是,點陣圖表格中位的位置要和堆裡的各個物件切實對應。一般來說堆中的一個字會分配到一個位。

點陣圖標記中 mark() 函式的虛擬碼實現如下:

func mark(obj){
    obj_num = (obj - $heap_start) / WORD_LENGTH  // WORD_LENGTH 是一個常量,表示機器中一個字的位寬
    index = obj_num / WORD_LENGTH
    offset = obj_num % WORD_LENGTH
    
    if ($bitmap_tbl[index] & (1 << offset)) == 0
        $bitmap_tbl[index] |= (1 << offset)
        for (child: children(obj)) // 然後遞迴的標記通過指標陣列能訪問到的物件
            mark(*child)
}
複製程式碼

這裡 obj_num 指的是從點陣圖表格前面數,obj 的標誌位在第幾個。例如 E 的 obj_num 是8。

obj_num 除以 WORD_LENGTH 得到的商 index 以及餘數 offset 來分別表示點陣圖表格的行編號和列編號。

優點

  • 和寫時複製技術相容
  • 清除更高效(只需要遍歷點陣圖表格就可以,清除的時候也只需要清除表格中的標誌位)。

延遲清除

清除操作所花費的時間和堆的大小成正比,堆越大,標記-清除 動作花費的時間越長,也就越影響程式的執行。

延遲清除(lazy sweep)是縮短清除操作花費導致程式最大暫停時間的方法。

最大暫停時間,因執行 GC 而暫停執行程式的最長時間。

延遲清除中 new_obj() 函式會在分配的時候呼叫 lazy_sweep()函式,進行清除操作。如果它能用清除操作來分配分塊,就會返回分塊,如果不能分配分塊,就會執行標記操作。然後重複這個步驟,直到找到分塊或者allocation_fail

通過延遲清除法可以縮減程式的暫停時間,不過延遲效果並不是均衡的。比如下圖這種剛標記完堆的情況:

堆裡垃圾分佈不均的情況

這時,活動物件和非活動物件都是相鄰分佈,如果程式在活動物件周圍開始清除,那它找到的物件都是活動物件不可清除,只能不停遍歷,暫停時間就會變長。

參考連結


最後,感謝女朋友支援和包容,比❤️

也可以在公號輸入以下關鍵字獲取歷史文章:公號&小程式 | 設計模式 | 併發&協程

垃圾回收演算法|GC標記-清除演算法

相關文章