JVM G1垃圾收集器

SaiW-n_n-發表於2017-05-17
Garbage-First(後文簡稱G1)收集器是當今收集器技術發展的最前沿成果,在Sun公司給出的JDK RoadMap裡面,它被視作JDK 7的HotSpot VM 的一項重要進化特徵。從JDK 6u14中開始就有Early Access版本的G1收集器供開發人員實驗、試用,雖然在JDK 7正式版釋出時,G1收集器仍然沒有擺脫“Experimental”的標籤,但是相信不久後將會有一個成熟的商用版本跟隨某個JDK 7的更新包釋出出來。
  因版面篇幅限制,筆者行文過程中假設讀者對HotSpot其他收集器(例如CMS)及相關JVM記憶體模型已有基本的瞭解,涉及到基礎概念時,沒有再延伸介紹,讀者可參考相關資料。

G1收集器的特點 
  G1是一款面向服務端應用的垃圾收集器,Sun(Oracle)賦予它的使命是(在比較長期的)未來可以替換掉JDK 5中釋出的CMS(Concurrent Mark Sweep)收集器,與其他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把記憶體“化整為零”的思路,理解起來似乎很容易理解,但其中的實現細節卻遠遠沒有現象中簡單,否則也不會從04年Sun實驗室發表第一篇G1的論文拖至今將近8年時間都還沒有開發出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,時間是使用者可控制的,而且停頓使用者執行緒將大幅提高收集效率。通過圖1可以比較清楚地看到G1收集器的運作步驟中併發和需要停頓的階段。


圖1 G1收集器執行示意圖

G1收集器的實際效能 
  由於目前還沒有成熟的版本,G1收集器幾乎可以說還沒有經過實際應用的考驗,網上關於G1收集器的效能測試非常貧乏,筆者沒有Google到有關的生產環境下的效能測試報告。強調“生產環境下的測試報告”是因為對於垃圾收集器來說,僅僅通過簡單的Java程式碼寫個Microbenchmark程式來建立、移除Java物件,再用-XX:+PrintGCDetails等引數來檢視GC日誌是很難做到準衡量其效能的(為何Microbenchmark的測試結果不準確可參見筆者這篇部落格:http://icyfenix.iteye.com/blog/1110279 )。因此關於G1收集器的效能部分,筆者引用了Sun實驗室的論文《Garbage-First Garbage Collection》其中一段測試資料,以及一段在StackOverfall.com上同行們對G1在真實生產環境下的效能分享討論。
  Sun給出的Benchmark的執行硬體為Sun V880伺服器(8×750MHz UltraSPARC III 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時間佔用執行時間的百分比。
 測試結果如下表所示: 
  表1:軟實時目標測試結果 


  從上面結果可見,對於telco來說,軟實時目標失敗的概率控制在0.5%~0.7%之間,SPECjbb就要差一些,但也控制在2%~5%之間,概率隨著(X/Y)的比值減小而增加。另一方面,失敗時超出允許GC時間的比值隨著總時間片段增加而變小(分母變大了嘛),在(100/200)、512MB的配置下,G1收集器出現了某些時間片段下100%時間在進行GC的最壞情況。而相比之下,CMS收集器的測試結果對比之下就要差很多,3種Java堆容量下都出現了100%時間進行GC的情況,
  在吞吐量測試中,測試資料取3次SPECjbb和15次telco的平均結果。在SPECjbb的應用下,各種配置下的G1收集器表現出了一致的行為,吞吐量看起來只與允許最大GC時間成正比關係,而在telco的應用中,不同配置對吞吐量的影響則顯得很微弱。與CMS收集器的吞吐量對比可以看到,在SPECjbb測試中,在堆容量超過768M時,CMS收集器有5%~10%的優勢,而在telco測試中CMS的優勢則要小一些,只有3%~4%左右。


圖2:吞吐量測試結果

  在更大規模的生產環境下,筆者引用一段在StackOverflow.com上看到的經驗分享:“我在一個真實的、較大規模的應用程式中使用過G1:大約分配有60~70GB記憶體,存活物件大約在20~50GB之間。伺服器執行Linux作業系統,JDK版本為6u22。G1與PS/PS Old相比,最大的好處是停頓時間更加可控、可預測,如果我在PS中設定一個很低的最大允許GC時間,譬如期望50毫秒內完成GC(-XX:MaxGCPauseMillis=50),但在65GB的Java堆下有可能得到的直接結果是一次長達30秒至2分鐘的漫長的Stop-The-World過程;而G1與CMS相比,它們都立足於低停頓時間,CMS仍然是我現在的選擇,但是隨著Oracle對G1 的持續改進,我相信G1會是最終的勝利者。如果你現在採用的收集器沒有出現問題,那就沒有任何理由現在去選擇G1,如果你的應用追求低停頓,那G1現在已經可以作為一個可嘗試的選擇,如果你的應用追求吞吐量,那G1並不會為你帶來什麼特別的好處。”
  在這節筆者引了兩段別人的測試結果、經驗後,對於G1給出一個自己的建議:直到現在為止還沒有一款“最好的”收集器出現,更加沒有“萬能的”收集器,所以我們選擇的只是對具體應用最合適的收集器。對於不同的硬體環境、不同的軟體應用、不同的引數配置、不同的調優目標都會對調優時的收集器選擇產生影響,選擇適合的收集器,除了理論和別人的資料經驗作為指導外,最終還是應當建立在自己應用的實際測試之上,別人的測試,大可抱著“至於你信不信,反正我自己沒測之前是不信的”的態度。

參考資料 
  本文撰寫時主要參考了以下資料: 
宣告: 
  本文已經首發於InfoQ中文站,版權所有,原文為《解析JDK 7的Garbage-First收集器》,如需轉載,請務必附帶本宣告,謝謝。
  InfoQ中文站是一個面向中高階技術人員的線上獨立社群,為Java、.NET、Ruby、SOA、敏捷、架構等領域提供及時而有深度的資訊、高階技術大會如QCon 、線下技術交流活動QClub、免費迷你書下載如《架構師》等。

相關文章