1. 程式人生 > >併發垃圾收集器(CMS)為什麼沒有采用標記-整理演算法來實現?

併發垃圾收集器(CMS)為什麼沒有采用標記-整理演算法來實現?

併發垃圾收集器(CMS)為什麼沒有采用標記整理-演算法來實現,而是採用的標記-清除演算法?

分代式GC裡,年老代常用mark-sweep;或者是mark-sweep/mark-compact的混合方式,一般情況下用mark-sweep,統計估算碎片量達到一定程度時用mark-compact。這是因為傳統上大家認為年老代的物件可能會長時間存活且存活率高,或者是比較大,這樣拷貝起來不划算,還不如採用就地收集的方式。Mark-sweepmark-compactcopying這三種基本演算法裡,只有mark-sweep是不移動物件(也就是不用拷貝)的,所以選用mark-sweep。 

簡要對比三種基本演算法: 


mark-sweepmark-compactcopying
速度中等最慢最快
空間開銷少(但會堆積碎片)少(不堆積碎片)通常需要活物件的2倍大小(不堆積碎片)
移動物件?


關於時間開銷: 
mark-sweep:mark階段與活物件的數量成正比,sweep階段與整堆大小成正比 
mark-compact:mark階段與活物件的數量成正比,compact階段與活物件的大小成正比 
copying:與活物件大小成正比 

如果把mark、sweep、compact、copying這幾種動作的耗時放在一起看,大致有這樣的關係: 
compaction >= copying > marking > sweeping 

還有 marking + sweeping > copying 
(雖然compactiont與copying都涉及移動物件,但取決於具體演算法,compact可能要先計算一次物件的目標地址,然後修正指標,然後再移動物件;copying則可以把這幾件事情合為一體來做,所以可以快一些。 
另外還需要留意GC帶來的開銷不能只看collector的耗時,還得看allocator一側的。如果能保證記憶體沒碎片,分配就可以用pointer bumping方式,只有挪一個指標就完成了分配,非常快;而如果記憶體有碎片就得用freelist之類的方式管理,分配速度通常會慢一些。) 

在分代式假設中,年輕代中的物件在minor GC時的存活率應該很低,這樣用copying演算法就是最合算的,因為其時間開銷與活物件的大小成正比,如果沒多少活物件,它就非常快;而且young gen本身應該比較小,就算需要2倍空間也只會浪費不太多的空間。 

而年老代被GC時物件存活率可能會很高,而且假定可用剩餘空間不太多,這樣copying演算法就不太合適,於是更可能選用另兩種演算法,特別是不用移動物件的mark-sweep演算法。 

不過HotSpot VM中除了CMS之外的其它收集器都是會移動物件的,也就是要麼是copying、要麼是mark-compact的變種。 

================================================================ 

HotSpot VM的serial GC(UseSerialGC)、parallel GC(UseParallelGC)中,只有full GC會收集年老代(實際上收集整個GC堆,包括年老代在內)。它用的演算法是mark-compact("Mark-Compact Old Object Collector"那一節),具體來說是典型的單執行緒(序列)的LISP 2演算法。雖然在HotSpot VM的原始碼裡,這個full GC的實現類叫做MarkSweep,而許多資料上都把它稱為mark-sweep-compact,但實際上它是典型的mark-compact而不是mark-sweep,請留意不要弄混了。出現這種情況是歷史原因,十幾二十年前GC的術語還沒固定到幾個公認的用法時mark-sweep-compact和mark-compact說的是一回事。 

我不太清楚當初HotSpot VM為何選擇先以mark-compact演算法來實現full GC,而不像後來微軟的CLR那樣先選擇使用mark-sweep為基本演算法來實現Gen 2 GC。但其背後的真相未必很複雜: 
HotSpot VM的前身是Strongtalk VM,它的full GC也是mark-compact演算法的,雖說具體演算法跟HotSpot VM的不同,是一種threaded compaction演算法。這種演算法比較省空間,但限制也挺多,實現起來比較繞彎,所以後來出現的HotSpot才改用了更簡單直觀的LISP 2演算法吧,而這個決定又進一步在V8上得到體現。 
而Strongtalk VM的前身是Self VM,同樣使用mark-compact演算法來實現full GC。可以看到mark-compact是這一系列VM一脈相承的,一直延續到更加新的Google V8也是如此。或許當初規劃HotSpot VM時也沒想那麼多就先繼承下了其前身的特點。 

如果硬要猜為什麼,那一個合理的推斷是:如果不能整理碎片,長時間執行的程式終究會遭遇記憶體碎片化,導致記憶體空間的浪費和記憶體分配速度下降的問題;要解決這個問題就得要能整理記憶體。如果決定要根治碎片化問題,那麼可以直接選用mark-compact,或者是主要用mark-sweep外加用mark-compact來備份。顯然直接選用mark-compact實現起來更簡單些。所以就選它了。 
(而CLR就選擇了不根治碎片化問題。所有可能出問題的地方都最終會出問題,於是現在就有很多.NET程式受碎片化的困擾) 

後來HotSpot VM有了parallel old GC(UseParallelOldGC),這個用的是多執行緒並行版的mark-compact演算法。這個演算法具體應該叫什麼名字我說不上來,因為並沒有專門的論文去描述它,而大家有許多不同的辦法去並行化LISP 2之類的經典mark-compact演算法,各自取捨的細節都不同。無論如何,這裡要關注的只是它用的也是mark-compact而不是mark-sweep演算法。 

