[深入理解Java虛擬機器]第三章 垃圾收集器及相關引數

Coding-lover發表於2015-10-05

如果說收集演算法是記憶體回收的方法論,那麼垃圾收集器就是記憶體回收的具體實現。Java虛擬機器規範中對垃圾收集器應該如何實現並沒有任何規定,因此不同的廠商、不同版本的虛擬機器所提供的垃圾收集器都可能會有很大差別,並且一般都會提供引數供使用者根據自己的應用特點和要求組合出各個年代所使用的收集器。這裡討論的收集器基於JDK 1.7 Update 14之後的HotSpot虛擬機器 (在這個版本中正式提供了商用的G1收 集 器 ,之前G1仍處於實驗狀態 ),這個虛擬機器包含的所有收集器如圖3-5所示。

圖3-5展示了7種作用於不同分代的收集器,如果兩個收集器之間存在連線,就說明它們可以搭配使用。虛擬機器所處的區域,則表示它是屬於新生代收集器還是老年代收集器。接下來筆者將逐一介紹這些收集器的特性、基本原理和使用場景,並重點分析CMS和G1這兩款相對複雜的收集器,瞭解它們的部分運作細節。

在介紹這些收集器各自的特性之前,我們先來明確一個觀點:雖然我們是在對各個收集器進行比較,但並非為了挑選出一個最好的收集器。因為直到現在為止還沒有最好的收集器出現 ,更加沒有萬能的收集器,所以我們選擇的只是對具體應用最合適的收集器。這點不需要多加解釋就能證明:如果有一種放之四海皆準、任何場景下都適用的完美收集器存在,那HotSpot虛擬機器就沒必要實現那麼多不同的收集器了。

Serial收集器

Serial收集器是最基本、發展歷史最悠久的收集器,曾經 (在JDK 1.3.1之 前 )是虛擬機器新生代收集的唯一選擇。大家看名字就會知道,這個收集器是一個單執行緒的收集器,但它的“單執行緒”的意義並不僅僅說明它只會使用一個CPU或一條收集執行緒去完成垃圾收集工作, 更重要的是在它進行垃圾收集時,必須暫停其他所有的工作執行緒,直到它收集結束。“Stop The World”這個名字也許聽起來很酷,但這項工作實際上是由虛擬機器在後臺自動發起和自動完成的 ,在使用者不可見的情況下把使用者正常工作的執行緒全部停掉,這對很多應用來說都是難以接受的。讀者不妨試想一下,要是你的計算機每執行一個小時就會暫停響應5分鐘 ,你會有什麼樣的心情?圖3-6示意了Serial/Serial Old收集器的執行過程。

對於“Stop The World”帶給使用者的不良體驗,虛擬機器的設計者們表示完全理解,但也表示非常委屈:“你媽媽在給你打掃房間的時候,肯定也會讓你老老實實地在椅子上或者房間外待著 ,如果她一邊打掃,你一邊亂扔紙屑,這房間還能打掃完?”這確實是一個合情合理的矛盾,雖然垃圾收集這項工作聽起來和打掃房間屬於一個性質的,但實際上肯定還要比打掃房間複雜得多啊!

從JDK 1.3開始,一直到現在最新的JDK 1.7, HotSpot虛擬機器開發團隊為消除或者減少工作執行緒因記憶體回收而導致停頓的努力一直在進行著,從Serial收集器到Parallel收集器,再到 Concurrent Mark Sweep ( CMS ) 乃至GC收集器的最前沿成果Garbage First ( G1 ) 收集器,我們看到了一個個越來越優秀(也越來越複雜)的收集器的出現,使用者執行緒的停頓時間在不斷縮短,但是仍然沒有辦法完全消除(這裡暫不包括RTSJ中的收集器)。尋找更優秀的垃圾收集器的工作仍在繼續!

