Reference Counting GC (Part one)

引用計數法

George E.Collins.1960.數組

引用計數算法中引入了一個概念計數器。計數器表明對象被引用的次數,它是無符號正整數用於計數器的位數根據算法和實現有所不一樣。本文結構以下圖所示。緩存

在變動數組元素等的時候會進行指針的更新。經過更新指針,可能會產生沒有被任何程序引用的垃圾對象。引用計數法中會監督在更新指針的時候是否有產生垃圾,從而在產生 垃圾時將其馬上回收。這樣,能夠說將內存管理和 mutator 同時運行正是引用計數法的一大特徵。分佈式

計數器值的增減

引用計數算法中,mutator沒有明確啓動GC的語句。它在mutator的處理過程當中經過增減計數器的值來進行內存管理,這兩種狀況下計數器的值會發生改變,這使用到了new_obj()函數和update_ptr()函數。函數

new_obj()和update_ptr()函數

new_obj()生成對象

new_obj(size){
    obj = pickup_chunk(size, $free_list)
    
    if(obj == NULL)
        allocation_fail()
    else
        obj.ref_cnt = 1
        return obj
}

這是生成新的對象,使用pickup_chunk()函數。當函數返回NULL是表明分配失敗。在引用計數法中,除了鏈接到空閒鏈表的對象,其餘全部對象都是活動對象。也就是說,一旦返回NULL就表明沒有任何多餘的空間了。也就沒法進行分配。指針

當pickup_chunk()函數返回合適大小的對象時,講他的計數器ref_cnt置爲1,表明其被引用了1次。code

update_ptr()更新指針ptr,對計數器進行增減

update_ptr(ptr, obj){
    inc_ref_cnt(obj)  //計數器+
    dec_ref_cnt(*ptr) //計數器-
    *ptr = obj  // 從新指向 obj
}

在mutator更新指針時會執行此程序。其、*ptr = obj是指針更新的部分。orm

增長引用和刪除引用

inc_ref_cnt(obj)對指針ptr新引用對象obj計數器進行增量操做。對象

inc_ref_cnt(obj){
    obj.ref_cnt++
}

僅僅是對對象計數器進行自增操做。blog

dec_ref_cnt(*ptr)對以前ptr引用的對象進行計數器減量操做。

dec_ref_cnt(*ptr){
    obj.ref_cnt--
    if (obj.ref_cnt == 0)
        for(child : children(obj)) // 當本身被清除時,本身所引用的孩子的計數器必須減一。進行遞歸操做。
            dec_ref_cnt(*child)
        reclaim(obj)
}
  • 首先對更新指針以前引用的對象*ptr的計數器進行減量操做。減量操做後,計數器的值爲0的對象變成了「垃圾」。所以,這個對象的指針會所有被刪除。
  • 而後遞歸調用dec_ref_cnt(*ptr)對孩子進行計數器減量的操做。
  • 而後經過reclaim()函數,將obj鏈接到空閒鏈表上面。

疑問爲何要先inc_ref_cnt(obj)而後再dec_ref_cnt(*ptr)呢?
以爲應該先調用 dec_ref_cnt() 函數,後調 用 inc_ref_cnt() 函數才合適。

答案就是「爲了處理ptr和obj是同一對象時的狀況」。若是按照先dec_ref_cnt()後inc_ref_cnt()函數的順序調用ptr和 obj又是同一對象的話執行dec_ref_cnt(ptr)時ptr的計數器的值就有可能變爲0而被回收。這樣一來,下面再想執行inc_ref_cnt(obj)時obj早就被回收了,可能會引起重大的BUG。

update_ptr()函數執行狀況

初始狀態下從根引用A和C,從A引用B。A持有惟一指向B的指針(代指狀態a),假設如今將A指針更新到了C(狀態b)。以下圖。

B的計數器值變成了0,所以B被回收。且B鏈接上了空閒鏈表,可以再被利用了。又由於新造成了由A指向C的指針,因此C的計數器的值增量爲2。

優勢

可便可回收垃圾

在引用計數法中,每一個對象始終都知道本身的被引用數(就是計數器的值)。當被引用數的值爲0時,對象立刻就會把本身做爲空閒空間鏈接到空閒鏈表。也就是說,各個對象在變成垃圾的同時就會馬上被回收。要說這有什麼意義,那就是內存空間不會被垃圾佔領。垃圾 所有都已鏈接到空閒鏈表,能做爲分塊再被利用。

最大暫停時間短

在引用計數法中,只有當經過mutator更新指針時程序纔會執行垃圾回收。也就是說,每次經過執行mutator 生成垃圾時這部分垃圾都會被回收,於是大幅度地削減了mutator的最大暫停時間。

