垃圾回收演算法:引用計數法

goodspeed發表於2018-08-12

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

上一篇為《GC 標記-清除演算法》

引用計數演算法

給物件中新增一個引用計數器,每當有一個地方引用它時,計數器的值就加1;當引用失效時,計數器值就減1;任何時刻計數器為0的物件就是不可能再被使用的。這也就是需要回收的物件。

引用計數演算法是物件記錄自己被多少程式引用,引用計數為零的物件將被清除。

計數器表示的是有多少程式引用了這個物件(被引用數)。計數器是無符號整數。

計數器的增減

引用計數法沒有明確啟動 GC 的語句,它與程式的執行密切相關,在程式的處理過程中通過增減計數器的值來進行記憶體管理。

new_obj() 函式

GC標記-清除演算法相同,程式在生成新物件的時候會呼叫 new_obj()函式。

func new_obj(size){
    obj = pickup_chunk(size, $free_list)
    
    if(obj == NULL)
        allocation_fail()
    else
        obj.ref_cnt = 1  // 新物件第一隻被分配是引用數為1
        return obj
}
複製程式碼

這裡 pickup_chunk()函式的用法與GC標記-清除演算法中的用法大致相同。不同的是這裡返回 NULL 時,分配就失敗了。這裡 ref_cnt 域代表的是 obj 的計數器。

在引用計數演算法中,除了連線到空閒連結串列的物件,其他物件都是活躍物件。所以如果 pickup_chunk()返回 NULL,堆中也就沒有其它大小合適的塊了。

update_ptr() 函式

update_ptr() 函式用於更新指標 ptr,使其指向物件 obj,同時進行計數器值的增減。

func update_ptr(ptr, obj){
    inc_ref_cnt(obj)     // obj 引用計數+1
    dec_ref_cnt(*ptr)    // ptr之前指向的物件(*ptr)的引用計數-1
    *ptr = obj
}
複製程式碼

這裡 update_ptr 為什麼需要先呼叫 inc_ref_cnt,再呼叫dec_ref_cnt呢?

是因為有可能 *ptr和 obj 可能是同一個物件,如果先呼叫dec_ref_cnt可能會誤傷。

**inc_ref_cnt()**函式

這裡inc_ref_cnt函式只對物件 obj 引用計數+1

func inc_ref_cnt(obj){
    obj.ref_cnt++
}
複製程式碼

dec_ref_cnt() 函式

這裡 dec_ref_cnt 函式會把之前引用的物件進行-1 操作,如果這時物件的計數器變為0,說明這個物件是一個垃圾物件,需要銷燬,那麼被它引用的物件的計數器值都需要相應的-1。

func dec_ref_cnt(obj){
    obj_ref_cnt--
    if(obj.ref_cnt == 0)
        for(child : children(obj))
            dec_ref_cnt(*child)  // 遞迴將被需要銷燬物件引用的物件計數-1
    reclaim(obj)
}
複製程式碼

update_prt() 函式執行是的情況

上圖這裡開始時,A 指向 B,第二步 A 指向了 C。可以看到通過更新,B 的計數器值變為了0,因此 B 被回收(連線到空閒連結串列),C 的計數器值由1變成了2。

通過上邊的介紹,應該可以看出引用計數垃圾回收的特點。

  1. 在變更陣列元素的時候會進行指標更新
  2. 通過更新執行計數可能會產生沒有被任何程式引用的垃圾物件
  3. 引用計數演算法會時刻監控更新指標是否會產生垃圾物件,一旦生成會立刻被回收。

所以如果呼叫 pickup_chunk函式返回 NULL,說明堆中所有物件都是活躍物件。

引用計數演算法的優點

  1. 可立即回收垃圾

    每個物件都知道自己的引用計數,當變為0時可以立即回收,將自己接到空閒連結串列

  2. 最大暫停時間短

    因為只要程式更新指標時程式就會執行垃圾回收,也就是每次通過執行程式生成垃圾時,這些垃圾都會被回收,記憶體管理的開銷分佈於整個應用程式執行期間,無需掛起應用程式的執行來做,因此消減了最大暫停時間(但是增多了垃圾回收的次數)

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

  3. 不需要沿指標查詢

    產生的垃圾立即就連線到了空閒連結串列,所以不需要查詢哪些物件是需要回收的