寫到這裡,筆者似乎已經把Serial收集器描述成一個“老而無用、食之無味棄之可惜”的雞肋 了 ,但實際上到現在為止,它依然是虛擬機器執行在Client模式下的預設新生代收集器。 它也有著優於其他收集器的地方:簡單而高效(與其他收集器的單執行緒比),對於限定單個CPU的環境來說,Serial收集器由於沒有執行緒互動的開銷,專心做垃圾收集自然可以獲得最高的單執行緒收集效率。在使用者的桌面應用場景中,分配給虛擬機器管理的記憶體一般來說不會很大 ,收集幾十兆甚至一兩百兆的新生代(僅僅是新生代使用的記憶體,桌面應用基本上不會再大了 ) ,停頓時間完全可以控制在幾十毫秒最多一百多毫秒以內 ,只要不是頻繁發生 ,這點停頓是可以接受的。所 以 ,Serial收集器對於執行在Client模式下的虛擬機器來說是一個很好的選擇。

ParNew收集器

ParNew收集器其實就是Serial收集器的多執行緒版本,除了使用多條執行緒進行垃圾收集之外 ,其餘行為包括Serial收集器可用的所有控制引數(例 如 : -XX : SurvivorRatio、 -XX : PretenureSizeThreshold、 -XX : HandlePromotionFailure等)、收集演算法、 Stop The World、物件分配規則、回收策略等都與Serial收集器完全一樣,在實現上,這兩種收集器也共用了相當多的程式碼。ParNew收集器的工作過程如圖3-7所示。

ParNew收集器除了多執行緒收集之外,其他與Serial收集器相比並沒有太多創新之處,但它卻是許多執行在Server模式下的虛擬機器中首選的新生代收集器,其中有一個與效能無關但很重要的原因是,除了Serial收集器外,目前只有它能與CMS收集器配合工作。在JDK 1.5時 期 ,HotSpot推出了一款在強互動應用中幾乎可認為有劃時代意義的垃圾收集器— CMS收 集器( Concurrent Mark Sweep),這款收集器是HotSpot虛擬機器中第一款真正意義上的併發(Concurrent) 收集器,它第一次實現了讓垃圾收集執行緒與使用者執行緒(基本上)同時工作,用前面那個例子的話來說,就是做到了在你的媽媽打掃房間的時候你還能一邊往地上扔紙屑。

不幸的是,CMS作為老年代的收集器,卻無法與JDK 1.4.0中已經存在的新生代收集器Parallel Scavenge配合工作 ,所以在JDK 1.5中使用CMS來收集老年代的時候,新生代只能選擇ParNew或者Serial收集器中的一個。ParNew收集器也是使用-XX : +UseConcMarkSweepGC 選項後的預設新生代收集器,也可以使用-XX : +UseParNewGC選項來強制指定它。

ParNew收集器在單CPU的環境中絕對不會有比Serial收集器更好的效果,甚至由於存線上程互動的開銷,該收集器在通過超執行緒技術實現的兩個CPU的環境中都不能百分之百地保證可以超越Serial收集器。當然 ,隨著可以使用的CPU的數量的增加,它對於GC時系統資源的有效利用還是很有好處的。它預設開啟的收集執行緒數與CPU的數量相同,在CPU非常多(譬如32個 ,現在CPU動輒就4核加超執行緒,伺服器超過32個邏輯CPU的情況越來越多了)的環境下,可以使用-XX : ParallelGCThreads引數來限制垃圾收集的執行緒數。

注意從ParNew收集器開始,後面還會接觸到幾款併發和並行的收集器。在大家可能產生疑惑之前,有必要先解釋兩個名詞:併發和並行。這兩個名詞都是併發程式設計中的概念, 在談論垃圾收集器的上下文語境中,它們可以解釋如下。

  • 並行( Parallel ) : 指多條垃圾收集執行緒並行工作,但此時使用者執行緒仍然處於等待狀態。
  • 併發(Concurrent) : 指使用者執行緒與垃圾收集執行緒同時執行(但不一定是並行的,可能會交替執行),使用者程式在繼續執行,而垃圾收集程式執行於另一個CPU上。

Parallel Scavenge收集器