沒有沿着指針查找

引用計數法和GC標記-清除算法不同,不必由根沿指針查找。當咱們想減小沿指針查找的次數時,它就派上用場了。 打個比方,在分佈式環境中,若是要沿各個計算節點之間的指針進行查找,成本就會增大,所以須要極力控制沿指針查找的次數因此有一種作法是在各個計算節點內回收垃圾時使用GC標記-清除算法在考慮到節點間的引用關係時則採用引用計數法。

缺點

計數器的增減處理頻繁

在大多數狀況下指針都會頻繁地更新,特別是有根的指針,會以近乎使人目眩的勢頭飛速地進行更新。這是由於根能夠經過mutator直接被引用。在引用計數法中,每當指針更新時,計數器的都會隨之更新,所以值的增減處理必然會變得繁重。

計數器佔用不少位

用於引用計數的計數器最大必須能數完堆中全部對象的引用數。打個比方,假如咱們用的是32位機器,那麼就有可能要讓2 的32次方個對象同時引用一個對象。考慮到這種狀況,就有必要確保各對象的計數器有32位大小。也就是說,對於全部對象,必須留有32位的空間。這就害得內存空間的使用效率大大下降了。打比方說,假如對象只有2個域,那麼其計數器就佔了它總體的1/3。

實現繁瑣複雜

進行指針更新操做 update_ptr()函數是在mutator這邊調用的打個比方咱們須要把以往寫成*ptr=obj的地方都重寫成updat_ptr(ptr,obj)。由於調用update_ptr()函數的地方很是多,因此重寫過程當中很容易出現遺漏。若是漏掉了某處,內存管理就沒法正確進行,就會產生BUG。

循環引用問題

由於兩個對象互相引用,因此各對象的計數器的值都是1。可是這些對象組並無被其餘任何對象引用。所以想一併回收這兩個對象都不行,只要它們的計數器值都是1,就沒法回收。

延遲引用計數

在講到引用計數法缺點時候,咱們提到了計數器增減處理繁重。下面就對改善此缺點進行說明即延遲引用計數法(Deferred Reference Countin [L. Peter Deutsch 和 Daniel G. Bobrow] )。

什麼是延遲引用計數

咱們就讓從根引用的指針的變化不反映在計數器上。打個比方,咱們把重寫全局變量指針的 update_ptr(ptr,obj) 改寫成 *ptr = obj(直接更改引用對象,沒有使用方法也就沒有對計數器進行操做。)。如上所述,這樣一來即便頻繁重寫堆中對象的引用關係,對象的計數器值也不會有所變化,於是大大改善了「計數器值的增減處理繁重」這一缺點。然而,這樣內存管理仍是不能順利進行。由於引用沒有反映到計數器上,因此各個對象的計數器沒有正確表示出對象自己的被引用數。所以,有可能發生對象仍在活動,但卻被錯當成垃圾回收的狀況。 以下圖所示該對象其實還正在活動。

以後咱們在延遲計數法中使用ZCT(Zero Count Table)。ZTC是一個表,他會事先記錄下計數器值在dec_ref_cnt()函數的做用下變爲0的對象

由於計數器值爲0的對象不必定都是垃圾,因此暫時先將這些對象保留。由圖也能看出,咱們必須修正dec_ref_cnt() 函數,使其適應延遲引用計數法。

dec_ref_cnt()函數

dec_ref_cnt(obj){
    obj.ref_cnt--
    if(obj.ref_cnt == 0)
        if(is_full($zct) == TRUE)
            scan_zct()
        push($zct, obj)
}

當obj計數器爲0,就把obj添加到$zct中。不過當$zct爆滿,首先要經過scan_zct()函數來減小$zct中的對象。

new_obj()函數

也要修改一線new_obj()函數。當沒法分配空間時,執行scan_zct()清理一遍$zct對象(表)。

new_obj(size){
    obj = pickup_chunk(size, $free_list)
    if(obj == NULL)
        scan_zct()
        obj = pickup_chunk(size, $free_list)
        if(obj == NULL)
            allocation_fall()
    obj.ref_cnt = 1
    return obj
}

若是第一次分配沒有順利進行,就意味着空閒鏈表中沒有了大小合適的分塊。此時程序要搜索一遍$zct,以再次分配分塊。若是這樣還不行,分配就失敗了。分配順利進行以後的流程一般與引用計數法徹底同樣。

scan_zct()函數

