本文是《垃圾回收的算法與實現》讀書筆記上一篇爲《GC 標記-清除算法》python
給對象中添加一個引用計數器,每當有一個地方引用它時,計數器的值就加1;當引用失效時,計數器值就減1;任什麼時候刻計數器爲0的對象就是不可能再被使用的。這也就是須要回收的對象。
引用計數算法
是對象記錄本身被多少程序引用,引用計數爲零的對象將被清除。算法
計數器
表示的是有多少程序引用了這個對象(被引用數)。計數器是無符號整數。小程序
引用計數法沒有明確啓動 GC 的語句,它與程序的執行密切相關,在程序的處理過程當中經過增減計數器的值來進行內存管理。設計模式
與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() 函數用於更新指針 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) }
上圖這裏開始時,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 的對象。
在延遲引用計數法中,引用計數爲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) }
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) }
除 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
函數也會增長程序的最大暫停時間。
對於引用計數法,有一個不能忽略的部分是計數器位寬的設置。假設爲了反映全部引用,計數器須要1個字(32位機器就是32位)的空間。可是這會大量的消耗內存空間。好比,2個字的對象就須要一個字的計數器。也就是計數器會使對象所佔的空間增大1.5倍。
sticky 引用計數法
就是用來減小位寬的。
若是咱們爲計數器的位數設爲5,那麼計數器最大的引用數爲31,若是有超過31個對象引用,就會爆表。對於爆表,咱們怎麼處理呢?
這種處理方式對於計數器爆表的對象,再有新的引用也不在增長,固然,當計數器爲0 的時候,也不能直接回收(由於可能還有對象在引用)。這樣實際上是會產生殘留的對象佔用內存。
不過,研究代表,大部分對象其實只被引用了一次就被回收了,出現5位計數器溢出的狀況少之又少。爆表的對象大部分也都是重要的對象,不會輕易回收。
因此,什麼都不作也是一個不錯的辦法。
這種方法是,對於爆表的對象,使用 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 標記-清除算法和引用計數法結合起來,在計數器溢出後,對象稱爲垃圾也不會漏掉清除。而且也能回收循環引用的垃圾。
由於在查找對象時不是設置標誌位而是把計數器進行增量,因此須要屢次查找活動對象,因此這裏的標記處理比以往的標記清除花的時間更長,吞吐量會相應的下降。
最後,感謝女友支持和包容,比❤️
也能夠在公號輸入如下關鍵字獲取歷史文章:公號&小程序
| 設計模式
| 併發&協程