Parallel Scavenge收集器是一個新生代收集器,它也是使用複製演算法的收集器,又是並行的多執行緒收集器……看上去和ParNew都一樣,那它有什麼特別之處呢?

Parallel Scavenge收集器的特點是它的關注點與其他收集器不同,CMS等收集器的關注點是儘可能地縮短垃圾收集時使用者執行緒的停頓時間,而Parallel Scavenge收集器的目標則是達到一個可控制的吞吐量(Throughput) 。所謂吞吐量就是CPU用於執行使用者程式碼的時間與CPU總消耗時間的比值,即吞吐量=執行使用者程式碼時間/ ( 執行使用者程式碼時間+垃圾收集時間),虛擬機器總共執行了100分鐘 ,其中垃圾收集花掉1分鐘 ,那吞吐量就是99% 。

停頓時間越短就越適合需要與使用者互動的程式,良好的響應速度能提升使用者體驗,而高吞吐量則可以高效率地利用CPU時間 ,儘快完成程式的運算任務,主要適合在後臺運算而不需要太多互動的任務。

Parallel Scavenge收集器提供了兩個引數用於精確控制吞吐量,分別是控制最大垃圾收集停頓時間的-XX : MaxGCPauseMillis引數以及直接設定吞吐量大小的-XX : GCTimeRatio引數。

MaxGCPauseMillis引數允許的值是一個大於0的毫秒數,收集器將盡可能地保證記憶體回收花費的時間不超過設定值。不過大家不要認為如果把這個引數的值設定得稍小一點就能使得系統的垃圾收集速度變得更快,GC停頓時間縮短是以犧牲吞吐量和新生代空間來換取的 :系統把新生代調小一些,收集300MB新生代肯定比收集500MB快吧 ,這也直接導致垃圾收集發生得更頻繁一些,原來10秒收集一次、每次停頓100毫 秒 ,現在變成5秒收集一次、每次停頓70毫秒。停頓時間的確在下降,但吞吐量也降下來了。

GCTimeRatio引數的值應當是一個大於0且小於100的整數 ,也就是垃圾收集時間佔總時間的比率,相當於是吞吐量的倒數。如果把此引數設定為19 ,那允許的最大GC時間就佔總時間的5% (即1/ ( 1+19 ) ) ,預設值為99,就是允許最大1% (即1/ ( 1+99))的垃圾收集時間。

由於與吞吐量關係密切, Parallel Scavenge收集器也經常稱為“吞吐量優先”收集器。除上述兩個引數之外, Parallel Scavenge收集器還有一個引數-XX : +UseAdaptiveSizePolicy值得關注。這是一個開關引數,當這個引數開啟之後,就不需要手工指定新生代的大小(-Xmn)、 Eden與Survivor區的比例( -XX : SurvivorRatio ) 、晉升老年代物件年齡( -XX : PretenureSizeThreshold ) 等細節引數了,虛擬機器會根據當前系統的執行情況收集效能監控資訊 ,動態調整這些引數以提供最合適的停頓時間或者最大的吞吐量,這種調節方式稱為GC
自途應的調節策略( GC Ergonomics ) 。如果讀者對於收集器運作原來不太瞭解,手工優化存在困難的時候,使用Parallel Scavenge收集器配合自適應調節策略,把記憶體管理的調優任務交給虛擬機器去完成將是一個不錯的選擇。只需要把基本的記憶體資料設定好(如-Xmx設定最大堆 ),然後使用MaxGCPauseMillis引數(更關注最大停頓時間)或GCTimeRatio (更關注吞吐量)引數給虛擬機器設立一個優化目標,那具體細節引數的調節工作就由虛擬機器完成了。自適應調節策略也是Parallel Scavenge收集器與ParNew收集器的一個重要區別。

Serial Old收集器

