《垃圾收集》筆記——第二章

3種經典算法:引用計數(reference counting)、標記-清掃(mark-sweep)和節點複製(copying)。


2.1 引用計數算法
是一種直接算法,其基本手段是爲每個單元計算指向它的引用(來自其他活動單元或者根)的數量。優點在於能夠非常簡單地判斷單元是否正在使用。天生是一種漸進式的技術,能夠將內存管理的開銷分佈到整個程序之中。

每個單元都有一個額外的域,存放引用計數值(reference count)。內存管理器必須位每個單元維護引用計數值,使之等於指向該單元的指針的數量(這些指針來自其他堆單元或是根)。首先這一算法把所有的單元放在一個自由單元池中,這個池的實現常常是一個鏈表(每個單元有一個指針域,稱之爲next域,所有單元通過它鏈接成一條長鏈),連同一個指向鏈表表頭的指針,free_list。

不一定非得位此專門增加一個next域。通常,它和保存引用計數值的域是同一個域——自由單元並不需要明確的引用計數值。或者,我們也可以使用單元中某個保存用戶數據的域。

2.1.1 算法
自由單元的引用計數值是0。當一個新單元從池中被分配的時候,它的引用計數值被設爲1.每次有一個指針被設爲指向這一單元時,該單元的計數值加1;而每次刪除某個指向它的指針時,它的計數值減1。如果單元的計數值將爲0,引用計數不變式告訴我們不再存在指向該單元的指針。更進一步,程序已不再需要這個單元,可以把它放回自由單元的列表了。

// 引用計數的分配
allocate() =
 newcell = free_list
 free_list = next(free_list)
 return newcell

New() =
 if free_list == nil
  abort "Memory exhausted"
 newcell = allocate()
 RC(newcell) = 1
 return newcell

//引用計數環境下更新指針域
free(N) =
 next(N) = free_list
 free_list = N

delete(T) =
 RC(T) = RC(T) - 1
 if RC(T) == 0
  for U in Children(T)
   delete(*U)
  free(T)

Update(R, S) =
 RC(S) = RC(S) + 1
 delete(*R)
 *R = S

2.1.3 優勢和弱點
優勢:
1)這類垃圾收集器在運行時不會掛起完成實際工作的用戶程序,使得響應時間更加“平滑”。
2)在空間上的引用局部性(locality of reference)不會劣於客戶程序。當某個單元的引用計數值變爲0時,系統無需訪問堆中其他頁面的單元(除了該單元的後代)。
3)允許這些單元以一種類似棧分配的方式在剛被丟棄時就立刻回收重用。
缺點:
1)爲了維護引用計數不變式,這類實現不得不支付高昂的處理開銷。每次改寫一個指針,它的舊目標單元和新目標單元的引用計數都必須進行調整。
2)基於引用計數的內存管理機制總是與客戶程序或它的編譯器緊密地耦合在一起。

2.1.4 環形數據結構
簡單的引用計數算法最主要的缺陷是無法回收環形的數據結構。

常見環形數據結構,包括雙向鏈表和那些包含了從葉子回到根的指針的樹。
許多基於圖規約(graph reduction)的延遲函數式語言(lazy functional language)的實現,採用環來處理遞歸。


2.2 標記-清掃算法
是一種基於追蹤的垃圾收集技術:標記-清掃(mark-sweep)算法,也可稱爲標記-掃描(mark-scan)算法。

內存單元並不會在變成垃圾的同時立刻回收,而是保持不可到達和未被發現的狀態,知道所有可用的內存都耗盡。如果此時再次出現對新單元的請求,系統會暫時掛起“有用”的程序,並調用垃圾收集例程,將對中所有當前並未使用的單元清掃回自由單元池中。

這個遍歷從根除非,標明所有可以到達的單元。根據定義,這些就是存活單元。除此之外所有其他節點都是垃圾,可以送回自由單元池。

2.2.1 算法
一種可能的實現是把自由單元鏈接成一個自由鏈表。

該算法分兩階段執行:
1)標記(mark)階段,標明所有存活單元。
2)清掃(sweep)階段,把垃圾單元還給自由單元池。
如果清掃階段沒有回覆足夠多的自由單元,那麼系統必須擴展堆,或者中斷程序。

每個單元需保留一個二進制位讓垃圾收集器使用。

標記過程不會沿着已經標記的單元追蹤下去,確保標記能終止。

// 標記-清掃的分配
New() ==
 if free_pool is empty
  mark_sweep()
 newcell = allocate()
 return newcell

