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

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

上一篇爲《GC 標記-清除算法》python

引用計數算法

給對象中添加一個引用計數器,每當有一個地方引用它時,計數器的值就加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可能會誤傷。spa

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 標記-清除算法和引用計數法結合起來,在計數器溢出後,對象稱爲垃圾也不會漏掉清除。而且也能回收循環引用的垃圾。

由於在查找對象時不是設置標誌位而是把計數器進行增量,因此須要屢次查找活動對象,因此這裏的標記處理比以往的標記清除花的時間更長,吞吐量會相應的下降。

參考連接


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

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

相關文章
相關標籤/搜索