Serial Old是Serial收集器的老年代版本,它同樣是一個單執行緒收集器,使用“標記-整理”演算法。這個收集器的主要意義也是在於給Client模式下的虛擬機器使用。如果在Server模式下 ,那麼它主要還有兩大用途:一種用途是在JDK 1.5以及之前的版本中與Parallel Scavenge 收集器搭配使用 ,另一種用途就是作為CMS收集器的後備預案,在併發收集發生Concurrent Mode Failure時使用。這兩點都將在後面的內容中詳細講解。Serial Old收集器的工作過程如圖3-8所示。

需要說明一下, Parallel Scavenge收集器架構中本身有PS MarkSweep收集器來進行老年代收集,並非直接使用了Serial Old收集器,但是這個PS MarkSweep收集器與Serial Old的實現非常接近,所以在官方的許多資料中都是直接以Serial Old代替PS MarkSweep進行講解,這裡筆者也採用這種方式。

Parallel Old收集器

Parallel Old是Parallel Scavenge收集器的老年代版本,使用多執行緒和“標記-整理”演算法。這個收集器是在JDK 1.6中才開始提供的,在此之前,新生代的Parallel Scavenge收集器一直處於比較尷尬的狀態。原因是 ,如果新生代選擇了Parallel Scavenge收集器 ,老年代除了 Serial Old ( PS MarkSweep ) 收集器外別無選擇(還記得上面說過Parallel Scavenge收集器無法與CMS收集器配合工作嗎?)。由於老年代Serial Old收集器在服務端應用效能上的“拖累” ,使用了Parallel Scavenge收集器也未必能在整體應用上獲得吞吐量最大化的效果,由於單執行緒的老年代收集中無法充分利用伺服器多CPU的處理能力,在老年代很大而且硬體比較高階的環境中,這種組合的吞吐量甚至還不一定有ParNew加CMS的組合“給力”。

直到Parallel Old收集器出現後,“吞吐量優先”收集器終於有了比較名副其實的應用組合,在注重吞吐量以及CPU資源敏感的場合,都可以優先考慮Parallel Scavenge加Parallel Old 收集器。 Parallel Old收集器的工作過程如圖3-9所示。

CMS收集器

CMS ( Concurrent Mark Sweep )收集器是一種以獲取最短回收停頓時間為目標的收集器。目前很大一部分的Java應用集中在網際網路站或者B/S系統的服務端上,這類應用尤其重視服務的響應速度,希望系統停頓時間最短,以給使用者帶來較好的體驗。CMS收集器就非常符合這類應用的需求。

從名字(包含“Mark Sweep”)上就可以看出,CMS收集器是基於“標記一清除”演算法實現的 ,它的運作過程相對於前面幾種收集器來說更復雜一些,整個過程分為4個步驟,包括:

  • 初始標記( CMS initial mark )
  • 併發標記( CMS concurrent mark )
  • 重新標記( CMS remark )
  • 併發清除( CMS concurrent sweep )

其中 ,初始標記、重新標記這兩個步驟仍然需要“Stop The World”。初始標記僅僅只是標記一下GC Roots能直接關聯到的物件,速度很快,併發標記階段就是進行GC RootsTracing的過程 ,而重新標記階段則是為了修正併發標記期間因使用者程式繼續運作而導致標記產生變動的那一部分物件的標記記錄,這個階段的停頓時間一般會比初始標記階段稍長一些,但遠比並發標記的時間短。

由於整個過程中耗時最長的併發標記和併發清除過程收集器執行緒都可以與使用者執行緒一起工作,所以 ,從總體上來說,CMS收集器的記憶體回收過程是與使用者執行緒一起併發執行的。通過圖3-10可以比較清楚地看到CMS收集器的運作步驟中併發和需要停頓的時間。