// 標記-清掃垃圾收集器
mark_sweep() =
 for R in Roots
  mark(R)
 sweep()
 if free_pool is empty
  abort "Memory exhausted"

// 簡單的遞歸標記算法
mark(N) =
 if mark_bit(N) == unmarked
  mark_bit(N) = marked
  for M in Children(N)
   mark(*M)

// 對堆得急切清掃(eager sweep)
sweep() =
 N = Heap_bottom
 while N < Heap_top
  if mark_bit(N) == unmarked
   free(N)
  else mark_bit(N) = Unmarked
  N = N + size(N)

2.2.2 標記-清掃算法的優勢和弱點
關鍵性的安全系統、實時系統,甚至是視頻遊戲,都不能接受在垃圾收集時有這麼長時間的停頓。解決方案是在關鍵的時間段內禁止垃圾收集。

這一算法的漸進複雜度正比於整個堆的大小,而非僅僅正比於存活單元的數量。

這已算法還會傾向於使內存空間更破碎,讓單元散佈在整個堆中。

在實存儲器系統中,儘管內存破碎會使採用cache帶來的益處不復存在,但它對性能的影響還不是很大。而在虛擬存儲器系統中,這種破碎會造成數據結構的相關單元之間失去空間上的侷限性,導致系統出現顛簸(thrashing)現象:程序極爲頻繁地在輔助存儲器和主存儲器之間交換頁面。

這種破碎會使得分配內存更加困難,因爲必須在隊中搜尋合適的空隙以容納新德對象。

 

2.3 節點複製算法
節點複製式收集器將整個堆等分爲兩個半區(semi-space),一個包含現有的數據,另一個包含已被廢棄的數據。

節點複製式垃圾收集從切換(flip)兩個半區的角色開始。然後收集器在老得半區(Fromspace)中遍歷存活的數據結構,在第一次訪問某個單元時把它複製到新的半區(Tospace)。遍歷完後,收集器在Tospace中建立了一個存活數據結構的複本。垃圾單元只是簡單地被遺棄在Fromspace中。

節點複製垃圾收集天生的一個有益的作用:所有存活的數據結構都縮並地排列在Tospace的底部。

New過程只需要檢查有沒有足夠的空間,然後再遞增指向自由空間開始處的指針free。
由於存活數據在Tospace中是縮並的,檢查空間是否足夠僅僅是一個指針比較而已。
不會給像更新指針這樣的用戶程序操作帶來額外的負擔。

// 節點複製式收集器中的分配
init() =
 Tospace = Heep_bottom
 space_size = Heap_size / 2
 top_of_space = Tospace + space_size
 Fromspace = top_of_space + 1

New(n) =
 if free + n > top_of_space
  flip()
 if free + n > top_of_space
  abort "Memory exhausted"
 newcell = free
 free = free + n
 return newcell

2.3.1 算法
首先過程flip交換Tospace和Fromspace的角色,它重置了變量Tospace、Fromspace和top_of_space。接着,收集器把每個從根出發可以到達的單元都從Fromspace複製到Tospace。這裏暫時使用簡單的遞歸算法。

Copy(P)複製P指向的單元中的各個域。複製數據時必須小心地保持共享結構的拓撲,否則將會導致共享的對象出現多個副本。

最好的情況下,這將增大程序對堆空間的佔用率,如果沒那麼幸運,這種錯誤可能破壞用戶程序的語言(例如,如果用戶程序更新了某個單元的一個副本,但卻讀取了另一個副本的數據。若沒能保持共享,複製環形數據結構將會需要很大的空間

// 節點複製式垃圾收集中的切換過程
flip() =
 Fromspace, Tospace = Tospace, Fromspace
 top_of_space = Tospace + space_size
 free = Tospace
 for R in Roots
  R = copy(R)

在複製節點時,節點複製收集器爲Fromspace中德對象保留一個遷移地址(forwarding address),以此來保持共享。遷移地址實際上是Tospace中複本的地址。

每當Fromspace中德某個單元被訪問時,Copy過程會檢查它是否已經被複制了,若是那麼就返回遷移地址,否則就在Tospace中保留空間以備複製。

遷移地址可能存放在單元內專爲它保留的域中。更普遍的做法是將它寫入單元的第一個字。

假設單元P中保存遷移地址的域是P[0],並且我們等價地使用forwarding_address(p)和P[0]。P指向一個字,而不是一個單元。

// 支持可變大小單元的Fenichel-Yochelson節點複製垃圾收集
copy(P) =
 if atomic(p) or p == null --P不是指針
  return P
 if not forwarded(p)
  n = size(P)
  p' = free --在Tospace中保留空間
  free = free + n
  temp = P[0] --遷移地址將保存在第0域中
  forwarding_address(P) = p'
  p'[0] = copy(temp)
  for i = 1 to n-1 --將P中德各個域複製到P'
   p'[i] = copy(p[i])
 return forwarding_address(P)

2.3.3 節點複製算法的優勢和弱點
採用節點複製技術可以極大地降低內存分配的開銷:
1)檢查空間是否耗盡只需要做簡單的指針比較;
2)獲取新內存可以簡單地通過遞增自由空間指針來實現;
3)由於存活數據縮並地排列在Tospace的底部,內存碎片問題將不存在。