scan_zct(){
    for(r :$roots)  // 以前咱們說過,把根的引用不反應在計數器上,如今要清表了天然要加上。
        (*r).ref_cnt++
    for(obj :$zct)  // 查水錶
        if(obj.ref_cnt == 0)  // 判斷值
            remove($zct, obj)  // 減計數
            delete(obj)  //清對象
    for(r :$root)
        (*r).ref_cnt-- // 用完了在給根加回去。
}
  • 程序把全部經過根直接引用的對象的計數器都進行增量。這樣纔算把根引用反映到了計數器的值上
  • 調查全部與$zct相連的對象,若是存在計數器值爲0的對象,則將此對象從$zct中刪除(delete)。
// delete代碼清單
delete(obj){
    for(child :children(obj))
        (*child).ref_cnt--
        if((*child).ref_cnt == 0)
            delete(*child)  // 一樣要遞歸的去操做孩子的計數器
    reclaim(obj) // 加到空閒鏈表上
    
}

優缺點

優勢在延遲引用計數法中,程序延遲了根引用的計數,將垃圾一併回收。經過延遲,減輕了 因根引用頻繁發生變化而致使的計數器增減所帶來的額外負擔。

缺點爲了延遲計數器值的增減,垃圾不能立刻獲得回收,這樣一來垃圾就會壓迫堆,咱們也 就失去了引用計數法的一大優勢(即刻回收垃圾)。

缺點scan_zct() 函數致使最大暫停時間延長了,執行scan_zct() 函數所花費的時間 $zct的大小成正比。$zct 越大,要搜索的對象就越多,妨礙mutator運做的時間也就越長。要想縮減因scan_zct()函數而致使的暫停時間,就要縮小 $zct。可是這樣一來調用scan_ zct()函數的頻率就增長了,也壓低了吞吐量。很明顯這樣就本末倒置了。

Stricky引用計數法

什麼是Stricky引用計數法

在引用計數法中,咱們有必要花功夫來研究一件事,那就是要爲計數器設置多大的位寬。 假設爲了反映全部引用,計數器須要1個字(32位機器就是32位)的空間。可是這樣會大量消耗內存空間。打個比方,2 個字的對象就要附加1個字的計數器。也就是說,計數器害得對象所佔空間增大了1.5 倍。

對此咱們有個方法,那就是用來減小計數器位寬的「Sticky引用計數法」。舉個例子咱們假設用於計數器的位數爲5位,那麼這種計數器最多隻能數到2的5次方減1也就是31個引用數。若是此對象被大於31個對象引用,那麼計數器就會溢出。這跟車輛速度計的指針爆表是一個情況。

針對計數器溢出(也就是爆表的對象),須要暫停對計數器的管理。對付這種對象,咱們主要有兩種方法

什麼都不作

對於計數器溢出的對象,咱們能夠這樣處理:再也不增減計數器的值,就把它放着,什麼也不作。不過這樣一來,即便這個對象成了垃圾(即被引用數爲 0),也不能將其回收。也就是說,白白浪費了內存空間。然而事實上有不少研究代表,不少對象一輩子成立刻就死了。也就是說, 在不少狀況下,計數器的值會在0到1的範圍內變化,鮮少出現 5 位計數器溢出這樣的狀況。 此外,由於計數器溢出的對象在執行中的程序裏佔有很是重要的地位,因此可想而知,其將 來成爲垃圾的可能性也很低也就是說,不增減計數器的值,就把它那麼放着也不會有什麼大問題。 考慮到以上事項,對於計數器溢出的對象,什麼也不作也不失爲一個可用的方法。

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

另外一個方法是,能夠使用標記清除算法來輔助。可是須要對標記清除進行修改。
mark_sweep_for_counter_overflow()

// mark_sweep_for_counter_overflow()
mark_sweep_for_counter_overflow(){
    reset_all_ref_cnt()
    mark_phase()
    sweep_phase()
    
}

首先把全部對象的計數器都置爲0。下面進入標記階段和清除階段。

mark_phase()

// mark_phase()
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)

}

首先把由根直接引用的對象堆到標記棧裏,而後按順序從標記棧取出對象。對計數器進行增量操做。不過這裏必須把各個對象及其子對象堆進標記棧一次。while裏的if是檢測各個對象是否是隻進棧一次。一旦棧爲空,則標記階段結束。
sweep_phase()

// sweep_phase()
sweep_phase(){
    sweeping = $heap_top
    while(sweeping < $heap_end)
        if(sweeping.ref_cnt == 0)
            reclaim(sweeping)
        sweeping +=sweeping.size
}

在清除階段,程序會搜索整個堆,回收計數器值仍爲0的對象