CMS是一款優秀的收集器,它的主要優點在名字上已經體現出來了:併發收集、低停頓,Sun公司的一些官方文件中也稱之為併發低停頓收集器( Concurrent Low Pause Collector ) 。但是CMS還遠達不到完美的程度,它有以下3個明顯的缺點:

  • CMS收集器對CPU資源非常敏感。其實 ,面向併發設計的程式都對CPU資源比較敏感。在併發階段,它雖然不會導致使用者執行緒停頓,但是會因為佔用了一部分執行緒(或者說CPU資源)而導致應用程式變慢,總吞吐量會降低。CMS預設啟動的回收執行緒數是(CPU數量+3 ) / 4,也就是當CPU在4個以上時,併發回收時垃圾收集執行緒不少於25%的CPU資源,並且隨著CPU數量的增加而下降。但是當CPU不足4個(譬如2個)時 ,CMS對使用者程式的影響就可能變得很大,如果本來CPU負載就比較大,還分出一半的運算能力去執行收集器執行緒,就可能導致使用者程式的執行速度忽然降低了50% ,其實也讓人無法接受。為了應付這種情況, 虛擬機器提供了一種稱為“增量式併發收集器” (Incremental Concurrent Mark Sweep/i-CMS )的CMS收集器變種,所做的事情和單CPU年代PC機作業系統使用搶佔式來模擬多工機制的思想一樣,就是在併發標記、清理的時候讓GC執行緒、使用者執行緒交替執行,儘量減少GC執行緒的獨佔資源的時間,這樣整個垃圾收集的過程會更長,但對使用者程式的影響就會顯得少一些,也就是速度下降沒有那麼明顯。實踐證明,增量時的CMS收集器效果很一般,在目前版本中 ,i-CMS已經被宣告為“deprecated”,即不再提倡使用者使用。
  • CMS收集器無法處理浮動垃圾( Floating Garbage ) ,可能出現“Concurrent Mode Failure” 失敗而導致另一次Full GC的產生。由於CMS併發清理階段使用者執行緒還在執行著,伴隨程式執行自然就還會有新的垃圾不斷產生,這一部分垃圾出現在標記過程之後,CMS無法在當次收集中處理掉它們,只好留待下一次GC時再清理掉。這一部分垃圾就稱為“浮動垃圾”。也是由於在垃圾收集階段使用者執行緒還需要執行,那也就還需要預留有足夠的記憶體空間給使用者執行緒使用,因此CMS收集器不能像其他收集器那樣等到老年代幾乎完全被填滿了再進行收集 ,需要預留一部分空間提供併發收集時的程式運作使用。在JDK 1.5的預設設定下 ,CMS收集器當老年代使用了68%的空間後就會被啟用,這是一個偏保守的設定,如果在應用中老年代增長不是太快,可以適當調高引數-XX : CMSMtiatingOccupancyFraction的值來 提高觸發百分比,以便降低記憶體回收次數從而獲取更好的效能,在JDK 1.6中 ,CMS收集器的啟動閾值已經提升至92%。要是CMS執行期間預留的記憶體無法滿足程式需要,就會出現一 次“Concurrent Mode Failure”失敗,這時虛擬機器將啟動後備預案:臨時啟用Serial Old收集器來 重新進行老年代的垃圾收集,這樣停頓時間就很長了。所以說引數-XX : CM SlnitiatingOccupancyFraction設定得太高很容易導致大量“Concurrent Mode Failure”失敗,效能反而降低。
  • 還有最後一個缺點,在本節開頭說過,CMS是一款基於“標記一清除”演算法實現的收集器 ,如果讀者對前面這種演算法介紹還有印象的話,就可能想到這意味著收集結束時會有大量空間碎片產生。空間碎片過多時,將會給大物件分配帶來很大麻煩,往往會出現老年代還有很大空間剩餘,但是無法找到足夠大的連續空間來分配當前物件,不得不提前觸發一次Full GC。為了解決這個問題,CMS收集器提供了一個-XX : +UseCMSConpactAtFullCollection開關引數(預設就是開啟的),用於在CMS收集器頂不住要進行FullGC時開啟記憶體碎片的合併整理過程,記憶體整理的過程是無法併發的,空間碎片問題沒有了,但停頓時間不得不變長。 虛擬機器設計者還提供了另外一個引數-XX : CMSFullGCsBeforeConpaction,這個引數是用於設定執行多少次不壓縮的Full GC後 ,跟著來一次帶壓縮的(預設值為0 ,表示每次進入Full GC時都進行碎片整理)。