================================================================ 

那CMS為啥選用mark-sweep為基本演算法將其併發化,而不跟HotSpot VM的其它GC一樣用會移動物件的演算法呢? 

一個不算原因的原因是:當時設計和實現CMS是在Sun的另外一款JVM,Exact VM(EVM)上做的。後來EVM專案跟HotSpot VM競爭落敗,CMS才從EVM移植到HotSpot VM來。因此它沒有HotSpot VM的初始血緣。 <- 真的算不上原因(逃 

真正的原因請參考CMS的原始論文:A Generational Mostly-concurrent Garbage Collector(可惡,Oracle Labs的連結掛了。用CiteSeerX的連結吧) 

把GC之外的程式碼(主要是應用程式的邏輯)叫做mutator,把GC的程式碼叫做collector。兩者之間需要保持同步,這樣才可以保證兩者所觀察到的物件圖是一致的。 

如果有一個序列、不併發、不分代、不增量式的collector,那麼它在工作的時候總是能觀察到整個物件圖。因而它跟mutator之間的同步方式非常簡單:mutator一側不用做任何特殊的事情,只要在需要GC時同步呼叫collector即可,就跟普通函式呼叫一樣。 

如果有一個分代式的,或者增量式的collector,那它在工作的時候就只會觀察到整個物件圖的一部分;它觀察不到的部分就有可能與mutator產生不一致,於是需要mutator配合:它與mutator之間需要額外的同步。Mutator在改變物件圖中的引用關係時必須執行一些額外程式碼,讓collector記錄下這些變化。有兩種做法,一種是write barrier,一種是read barrier。 
Write barrier就是當改寫一個引用時: 
Java程式碼  收藏程式碼
  1. a.x = b  

插入一塊額外的程式碼,變成: 
C程式碼  收藏程式碼
  1. write_barrier(a, &(a->x), b);  
  2. a->x = b;  

Read barrier就是當讀取一個引用時: 
Java程式碼  收藏程式碼
  1. b = a.x  

插入一塊額外的程式碼,變成: 
C程式碼  收藏程式碼
  1. read_barrier(&(a->x));  
  2. b = a->x;  


通常一個程式裡對引用的讀遠比對引用的寫要更頻繁,所以通常認為read barrier的開銷遠大於write barrier,所以很少有GC使用read barrier。 
如果只用write barrier,那麼“移動物件”這個動作就必須要完全暫停mutator,讓collector把物件都移動好,然後把指標都修正好,接下來才可以恢復mutator的執行。也就是說collector“移動物件”這個動作無法與mutator併發進行。 

如果用到了read barrier(雖少見但不是不存在,例如Azul C4 Collector),那移動物件就可以單個單個的進行,而且不需要立即修正所有的指標,所以可以看作整個過程collector都與mutator是併發的。 

CMS沒有使用read barrier,只用了write barrier。這樣,如果它要選用mark-compact為基本演算法的話,就只有mark階段可以併發執行(其中root scanning階段仍然需要暫停mutator,這是initial marking;後面的concurrent marking才可以跟mutator併發執行),然後整個compact階段都要暫停mutator。回想最初提到的:compact階段的時間開銷與活物件的大小成正比,這對年老代來說就不划算了。 
於是選用mark-sweep為基本演算法就是很合理的選擇:mark與sweep階段都可以與mutator併發執行。Sweep階段由於不移動物件所以不用修正指標,所以不用暫停mutator。 

(題外話:但現實中我們仍然可以看到以mark-compact為基礎演算法的增量式/併發式年老代GC。例如Google V8裡的年老代GC就可以把marking階段拆分為非併發的initial marking和增量式的incremental marking;但真正比較耗時的compact階段仍然需要完全暫停mutator。它要降低暫停時間就只能想辦法在年老代內進一步選擇其中一部分來做compaction,而不是整個年老代一口氣做compaction。這在V8裡也已經有實現,叫做incremental compaction。再繼續朝這方向發展的話最終會變成region-based collector,那就跟G1類似了。) 

那碎片堆積起來了怎麼辦呢?HotSpot VM裡CMS只負責併發收集年老代(而不是整個GC堆)。如果併發收集所回收到的空間趕不上分配的需求,就會回退到使用serial GC的mark-compact演算法做full GC。也就是mark-sweep為主,mark-compact為備份的經典配置。但這種配置方式也埋下了隱患:使用CMS時必須非常小心的調優,儘量推遲由碎片化引致的full GC的發生。一旦發生full GC,暫停時間可能又會很長,這樣原本為低延遲而選擇CMS的優勢就沒了。 

所以新的Garbage-First(G1)GC就回到了以copying為基礎的演算法上,把整個GC堆劃分為許多小區域(region),通過每次GC只選擇收集很少量region來控制移動物件帶來的暫停時間。這樣既能實現低延遲也不會受碎片化的影響。 
(注意:G1雖然有concurrent global marking,但那是可選的,真正帶來暫停時間的工作仍然是基於copying演算法而不是mark-compact的)

http://hllvm.group.iteye.com/group/topic/38223#post-248757