Mark Sweep GC
目錄
標記清除演算法
GC 標記-清除演算法是由標記階段和清除階段構成。標記階段把所有活動的物件做上標記。清除階段是吧沒有標記的物件也就是非活動的物件進行回收。通過這兩個階段就可以對空間進行重新利用。
mark_sweep()函式
mark_sweep(){
mark_phase() //標記
sweep_phase() //清除
}
在執行GC前堆的狀態
標記階段
使用 mark_phase()函式進行處理。在標記階段,collector會為堆裡所有活動的物件打上標記。我們首先要通過根找到直接引用的物件進行標記,之後才能遞迴的訪問到物件並把所有活著的物件進行標記。
mark_phase()
mark_phase(){
for(r:$roots)
mark(*r) //從根開始取它的孩子進行標記
}
mark(obj){
if(ob.mark == FALSE)
obj.mark = TRUE // 如果還是標記是FALSE則改為TRUE
for(child :children(obj)) // 再去遞迴的標記自己的孩子
mark(*child)
}
標記完所有物件後,標記活動就結束了。這時,狀態如下圖所示。
在標記階段中,程式會標記所有活動物件,毫無疑問,物件越多需要的時間就越多。他們是正相關的。
深度優先於廣度優先
這時一個老生常談的問題了。應用很廣,因為像這種型別可能沒有其他方式遍歷了,其使用的佇列和遞迴也是非常常用的。
深度優先depth-first search 使用遞迴實現
廣度優先breadth-first search 使用佇列實現
無論使用哪種方法,搜尋的物件時不會變的。但是深度優先可以壓低記憶體的使用量,因此我們經常實用深度優先。
清除階段
在清除階段,cllector會遍歷整個堆。回收垃圾,使其能再次利用。此時出現了size的域這是儲存物件大小的域,同mark一樣我們在頭中定義了它。簡單的說,在塊的頭中現在有mark和size。mark表示時候有用,size表示塊的大小。
sweep_phase()
sweep_phase(){
sweeping = $heap_start //堆頭
while(sweeping < $heap_end) //現在為止在堆尾之前
if(sweeping.mark == TRUE)
SWEEPING.mark = FALSE //這裡標記為false。因為如果以後不再使用了她就能被回收,否則就不能回收了。
else
sweeping.next = $free_list //初始化空連結串列
$free_list = sweeping // 連線到一個free連結串列上
sweeping += sweeping.size //可以看做是基址+偏移。拿到下一個塊地址
}
通過上面的操作,我們拿到了free_list。之後我們針對它進行分配空間操作。下圖就表現出gc後兩個連結串列
清除階段同理,會遍歷所有塊進行垃圾回收。時間和堆的大小成正相關。
分配
那麼對於垃圾回收的再利用是怎麼進行的呢?。當mutator申請分塊時,怎樣才能分呢?
我們使用nwe_obj函式來分配。
new_obj
nwe_obj(){
chunk = pickup_chunk(size, $free_list) // 我需要的大小size 和查詢物件 free_list
if (chunk !=NULL)
return chunk //拿到了
else
allocation_fail() // 拿不到
}
在分配的時候有幾個點:不能分配比size小的,只能使用大於等於size的塊去進行分配,如果大小合適再好不過,如果大了就需要去分割一個和size一樣大小的塊再分配,剩餘部分返回空連結串列。
First-fit、Best-fit、Worst-fit三種分配策略
- First-fit:最初發現大於等於size的分塊就進行操作。可能第一個就比size大,但是第二個就和size一樣大。這樣就直接分了第一個,碎片嚴重。
- Best-fit:遍歷空連結串列,選擇最合適的。效能低啊。
- Worst-fit:找出空閒連結串列中最大的塊,分割成size和其他。也容易產生碎片,比第一個還容易,不推薦使用。
綜上, 考慮到碎片和效能問題,選擇First-fit還是比較明智的選擇。
合併
根據分配策略會生成大量小塊,但是如果小塊是連續的我們其實可以將它連線起來成為一個大塊。這就叫做合併coalescing
sweep_phase()
sweep_phase(){
sweeping = $heap_start //堆頭
while(sweeping < $heap_end) //現在為止在堆尾之前
if(sweeping.mark == TRUE)
SWEEPING.mark = FALSE //這裡標記為false。因為如果以後不再使用了她就能被回收,否則就不能回收了。
else
if(sweeping = $free_list+$free_list.size)// 先驗證是不是相鄰的。
$free_list.size +=sweeping.size //這樣就連線起來了。
else
sweeping.next = $free_list //初始化空連結串列
$free_list = sweeping // 連線到一個free連結串列上
sweeping += sweeping.size //可以看做是基址+偏移。拿到下一個塊地址
}
優點
實現簡單
他其實就是用到了標識位,然後進行遍歷。演算法上難度很低。不像其他演算法難度是比較大的。
與保守式GC演算法相容
在保守式GC中,物件時不能被移動的。例如複製演算法他就是將資料複製過來,在複製過去,是移動的。而標記清除不移動物件,所以非常適合保守式GC演算法。實際上很多采用保守式gc的程式中使用到了標記清除。
缺點
碎片化
演算法使用過程中產生被細化的分塊,在不久後就會散落在各處。我們將這種狀況稱為碎片化(fragmentation)。windows檔案系統也會有這種現象。
如果發生了碎片化,及時空閒連結串列再大,也不能分配成功。為了解決這個問題可以採用壓縮,但是本文中介紹了一下BIBOP法提供參考。
分配速度
標記清除演算法中分塊不是連續的,因此每次都需要遍歷連結串列。在最糟糕的情況下是每次是連結串列的最後一塊。因此速度非常慢,如果一個大型遊戲採用這種方式後果可想而知。
後文敘述的多個空閒連結串列和BIBOP都是為了解決速度而採取的方案。
與寫時複製技術不相容
寫時複製技術(copy-on-write)是在Linux等眾多unix作業系統的虛擬儲存中使用的高速化方案。例如在Linux複製程序使用fork()時,大部分空間都不會被複制。如果說為了複製程序就複製了所有空間這樣記憶體怎麼多也不夠。因此寫時複製技術就假裝複製了記憶體空間實際上是共享記憶體空間。
當然我們對共享的空間寫入時不能直接寫它,因為它是別的。這時候就要將其複製到自己的私有空間然後進行重寫。複製後之訪問私有空間不訪問共享空間。
但是標記清除演算法,就算是沒有重寫,也會進行不斷的複製。實際上我們還是希望它是用共享,而不是浪費記憶體。為了處理這個問題我們採用為圖示記法bitmap marking。
多個空閒連結串列
之前的演算法中使用的只有一個空閒連結串列,對大塊和小塊進行統一的處理。這樣一來無論大小都要遍歷很長的鏈。
因此我們對塊進行分類。大的是一組,小的是一組。這樣一來按照mutator所申請的空間大小選擇合適的塊就容易的多。
一般情況來說,mutator很少會申請非常大的分塊。為了應對這種極少數情況,我們給分塊設定一個上限。如果分塊大於等於這個大小,就全部採用一個空連結串列。
利用多個空連結串列時,我們需要修正new_obj()以及sweep_phase()
new_obj
nwe_obj(){
index = size/(WORD_LENGTH/BYTE_LENGTH) //根據商來選擇合適的建表
if (index <=100 ) //小於100字
if($free_list[index] !=NULL)
chunk = $free_list[index] // 獲取鏈的索引,分給他一個index字鏈上的第一塊
$free_list[index] = $free_list[index].next
return chunk
else //大於100字
chunk = pickup_chunk(size, $free_list[101]) // 我需要的大小size 和 查詢物件 free_list
if (chunk !=NULL)
return chunk //拿到了
else
allocation_fail() // 拿不到
}
sweep_phase()
sweep_phase(){
for(i:2...101)
$free_list[1] = NULL
sweeping = $heap_start //堆頭
while(sweeping < $heap_end) //現在為止在堆尾之前
if(sweeping.mark == TRUE)
SWEEPING.mark = FALSE //這裡標記為false。因為如果以後不再使用了她就能被回收,否則就不能回收了。
else
index = size/(WORD_LENGTH/BYTE_LENGTH)
if(index <=100)
sweeping.next = $free_list[index]
$free_list[index] = sweeping
else
sweeping.next = $gree_list[101]
$free_list[101] = sweeping
sweeping += sweeping.size //可以看做是基址+偏移。拿到下一個塊地址
}
BIBOP法
BiBOP是Big Bag Of Pages的縮寫,就是將大小相近的物件整理成固定大小的塊進行管理的做法。我們可以使用這個方法把堆分成固定大小的塊,讓每個塊只能配置同樣大小的物件這就是BiBOP演算法。不覺得記憶體利用效率低嗎?熊die。
如圖所示,在多個分塊中殘留同樣大小的物件反而會使堆的使用效率低下。
點陣圖標記
在以前的標記清除演算法中,標記是在物件頭中的,這樣造成了與寫時複製技術的不相容。對此收集頭部標識表格化將標識與物件分開管理。這樣的標記方法稱為位圖表格(bitmap table)。點陣圖標記可以採用如散列表,樹形結構等。為了簡單起見我們使用陣列。如下圖。
表中的位置和堆裡的物件一一對應。一般來說堆中的一個字會分到一個位。
mark()
mark(obj){
obj_num = (obj-$heap_start)/WORD_LENGTH
index = obj_num / WORD_LENGTH
offset = obj_num % WORD_LENGTH
if (($bitmap_tbl[index]&(1<<offset))==0)
$bitmap_tbl[index] != (1<<offset)
for (child :children(obj))
mark(*child)
}
這裡WORD_LENGTH 是個常量,表示各機器中1個字的位寬。obj_num指從位圖表格前面數起,obj的標誌位在第幾個。例如上圖中E,它的obj_num就是8.但是bitmap的圖是從後往前的所以E的標誌位應該從右往左數是第九個位。如下圖
優點
與寫時複製技術相容
以往標記位是對物件進行設定,而點陣圖標記不對物件進行設定採用了對映或許可以理解為引用。所以自然不會發生無謂的複製。
清除操作更高效
利用位圖表格的清除操作把所有物件的標誌位集合到一處,可以快速定位。與一般的清除階段相同,我們sweeping遍歷整個堆,不過這裡使用了index和offset兩個變數,在遍歷堆的同時也遍歷位圖表格。
sweep_phase
sweep_phase(){
sweeping = $heap_start
index = 0
offset = 0
while(sweeping <$heap_end)
if($bitmap_tb1[index] &(1<<offset) ==0)
sweeping.next = $free_list
$free_list = sweeping
index +=(offset + sweeping.size) /WORD_LENGTH
offset = (offset + sweeping.size) % WORD_LENGTH
sweeping += sweeping.size
for (i:0...(HEAP_SIZE/WORD_LENGTH-1))
$bitmap_tbl[i] = 0
}
注意
物件地址和位圖表格對應。通過物件的地址求與其對應的位置標誌,要進行位運算的。再有多個堆且地址不連續的情況下,必須採用多個表。即每個堆一個表。
延遲清除法
我們之前說過,清除花費的時間和堆的大小成正比。這樣一來堆越大就越影響mutator。會越慢。
延遲清除(Lazy Sweep)是縮減因清除操作而導致的mutator最大暫停時間的方法。在標記操作結束後,不一併進行清除。通過延遲來防止mutator長時間暫停。
nwe_obj
nwe_obj(size){
chunk = lazy_sweep(size)
if (chunk!=NULL)
return chunk
mark_phase()
chunk = lazy_sweep(size)
if(chunk !=NULL)
return chunk
allocation_fail()
}
分配時呼叫lazy_sweep進行清除。如果他能用清除操作來分配塊,就會返回分開,如果不能分配塊就會執行標記操作。當lazy_sweep函式返回NULL時就是沒有找到。那就在進行一遍此操作。如果還沒能分得代表沒有分塊。mutator也就不需要進行下一步處理。
lazy_sweep
lazy_sweep(size){
while($sweeping <heap_end)
if($sweeping.mark == TRUE)
$sweeping.mark = FALSE
else if($sweeping.size >=size)
chunk = $sweeping
$sweeping +=$sweeping.size
return chunk
$sweeping += $sweeping.size
$sweeping = $heap_start
return NULL
}
次函式會一直遍歷堆,知道找打大於等於所申請的空間。再找到時候會將其返回,但是$sweeping是全域性變數,也就是說遍歷開始位置謂語上一次清除操作中發現的分塊右邊。
當次函式沒有找打分塊時候會返回NULL。
此方法在分配時執行必要的遍歷,因此可以壓縮清除操作導致mutator暫停的時間,這就是延遲的意思。
只有延遲清除是不夠的
雖然可以減少mutator的暫停時間。但是延遲清除的效果是不均勻的。打個比方如下圖。
垃圾變成了垃圾堆,活動物件變成了活動物件堆。這種情況下,程式清除垃圾較多的部分時馬上就能獲得分塊,隨意能減少mutator的暫停時間。然鵝一旦程式開始清除活動物件周圍就怎麼也獲得不了分塊,這就增加了mutator的暫停時間。
至於有什麼其他方法。後續會在其他文章裡。