節點複製式垃圾收集最直接的代價是使用兩個半區:這樣它所需要的地址空間是非複製式收集器的兩倍。

一種解決方案:採用虛擬存儲器的系統會將不活動的半區換出到輔助存儲器上,但是這種觀點忽略了換頁的開銷。

在垃圾收集週期內,不論用戶程序的內存佔用率如何,節點複製式垃圾收集器將會觸及堆中的每一頁。除非兩個半區能同時存放在物理內存中,否則由於節點複製式收集器所使用的頁的數量是標記—清掃式收集器的兩倍,它將會遇到更多的缺頁錯誤。

這一問題必須同縮並帶來的好處放在一起權衡。
採用簡單的標記—清掃技術,堆中的數據很可能變得更加破碎,從而導致程序的工作集(working set)所包含的頁面數量增加。如果工作集達到無法容納於主存儲器,缺頁率也會升高。

 

2.4 比較標記—清掃和節點複製算法
節點複製收集器最主要的缺點是必須把可用內存劃分爲兩個半區。隨着程序內存佔用率的升高,收集器的性能會不斷下降。虛擬存儲器可以減輕這些症狀:半區的大小和物理內存的大小相同(或更大),並且堆可以再必要的時候擴展。
標記-清掃收集器的性能隨着內存佔用率下降的速度,僅僅是節點複製收集器的一半。

節點複製算法的漸進複雜度比簡單的標記-清掃算法要小:它的複雜度正比於存活數據結構的大小,而不是整個堆(半區)的大小。

節點複製收集器必須追蹤並更新根集合和存活數據結構中的每一個指針,並把那些對象搬遷到Tospace中去。
標記—清掃收集器在標記階段追蹤指向存活數據結構的指針,並在清掃階段中線性地清掃整個堆。

 

2.5 需要考慮的問題

這一章所討論的實現都是幼稚簡單的。

1)需求
客戶程序能否容忍由內存管理器造成的中斷
引用計數算法的操作與用戶程序的指令交織在一起,在總體上提供了更平滑的響應。

2)即時性
第一、它允許變成垃圾的空間能夠立刻回收重用
第二、面嚮對象語言常常支持終結機制,可以再一個對象死亡時調用一個用戶定義的過程。終結機制最典型的範例是,在指向一個文件的最後一個引用銷燬時關閉這個文件。

3)環形數據結構

4)根和指針搜索
基於追蹤的垃圾收集器需要能夠找到程序中所有的根,而且可能需要找到存活數據結構中所有的指針。而像節點複製收集器這樣的搬遷式收集器,必須既能定位所有的根從而能夠追蹤到全部存活數據,又能找到存活數據結構中所有的指針從而能夠更新它們使之指向新德位置。

5)實現
在選擇垃圾算法時,除能否符合客戶程序和環境的需求之外,性能也是一個重要的因素。
性能可以通過多個方面衡量:給用戶程序操作帶來的開銷,在分配和收集上花費的時間,或是收集器直接佔用的空間和加在用戶數據上的額外的空間。

6)處理代價
引用計數與用戶程序耦合緊密,帶來兩個後果。
第一、引用計數給用戶程序的每一個指針操作都帶來了額外的開銷。
第二、簡單的、非分代式的追蹤式收集器並不會給客戶程序的操作帶來額外開銷。
引用計數收集器和標記—清掃收集器一般採用自由鏈表的某個變種來管理可用的自由空間池。因此存在堆內存破碎問題。內存碎片不但會弱化存活數據的局部性,而且會使分配可變大小的對象更加困難。

7)空間開銷

8)堆佔用率和收集器的退化

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章