引用計數演算法的缺點

  1. 計數器值的增減處理頻繁

    因為每次物件更新都需要對計數器進行增減,特別是被引用次數多的物件。

  2. 計數器需要佔用很多位

    計數器的值最大必須要能數完堆中所有物件的引用數。比如我們用的機器是32位,那麼極端情況,可能需要讓2的32次方個物件同時引用一個物件。這就必須要確保各物件的計數器有32位大小。也就是對於所有物件,必須保留32位的空間。

    假如物件只有兩個域,那麼其計數器就佔用了整體的1/3。

  3. 迴圈引用無法回收

    這個比較好理解,迴圈引用會讓計數器最小值為1,不會變為0。

迴圈引用

class Person{  // 定義 Person 類
    string name
    Person lover
}

lilw = new Person("李雷")    // 生成 person 類的例項 lilw
hjmmwmw = new Person("韓梅梅") // 生成 person 類的例項 hjmwmw

lilw.lover = hjmwmw   // lilw 引用 hjmwmw
hjmwmw.lover = lilw   // hjmwmw 引用 lilw

複製程式碼

像這樣,兩個物件相互引用,所以各個物件的計數器都為1,且這些物件沒有被其他物件引用。所以計數器最小值也為1,不可能為0。

延遲引用計數法

引用計數法雖然縮小了最大暫停時間,但是計數器的增減處理特別多。為了改善這個缺點,延遲引用計數法(Deferred Reference Counting)被研究了出來。

通過上邊的描述,可以知道之所以計數器增減處理特別繁重,是因為有些增減是根引用的變化,因此我們可以讓根引用的指標變化不反映在計數器上。比如我們把 update_ptr($ptr, obj)改寫成*$ptr = obj,這樣頻繁重寫對重物件中引用關係時,計數器也不需要修改。但是這有一個問題,那就是計數器並不能正確反映出物件被引用的次數,就有可能會出現,物件仍在活動,卻被回收。

延遲引用計數法中使用ZCT(Zero Count Table),來修正這一錯誤。

ZCT 是一個表,它會事先記錄下計數器在 dec_ref_cnt()函式作用下變成 0 的物件。

ZCT

dec_ref_cnt 函式

在延遲引用計數法中,引用計數為0 的物件並不一定是垃圾,會先存入到 zct 中保留。

func dec_ref_cnt(obj){
    obj_ref_cnt--
    if(obj.ref_cnt == 0) //引用計數為0 先存入到 $zct 中保留
        if(is_full($zct) == TRUE) // 如果 $zct 表已經滿了 先掃描 zct 表,清除真正的垃圾
            scan_zct()
        push($zct, obj)
}
複製程式碼

scan_zct 函式

func scan_zct(){
    for(r: $roots)
        (*r).ref_cnt++
    
    for(obj : $zct)
        if(obj.ref_cnt == 0)
            remove($zct, obj)
            delete(obj)
    
    for(r: $roots)
        (*).ref_cnt--
}
複製程式碼
  1. 第二行和第三行,程式先把所有根直接引用的計數器都進行增量。這樣,來修正計數器的值。
  2. 接下來檢查 $zct 表中的物件,如果此時計數器還為0,則說明沒有任何引用,那麼將物件先從 $zct中清除,然後呼叫 delete()回收。

delete() 函式定義如下:

func delete(obj){
    for(child : children(obj)) // 遞迴清理物件的子物件
        (*child).ref_cnt--
        if (*child).ref_cnt == 0 
            delete(*child)
    
    reclaim(obj)
}
複製程式碼

new_obj() 函式

除 dec_ref_cnt 函式需要調整,new_obj 函式也要做相應的修改。

