本文是《垃圾回收的演算法與實現》讀書筆記
上一篇為《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)
}
複製程式碼
上圖這裡開始時,A 指向 B,第二步 A 指向了 C。可以看到通過更新,B 的計數器值變為了0,因此 B 被回收(連線到空閒連結串列),C 的計數器值由1變成了2。
通過上邊的介紹,應該可以看出引用計數垃圾回收的特點。
- 在變更陣列元素的時候會進行指標更新
- 通過更新執行計數可能會產生沒有被任何程式引用的垃圾物件
- 引用計數演算法會時刻監控更新指標是否會產生垃圾物件,一旦生成會立刻被回收。
所以如果呼叫
pickup_chunk
函式返回 NULL,說明堆中所有物件都是活躍物件。
引用計數演算法的優點
-
可立即回收垃圾
每個物件都知道自己的引用計數,當變為0時可以立即回收,將自己接到空閒連結串列
-
最大暫停時間短
因為只要程式更新指標時程式就會執行垃圾回收,也就是每次通過執行程式生成垃圾時,這些垃圾都會被回收,記憶體管理的開銷分佈於整個應用程式執行期間,無需掛起應用程式的執行來做,因此消減了最大暫停時間(但是增多了垃圾回收的次數)
最大暫停時間
,因執行 GC 而暫停執行程式的最長時間。 -
不需要沿指標查詢
產生的垃圾立即就連線到了空閒連結串列,所以不需要查詢哪些物件是需要回收的
引用計數演算法的缺點
-
計數器值的增減處理頻繁
因為每次物件更新都需要對計數器進行增減,特別是被引用次數多的物件。
-
計數器需要佔用很多位
計數器的值最大必須要能數完堆中所有物件的引用數。比如我們用的機器是32位,那麼極端情況,可能需要讓2的32次方個物件同時引用一個物件。這就必須要確保各物件的計數器有32位大小。也就是對於所有物件,必須保留32位的空間。
假如物件只有兩個域,那麼其計數器就佔用了整體的1/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 的物件。
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--
}
複製程式碼
- 第二行和第三行,程式先把所有根直接引用的計數器都進行增量。這樣,來修正計數器的值。
- 接下來檢查
$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 標記-清除演算法 主要不同點如下:
- 開始時將所有物件的計數器值設為0
- 不標記物件,而是對計數器進行增量操作
- 為了對計數器進行增量操作,演算法對活動物件進行了不止一次的搜尋。
這裡將 GC 標記-清除演算法和引用計數法結合起來,在計數器溢位後,物件稱為垃圾也不會漏掉清除。並且也能回收迴圈引用的垃圾。
因為在查詢物件時不是設定標誌位而是把計數器進行增量,所以需要多次查詢活動物件,所以這裡的標記處理比以往的標記清除花的時間更長,吞吐量會相應的降低。
參考連結
最後,感謝女朋友支援和包容,比❤️
也可以在公號輸入以下關鍵字獲取歷史文章:公號&小程式
| 設計模式
| 併發&協程