G1收集器

G1 (Garbage-First)收集器是當今收集器技術發展的最前沿成果之一,早在JDK 1.7剛剛確立專案目標,Sun公司給出的JDK 1.7 RoadMap裡面,它就被視為JDK 1.7中HotSpot虛擬機器的一個重要進化特徵。從JDK 6u14中開始就有Early Access版本的G1收集器供開發人員實驗、試用,由此開始G1收集器的“Experimental”狀態持續了數年時間,直至JDK 7u4 , Sun公 司才認為它達到足夠成熟的商用程度,移除了“Experimental”的標識。

G1是一款面向服務端應用的垃圾收集器。HotSpot開發團隊賦予它的使命是(在比較長期的)未來可以替換掉JDK 1.5中釋出的CMS收集器。與其他GC收集器相比,G1具備如下特點。

  • 並行與併發:G1能充分利用多CPU、多核環境下的硬體優勢,使用多個CPU ( CPU或者CPU核心)來縮短Stop-The-World停頓的時間,部分其他收集器原本需要停頓Java執行緒執行的GC動作,G1收集器仍然可以通過併發的方式讓Java程式繼續執行。
  • 分代收集:與其他收集器一樣,分代概念在G1中依然得以保留。雖然G1可以不需要其他收集器配合就能獨立管理整個GC堆 ,但它能夠採用不同的方式去處理新建立的物件和已經存活了一段時間、熬過多次GC的舊物件以獲取更好的收集效果。
  • 空間整合:與CMS的“標記一清理”演算法不同,G1從整體來看是基於“標記一整理”演算法實現的收集器,從區域性(兩個Region之間)上來看是基於“複製”演算法實現的,但無論如何,這兩種演算法都意味著G1運作期間不會產生記憶體空間碎片,收集後能提供規整的可用記憶體。這種特性有利於程式長時間執行,分配大物件時不會因為無法找到連續記憶體空間而提前觸發下一次GC。
  • 可預測的停頓:這是G1相對於CMS的另一大優勢,降低停頓時間是G1和CMS共同的關注點 ,但G1除了追求低停頓外 ,還能建立可預測的停頓時間模型 ,能讓使用 者明確指定在一個長度為M毫秒的時間片段內,消耗在垃圾收集上的時間不得超過N毫秒 ,這幾乎已經是實時Java ( RTSJ ) 的垃圾收集器的特徵了。

在G1之前的其他收集器進行收集的範圍都是整個新生代或者老年代,而G1不再是這樣。使用G1收集器時,Java堆的記憶體佈局就與其他收集器有很大差別,它將整個Java堆劃分為多個大小相等的獨立區域(Region ) , 雖然還保留有新生代和老年代的概念,但新生代和老年代不再是物理隔離的了,它們都是一部分Region ( 不需要連續)的集合。

G1收集器之所以能建立可預測的停頓時間模型,是因為它可以有計劃地避免在整個Java 堆中進行全區域的垃圾收集。G1跟蹤各個Region裡面的垃圾堆積的價值大小(回收所獲得的空間大小以及回收所需時間的經驗值),在後臺維護一個優先列表,每次根據允許的收集時間 ,優先回收價值最大的Region ( 這就是 Garbage-First名稱的來由)。這種使用Region劃分記憶體空間以及有優先順序的區域回收方式,保證了G1收集器在有限的時間內可以獲取儘可能高的收集效率。