func new_obj(size){
    obj = pickup_chunk(size, $free_list)
    
    if(obj == NULL) // 空間不足
        scan_zct()  // 掃描 zct 以便獲取空間
        obj = pickup_chunk(size, $free_list) // 再次嘗試分配
        if(obj == NULL)
            allocation_fail()  // 提示失敗
            
     obj.ref_cnt = 1
     return obj
}
複製程式碼

如果第一次分配空間不足,需要掃描 $zct,以便再次分配,如果這時空間還不足,就提示失敗

在延遲引用計數法中,程式延遲了根引用的計數,通過延遲,減輕了因根引用頻繁變化而導致的計數器增減所帶來的額外的負擔。

但是,延遲引用計數卻不能馬上將垃圾進行回收,可立即回收垃圾這一優點也就不存在了。scan_zct函式也會增加程式的最大暫停時間。

Sticky 引用計數法

對於引用計數法,有一個不能忽略的部分是計數器位寬的設定。假設為了反映所有引用,計數器需要1個字(32位機器就是32位)的空間。但是這會大量的消耗記憶體空間。比如,2個字的物件就需要一個字的計數器。也就是計數器會使物件所佔的空間增大1.5倍。

sticky 引用計數法就是用來減少位寬的。

如果我們為計數器的位數設為5,那麼計數器最大的引用數為31,如果有超過31個物件引用,就會爆表。對於爆表,我們怎麼處理呢?

1. 什麼都不做

這種處理方式對於計數器爆表的物件,再有新的引用也不在增加,當然,當計數器為0 的時候,也不能直接回收(因為可能還有物件在引用)。這樣其實是會產生殘留的物件佔用記憶體。

不過,研究表明,大部分物件其實只被引用了一次就被回收了,出現5位計數器溢位的情況少之又少。

爆表的物件大部分也都是重要的物件,不會輕易回收。

所以,什麼都不做也是一個不錯的辦法。

2. 使用GC 標記-清除演算法進行管理

這種方法是,對於爆表的物件,使用 GC 標記-清除演算法來管理。

func mark_sweep_for_counter_overflow(){
    reset_all_ref_cnt()
    mark_phase()
    sweep_phase()
}
複製程式碼

首先,把所有物件的計數器都設為0,然後進行標記和清除階段。

標記階段程式碼為:

func mark_phase(){
    for (r: $roots)  // 先把根引用的物件推到標記棧中
        push(*r, $mark_stack)
    
    while(is_empty($mark_stack) == False) // 如果堆不為空
        obj = pop($mark_stack)
        obj.ref_cnt++  
        if(obj.ref_cnt == 1) // 這裡必須把各個物件及其子物件堆進行標記一次
            for(child : children(obj))
                push(*child, $mark_stack)
}
複製程式碼

在標記階段,先把根引用的物件推到標記棧中

然後按順序從標記棧中取出物件,對計數器進行增量操作。

對於迴圈引用的物件來說,obj.ref_cnt > 1,為了避免無謂的 push 這裡需要進行 if(obj.ref_cnt == 1) 的判斷

清除階段程式碼為:

func sweep_phase(){
    sweeping = $heap_top
    while(sweeping < $heap_end)  // 因為迴圈引用的所有物件都會被 push 到 head_end 所以也能被回收
        if(sweeping.ref_cnt == 0)
            reclaim(sweeping)
        sweeping += sweeping.size
}
複製程式碼

在清除階段,程式會搜尋整個堆,回收計數器仍為0的物件。

這裡的 GC 標記-清除演算法和上一篇GC 標記-清除演算法 主要不同點如下:

  1. 開始時將所有物件的計數器值設為0
  2. 不標記物件,而是對計數器進行增量操作
  3. 為了對計數器進行增量操作,演算法對活動物件進行了不止一次的搜尋。

這裡將 GC 標記-清除演算法和引用計數法結合起來,在計數器溢位後,物件稱為垃圾也不會漏掉清除。並且也能回收迴圈引用的垃圾。

因為在查詢物件時不是設定標誌位而是把計數器進行增量,所以需要多次查詢活動物件,所以這裡的標記處理比以往的標記清除花的時間更長,吞吐量會相應的降低。

參考連結


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

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

垃圾回收演算法:引用計數法

相關文章