閱讀《The Garbage Collection Handbook》第3章的Mark-Compaction垃圾回收算法時,對於Threaded Compaction總是無法理解。於是特意花了一些時間,總算是入門了,也搞懂了它的思想,寫出來總結一下。如果文中有錯誤,還請指正。
Compaction算法概述
簡單來說,Mark-Compaction算法做兩件事情:mark和compaction。mark的工作是標記堆上的存活對象;compaction的工作是:1、把這些存活對象移動到該去的位置上;2、修改引用,令它們指向新的地址。既然要移動存活對象,那么移動的順序有三種:1、任意順序;2、滑動順序;3、線性化順序。通常來說,第一種順序實現起來最簡單,但會搞亂堆上對象之間的排列順序,極大地傷害預取(prefetching)的效果,從而破壞了原有的局部性。在實踐中幾乎沒有收集器使用這種方式;第二種順序通常被認為是好的實現,因為它維持了堆上對象原有的順序,僅僅是把對象之間的空洞(hole)擠壓出來而已,因而沒有拉低緩存的效果;3、第三種順序可以認為是前一種的升級版。它是刻意地修改堆上對象的順序,將未來可能一起使用的對象排列在一起,實現更好的空間局部性。一般來說,我們平時用到的收集器大多還是實現第二種順序。
Compaction算法比較
首先,Mark-Compaction算法和Mark-Sweep、Copying和Reference Counting並稱為四大基礎垃圾回收算法。Java中我們熟知的並行收集器Parallel Collector(也稱吞吐量收集器,throughput collector)中老年代的收集算法就是采用了Mark-Compaction的思想。當然了,它是多個線程並行地進行垃圾回收,因此名字是parallel collector。在閱讀《The Garbage Collection Handbook》一書時,Two-Finger和Lisp2的Compaction算法理解起來相對容易些,唯獨這個Threaded Compaction算法晦澀難懂。而該算法相比於前兩種算法而言,有諸多優勢,因此絕對值得我們好好研究下。下表總結了三種算法的優缺點:
算法 | 是否需要額外空間 | 遍歷堆的次數 | 對象大小 | 順序 |
Two-Finger | 無需任何輔助空間 | 2 | 只能收集固定對象大小的堆 | 任意(隨機) |
Lisp2 | 要求對象槽能夠容納一個指針長度的數據 | 3 | 不要求對象大小固定 | 滑動(Sliding) |
Threaded | 要求對象頭部容納指針 | 2 | 不要求對象大小固定 | 滑動 |
如果排除掉並行收集算法的話, 實際上Compaction算法還有一類比較終極的收集器:Compressor收集器。它比Threaded算法還要好。不過鑒於它不屬於本文的研究范圍,這里就不羅列它的特性了。
Jonker算法特性
就像上表展示的那樣,Jonker算法無需額外的堆外存儲空間來保存數據——事實上,的確有些算法或實現需要side table或bitmap(或bytemap)來保存數據。我們前面說過,Compaction算法都是要移動對象的,那么如果算法本身不需要額外的存儲空間,那么就無法保存每個對象的新地址。那么,算法怎么知道要把對象移動到哪里呢?其實,算法只有在訪問到對象的時候才能知道它要被移動到哪里。后面我們來看下它是怎么做到的。
Jonker算法也成為Threaded Compaction算法。這里的thread不是線程的意思,而是表示把多個對象通過指針串聯在一起的過程。Threading的主要思想是:對於每個對象O,構造一個指針列表,里面的每個指針都指向O。這些指針就被稱為串聯指針(threaded pointer)。值得一提的是,這里的指針是指規范指針(canonical pointer),而不是內部指針(interior pointer)。前者是指指向對象首地址,也就是頭部的指針(我們假設內存布局中頭部永遠位於對象的最前部);后者是指指向對象某個字段或槽(slot)的指針。由於不借助任何輔助空間,threaded pointer會被保存在對象的頭部——這不算太“過分”,畢竟通常來說header word都足以容納一個地址信息。除此之外,這個算法還要求header中保存的地址信息要能和其他數據區分開來——這個要求有點困難了。
下面舉一個例子展示下什么叫threading。下圖(我直接使用了書中的例子)是一個4個對象的堆,其中A、B和C都引用了N:
當threading結束的時候,所有指向N的指針都被逆轉了方向,全部從對象N出發,依次串接在一起,如下圖所示:
具體的threading代碼很簡單,大約只有以下幾行:
thread(ref): if *ref != null *ref, **ref <- **ref, ref
它的主要功能是將ref指針逆轉,讓ref指向*ref指向的對象,而讓*ref指向ref所在對象。如果拿上面的例子來說,當我們按照A、B、C和N的順序遍歷堆的話,那么第一個調用thread方法的ref就是A,*ref就是N的地址,**ref實際上是對象N的header數據。那么執行完thread之后,A中將保存N的header頭部數據,而N指向A,如下圖所示:
之后,算法遍歷B時,整個堆上的引用關系將被調整為:
同理,遍歷C時繼續串接指針:
至此,A、B和C引用N的三個指針全部被串接在一起。現在算法可以通過N來訪問到A、B和C,而且N的頭部數據被搬到了A的字段中。串聯好了指針之后,下面要更新指針指向的新地址N'。Jonker算法提供了一個update子函數用於將ref指針串聯的所有指針全部unthread,並指向方法提供的第二個參數addr處。代碼如下:
update(ref, addr): tmp = *ref while isReference(tmp) *tmp, tmp = addr, *tmp *ref = tmp
結尾處的*ref = tmp是為了將頭部info數據恢復到ref所在的對象中。
下面使用一個圖來說thread + update的操作流程:
第一步我們之前解釋過了,執行完thread之后所有指向ref的指針全部被逆向串聯在一起並通過ref可以訪問到。第二步是執行update(ref, addr)或update(ref, ref'),令之前指向ref的所有指針全部指到ref'或addr處。這樣算法就實現了對ref對象compaction操作的重要一步:更新引用到前向地址,剩下的工作就是將ref對象移動到ref’所在的地址上。
第一遍遍歷
事實上,Jonker算法第一遍遍歷堆的工作就是這些,即從GC Roots開始,遍歷堆上的所有對象,依次串聯它們,如果某對象是存活對象,則調用update方法執行引用指向的調整。updateForwardReferences方法就是第一遍遍歷堆的邏輯實現方法,代碼如下:
updateForwardReferences(): for each field in Roots thread(*field) free = HeapStart scan = HeapStart while scan <= HeapEnd if isMarked(scan) update(scan, free) for each field in Pointers(scan) thread(field) free += size(scan) scan += size(scan)
這里的GC Roots通常是指從寄存器、棧上變量開始的引用,而HeapStart和HeapEnd分別對應於堆的起始地址和結束地址。由於我們要做compaction,我們通常會假設compaction會將存活對象全部擠壓到從HeapStart開始的區域,這是free字段被賦值為HeapStart的原因。整個過程就像我剛才所說,基本上是thread + update的操作。我就不詳細展開了。
第二遍遍歷
第一遍遍歷解決了調整compaction之后引用地址發生變化的問題,但沒有執行compaction最關鍵的操作:移動對象。因此Jonker算法還需要做第二遍遍歷進行對象的移動。下圖展示了一個執行完第一遍遍歷之后的堆分布:
在ref之后的指針引用被稱為后向指針(backwards pointer)。第一遍遍歷結束之后,所有對象的后向指針全部也都串接在一起了,因此這一步的工作就是更新這些后向指針的引用地址,然后把對象移動到新的地址上去,如圖所示:
第二遍遍歷的方法叫updateBackwardReferences。對於對象ref而言,它會將所有前向指針對象連同它自己全部compact到HeapStart開始的區域,之后調整后向指針對象引用新的地址ref'。代碼如下:
updateBackwardReferences(): free = HeapStart scan = HeapStart while scan <= HeapEnd if isMarked(scan) update(scan, free) move(scan, free) free += size(scan) scan += size(scan)
基本上,這個方法只做兩件事情:更新地址引用以及移動對象。
總結
總體而言,Jonker算法要遍歷堆兩次,但無需額外的輔助空間,同時支持不同大小對象構成的堆。最重要的是,它能保持堆上原有的對象順序,因而具有很好的局部性。要說它的缺陷,能想到的是算法需要訪問對象很多次,另一個是該算法要求對象頭部要能夠明顯區分出指針數據和其他數據。如果沒有編譯器和Runtime的支持,這個要求有時候是非常難實現的。不過,Jonker算法依然不失為一個非常優秀的Compaction算法。事實上,公認更加優秀的Compressor算法多多少少也有它的影子。