G1把記憶體“化整為零” 的思路 ,理解起來似乎很容易,但其中的實現細節卻遠遠沒有想象中那樣簡單,否則也不會從2004年Sun實驗室發表第一篇G1的論文開始直到今天(將近10年時間)才開發出G1的商用版。筆者以一個細節為例:把Java堆分為多個Region後,垃圾收集是否就真的能以Region為單位進行?聽起來順理成章,再仔細想想就很容易發現問題所在 :Region不可能是孤立的。一個物件分配在某個Region中 ,它並非只能被本Region中的其他物件引用,而是可以與整個Java堆任意的物件發生引用關係。那在做可達性判定確定物件 是否存活的時候,豈不是還得掃描整個Java堆才能保證準確性?這個問題其實並非在G1中才有 ,只是在G1中更加突出而已。在以前的分代收集中,新生代的規模一般都比老年代要小許多 ,新生代的收集也比老年代要頻繁許多,那回收新生代中的物件時也面臨相同的問題,如果回收新生代時也不得不同時掃描老年代的話,那麼Minor GC的效率可能下降不少。

在G1收集器中,Region之間的物件引用以及其他收集器中的新生代與老年代之間的物件引用 ,虛擬機器都是使用Remembered Set來避免全堆掃描的。G1中每個Region都有一個與之對應的Remembered Set,虛擬機器發現程式在對Reference型別的資料進行寫操作時,會產生一個 Write Barrier暫時中斷寫操作,檢查Reference引用的物件是否處於不同的Region之中 (在分代的例子中就是檢查是否老年代中的物件引用了新生代中的物件),如果是,便通過CardTable把相關引用資訊記錄到被引用物件所屬的Region的Remembered Set之中。當進行記憶體回收時,在GC根節點的列舉範圍中加入Remembered Set即可保證不對全堆掃描也不會有遺漏。

如果不計算維護Remembered Set的操作,G1收集器的運作大致可劃分為以下幾個步驟:

  • 初始標記( Initial Marking )
  • 併發標記( Concurrent Marking )
  • 最終標記( Final Marking )
  • 篩選回收( Live Data Counting and Evacuation )

對CMS收集器運作過程熟悉的讀者,一定已經發現G1的前幾個步驟的運作過程和CMS 有很多相似之處。初始標記階段僅僅只是標記一下GC Roots能直接關聯到的物件,並且修改 TAMS (Next Top at Mark Start)的值,讓下一階段使用者程式併發執行時,能在正確可用的Region中建立新物件,這階段需要停頓執行緒,但耗時很短。併發標記階段是從GC Root開始對堆中物件進行可達性分析,找出存活的物件,這階段耗時較長,但可與使用者程式併發執行。而最終標記階段則是為了修正在併發標記期間因使用者程式繼續運作而導致標記產生變動的那一部分標記記錄,虛擬機器將這段時間物件變化記錄線上程Remembered Set Logs裡面,最終標記階段需要把Remembered Set Logs的資料合併到Remembered Set中,這階段需要停頓執行緒,但是可並行執行。最後在篩選回收階段首先對各個Region的回收價值和成本進行排序, 根據使用者所期望的GC停頓時間來制定回收計劃,從Sun公司透露出來的資訊來看,這個階段其實也可以做到與使用者程式一起併發執行,但是因為只回收一部分Region,時間是使用者可控制的,而且停頓使用者執行緒將大幅提高收集效率。通過圖3-11可以比較清楚地看到G1收集器的運作步驟中併發和需要停頓的階段。

由於目前G1成熟版本的釋出時間還很短,G1收集器幾乎可以說還沒有經過實際應用的考驗 ,網路上關於G1收集器的效能測試也非常貧乏,到目前為止 ,筆者還沒有 搜尋到有關的生產環境下的效能測試報告。強調“生產環境下的測試報告”是因為對於垃圾收集器來說,僅僅通過簡單的Java程式碼寫個Microbenchmark程式來建立、移除Java物件,再用-XX :+PrintGCDetails等引數來檢視GC日誌是很難做到準確衡量其效能的。因此,關於G1收集器的效能部分,筆者引用了Sun實驗室的論文《Garbage-First Garbage Collection》中的一段測試資料。