這裏的標記清除,和以前的標記清除主要有如下3點不一樣之處。

  1. 一開始把全部對象的計數器置爲0.
  2. 不標記對象,而是對計數器進行增量操做。
  3. 爲了對計數器進行增量操做,算法對活動對象進行了不止一次的搜索。

像這樣,只要把引用計數法和 GC 標記 - 清除算法結合起來,在計數器溢出後即便對象 成了垃圾,程序仍是能回收它另外還有一個優勢,那就是還能回收循環的垃圾

可是在進行標記處理以前,必須重置全部的對象和計數器。此外,由於在查找對象時沒 有設置標誌位而是把計數器進行增量,因此須要屢次(次數和被引用數一致)查找活動對象。 考慮到這一點的話,顯然在這裏進行的標記處理比以往的 GC 標記 - 清除算法中的標記處理 要花更多的時間。也就是說,吞吐量會相應縮小。

1位引用計數法

1位引用計數法(1bit Reference Counting)是Sticky引用計數法的一個極端例子。由於計數器只有1位大小,因此瞬間就會溢出。

據Douglas W. Clark和C. Cordell Green觀察,「幾乎沒有對象是被共有的,全部對象都能被立刻回收」。考慮到這一點,即便計數器只有 1 位,經過用 0表示被引用數爲1用1表示被引用數大於等於2,這樣也能有效率地進行內存管理。以下圖示。

咱們用1位來表示某個對象的被引用數是1個仍是多個。通常引用計數法是讓對象持有計數器,可是W.R.Stoye、T.J.W.Clarke、A.C.Norman 3我的想出了1位引用計數法,以此來讓指針持有計數器。

設對象引用數爲1時標籤位爲0,引用數爲複數時標籤位爲1。咱們分別稱以上2種狀態爲UNIQUE和MULTIPLE,處於UNIQUE狀態下的指針爲「UNIQUE指針」,處於MULTIPLE狀態下的指針爲「MULTIPLE 指針」。

copy_ptr()函數

1 位引用計數法也是在更新指針的時候進行內存管理的。不過它不像以往那樣 指定要引用的對象來更新指針,而是經過複製某個指針來更新指針。進行這項操做的就是 copy_ptr() 函數。下圖示。

在這裏更新以前A引用D的指針,讓其引用C。能夠看到,B由UNIQUE變爲了MULTIPLE。僞代碼以下。

copy_ptr(dest_ptr, src_ptr){
    delete_ptr(dest_ptr)
    *dest_ptr = *src_ptr
    set_multiple_tag(dest_ptr)
    if(tag(src_ptr) == UNIQUE)
        set_multiple_tag(src_ptr)
        
}

數 dest_ptr 和 src_ptr 分別表示的是目的指針和被複制的原指針。在上圖圖 中,A 的指針就是目的指針,B 的指針就是被複制的原指針。

  1. 首先調用delete_ptr()函數,嘗試回收dest_ptr引用對象。
  2. 把src_ptr複製到dest_ptr。
  3. 把指針src_ptr以及dest_ptr的標籤更新爲MULTIPLE。
  • tag()函數返回實參的標籤,返回UNIQUE或者MULTIPLE的任意一值。
  • set_multiple_tag()函數則把指針變成MULTIPLE指針。

最後把mutator的update_ptr()函數的調用全換成copy_ptr()就能實現1位引用計數法。

delete_ptr()函數

delete_ptr(ptr){
    if(tag(ptr) == UNIQUE)
    reclaim(*ptr)
}

只有當指針ptr的標籤是UNIQUE時,它纔會回收根據這個指針所引用的對象。由於當標籤是MULTIPLE時,還可能存在其餘引用這個對象的指針,因此它沒法回收對象。

優缺點

優勢
不容易出現高速緩存缺失。緩存做爲存儲空間,比內存讀取的快得多。若是須要的數據在緩存中,計算機就能進行高速處理。但若是缺失了的話就須要從內存中去讀。這樣一來浪費許多時間。

也就是說,當某個對象A要引用在內存中離他很遠的B時,以往的引用計數法會在增減計數器的值時候去讀B,從而致使高速緩存缺失,浪費時間。

1位引用計數法,它不須要在更新計數器的時候讀取要引用的對象。指針直接複製就行,不必讀取對象。由於不必給計數器留出多餘的空間,因此節省了內存消耗量。這也不失爲1位引用計數法的一個優勢。

缺點 沒辦法處理計數器益處的對象。雖說,計數器的值通常都不足爲2,可是若是少許固然能夠放置無論。但咱們不能保證某一個任務不會出現不少計數器溢出的現象。

相關文章
相關標籤/搜索