Sun給出的Benchmark的執行硬體為Sun V880伺服器(8x750MHz UltraSPARC DI CPU、 32G記憶體、 Solaris 10作業系統)。執行軟體有兩個,分別為SPECjbb (模擬商業資料庫應用 ,堆中存活物件約為165MB , 結果反映吐量和最長事務處理時間)和telco (模擬電話應答服務應用,堆中存活物件約為100MB , 結果反映系統能支援的最大吞吐量)。為了便於對比 ,還收集了一組使用ParNew+CMS收集器的測試資料。所有測試都配置為與CPU數量相同的8條GC執行緒。

在反應停頓時間的軟實時目標( Soft Real-Time Goal)測試中,橫向是兩個測試軟體的 時間片段配置,單位是毫秒,以 (X/Y ) 的形式表示,代表在Y毫秒內最大允許GC時間為X毫秒 (對於CMS收集器 ,無法直接指定這個目標,通過調整分代大小的方式大致模擬)。縱向是兩個軟體在對應配置和不同的.Java堆容量下的測試結果,V%、avgV%和wV% 分別代表 的含義如下。

V% : 表示測試過程中,軟實時目標失敗的概率,軟實時目標失敗即某個時間片段中實 際GC時間超過了允許的最大GC時間。
avgV% :表示在所有實際GC時間超標的時間片段裡,實際GC時間超過最大GC時間的平 均百分比(實際GC時間減去允許最大GC時間,再除以總時間片段)。
wV% :表示在測試結果最差的時間片段裡,實際GC時間佔用執行時間的百分比。

測試結果見表3-1。

從表3-1所示的結果可見,對於telco來說,軟實時目標失敗的概率控制在0.5%〜0.7% 之間,SPEQjbb就要差一些,但也控制在2%〜5%之間,概率隨著(X /Y )的比值減小而增加。 另一方面,失敗時超出允許GC時間的比值隨著總時間片段增加而變小(分母變大了),在( 100/200 ) 、512MB的配置下,G1收集器出現了某些時間片段下100%時間在進行GC的最壞情況。而相比之下,CMS收集器的測試結果就要差很多,3種Java堆容量下都出現了 100% 時間進行GC的情況。

在吞吐量測試中,測試資料取3次SPEQjbb和15次telco的平均結果如圖3-12所示。在SPECjbb的應用下,各種配置下的G1收集器表現出了一致的行為,吞吐量看起來只與允許最 大GC時間成正比關係,而在telco的應用中,不同配置對吞吐量的影響則顯得很微弱。與 CMS收集器的吞吐量對比可以看到,在SPECjbb測試中,在堆容量超過768MB時 ,CMS收集 器有5%〜10%的優勢,而在telco測試中,CMS的優勢則要小一些,只有3%〜4%左右。

在更大規模的生產環境下,筆者引用一段在StackOverflow.com上看到的經驗與讀者分享:“我在一個真實的、較大規模的應用程式中使用過G1 :大約分配有60〜70GB記憶體,存活 物件大約在2 0〜50GB之間。伺服器執行Linux作業系統,JDK版本為6u22。G1與PS/PS Old相 比 ,最大的好處是停頓時間更加可控、可預測,如果我在PS中設定一個很低的最大允許GC 時間,譬如期望50毫秒內完成GC ( -XX : MaxGCPauseMmis=50 ) ,但在65GB的Java堆下有 可能得到的直接結果是一次長達30秒至2分鐘的漫長的Stop-The-World過 程 ;而G1與CMS相 比 ,雖然它們都立足於低停頓時間,CMS仍然是我現在的選擇,但是隨著Oracle對G1的持續 改 進 ,我相信G1會是最終的勝利者。如果你現在採用的收集器沒有出現問題,那就沒有任何 理由現在去選擇G1,如果你的應用追求低停頓,那G1現在已經可以作為一個可嘗試的選擇 ,如果你的應用追求吞吐量,那G1並不會為你帶來什麼特別的好處”。

垃圾收集器引數總結

JDK 1.7中的各種垃圾收集器到此已全部介紹完畢,在描述過程中提到了很多虛擬機器非穩定的執行引數,在表3-2中整理了這些引數供讀者實踐時參考。


相關文章