G1誕生的背景
Garbage First(簡稱G1)收集器是垃圾收集器技術發展歷史上的里程碑式的成果,它開創了收集器面向局部收集的設計思路和基於Region的內存布局形式。HotSpot開發團隊最初賦予它的期望是(在比較長期的) 未來可以替換掉JDK 5中發布的CMS收集器。 現在這個期望目標已經實現過半了, JDK 9發布之日, G1宣告取代Parallel Scavenge加Parallel Old組合, 成為服務端模式下的默認垃圾收集器, 而CMS則淪落至被聲明為不推薦使用(Deprecate) 的收集器[1]。 如果對JDK 9及以上版本的HotSpot虛擬機使用參數-XX: +UseConcMarkSweepGC來開啟CMS收集器的話, 用戶會收到一個警告信息, 提示CMS未來將會被廢棄
Java HotSpot(TM) 64-Bit Server VM warning: Option UseConcMarkSweepGC was deprecated in version 9.0
作為CMS收集器的替代者和繼承人, 設計者們希望做出一款能夠建立起“停頓時間模型”(PausePrediction Model) 的收集器, 停頓時間模型的意思是能夠支持指定在一個長度為M毫秒的時間片段內, 消耗在垃圾收集上的時間大概率不超過N毫秒這樣的目標, 這幾乎已經是實時Java(RTSJ) 的軟實時垃圾收集器特征了
應用場景
G1是一種服務端應用使用的垃圾回收器,目標是用在多核,大內存的機器上,它在大多數情況下可以實現指定的GC暫停時間(-XX:MaxGCPauseMillis=200,意思是要求 G1,在任意 1 秒的時間內,停頓時間不得超過 200ms,G1開創的基於Region的堆內存布局是它能夠實現這個目標的關鍵),同時還能保持較高的吞吐量,據研究他的吞吐量比PS降低了10%~15%
為什么說G1是收集器技術發展的一個里程碑?
從G1開始,最先進的垃圾收集器的設計導向都不約而同地變為追求能夠應付應用的內存分配速率(Allocation Rate),而不追求一次把整個Java堆全部清理干凈。這樣,應用在分配,同時收集器在收集,只要收集的速度能跟得上對象分配的速度,那一切就能運作得很完美。這種新的收集器設計思路從工程實現上看是從G1開始興起的,所以說G1是收集器技術發展的一個里程碑
什么是軟實時?
所謂的實時垃圾回收,是指在要求的時間內完成垃圾回收。“軟實時”則是指,用戶可以指定垃圾回收時間的限時,G1會努力在這個時限內完成垃圾回收,但是G1並不保證每次都能在這個時限內完成垃圾回收。通過設定一個合理的目標,可以讓達到90%以上的垃圾回收時間都在這個時限內。
-XX:MaxGCPauseMillis設置多少比較合適?
毫無疑問,可以由用戶指定期望的停頓時間是G1收集器很強大的一個功能,設置不同的期望停頓時間,可使得G1在不同應用場景中取得關注吞吐量和關注延遲之間的最佳平衡。不過,這里設置的“期望值”必須是符合實際的,不能異想天開,畢竟G1是要凍結用戶線程來復制對象的,這個停頓時間再怎么低也得有個限度。它默認的停頓目標為200ms,一般來說,回收階段占到幾十到一百甚至接近兩百毫秒都很正常,但如果我們把停頓時間調得非常低,譬如設置為二十毫秒,很可能出現的結果就是由於停頓目標時間太短,導致每次選出來的回收集只占堆內存很小的一部分,收集器收集的速度逐漸跟不上分配器分配的速度,導致垃圾慢慢堆積。很可能一開始收集器還能從空閑的堆內存中獲得一些喘息的時間,但應用運行時間一長就不行了,最終占滿堆引發Full GC反而降低性能,所以通常把期望停頓時間設置為一兩百毫秒或者兩三百毫秒會是比較合理的
G1如何划分內存?
前面的那些垃圾回收器的內存都是連續的,分塊的,且一旦內存塊大到一定程度無論怎么調優都沒戲,所以G1就帶來了新的內存模型,看下圖:
軟件架構設計有一個重要的思想就是分而治之,G1把內存分為一個個Region,從1M,2M到32M不等,但都是2的冪次方,可以通過參數-XX:G1HeapRegionSize設定。
每一個Region在邏輯上仍然是屬於某一個分代(邏輯分代,物理不分代),這個分代分為四種:
- old區:存放老對象
- Survivor區:存放存活對象
- Eden區:存放新生代對象
- Humongous區:存放大對象區域,對象特別大可能會跨兩個Region
所以G1的模型和以前的分代模型完全不一樣了
如何理解Region
- 每一個Region,即分區可能是年輕代也可能是老年代,但是在同一時刻只能屬於某個代。年輕代,老年代,幸存區這些概念還在,只不過只是邏輯上的概念,這樣方便復用之前的分代邏輯框架的邏輯。
- 在物理上不需要連續則帶來了額外的好處:有的分區垃圾特別多,有的分區垃圾比較少,G1就會優先回收垃圾對象特別多的分區,這樣就可以花費較少的時間來回收這些分區的垃圾,這也是G1垃圾回收器的由來,即首先回收垃圾較多的分區。但是新生代不使用這種算法,依然是新生代的空間滿了之后才會對整個新生代進行回收。整個新生代中的對象要么被回收,要么晉升。至於新生代也采取分區的機制是因為這樣和老年代的策略統一,方便調整代的大小。
- G1還是有壓縮功能的垃圾回收器,在回收老年代的分區時,是將存活的對象從一個分區拷貝到另外的一個分區,這樣拷貝的過程就實現了局部壓縮的效果。
- G1的內存Region不是固定的E或者O或者其他,比如說:在某一個時間段內這一塊區域是E,但是進行一次YGC回收之后把這個E區域擦除了,那么下一次回收的時候可能就當做O區來用了,所以說比較靈活
為什么G1可以預測停頓時間?
因為它將Region作為單次回收的最小單元, 即每次收集到的內存空間都是Region大小的整數倍, 這樣可以有計划地避免在整個Java堆中進行全區域的垃圾收集,更具體的處理思路是讓G1收集器去跟蹤各個Region里面的垃圾堆積的“價值”大小,價值即回收所獲得的空間大小以及回收所需時間的經驗值,然后在后台維護一個優先級列表,每次根據用戶設定允許的收集停頓時間(使用參數-XX:MaxGCPauseMillis指定, 默認值是200毫秒),優先處理回收價值收益最大的那些Region,這也就是“Garbage First”名字的由來。這種使用Region划分內存空間, 以及具有優先級的區域回收方式,保證了G1收集器在有限的時間內獲取盡可能高的收集效率。
特點
- 並發收集垃圾
- 壓縮空閑的空間不會延長GC的暫停時間
- 更易預測GC暫停的時間
- 適用於對吞吐量沒有極致要求的場景(STW的時間很短)
G1 的垃圾回收過程
在邏輯上,G1分為年輕代和老年代,但它的年輕代和老年代比例,並不是那么“固定”,為了達到 MaxGCPauseMillis 所規定的效果,G1 會自動調整兩者之間的比例。
如果你強行使用 -Xmn 或者 -XX:NewRatio 去設定它們的比例的話,我們給 G1 設定的這個目標將會失效。
- G1“年輕代”的垃圾回收,同樣叫 Minor GC,這個過程和我們前面描述的類似,發生時機就是 Eden 區滿的時候。
- 老年代的垃圾收集,嚴格上來說其實不算是收集,它是一個“並發標記”的過程,順便清理了一點點對象。
- 真正的清理,發生在“混合模式”(Mixed GC),它不止清理年輕代,還會將老年代的一部分區域進行清理。
老年代垃圾收集
具體標記過程如下:
1.初始標記(Initial Mark)
這個過程共用了 Minor GC 的暫停,這是因為它們可以復用 root scan 操作。雖然是STW的,但是時間通常非常短。
2.Root 區掃描(Root Region Scan)
3.並發標記( Concurrent Mark)
這個階段從 GC Roots 開始對 heap 中的對象標記,標記線程與應用程序線程並行執行,並且收集各個 Region 的存活對象信息。
4.重新標記(Remaking)
和 CMS 類似,也是STW的。標記那些在並發標記階段發生變化的對象。
5.清理階段(Cleanup)
這個過程不需要 STW。如果發現 Region 里全是垃圾,在這個階段會立馬被清除掉。不全是垃圾的 Region,並不會被立馬處理,它會在 Mixed GC 階段,進行收集。
RSet和CSet
GC什么時候觸發?
- Eden區空間不足
- Old空間不足或者手動調用System.gc()
MixedGC
比如YGC不行了,對象產生特別多達到了45%的閾值默認啟動了MixedGC,這個值是可以自己定的,MixedGC就相當於一個完整的CMS垃圾回收過程
初始標記-STW(initial mark)
並發標記(concurrent mark)
重新標記-STW(remark)
篩選回收
G1有沒有FGC?
有,而且JDK10以前都是串行的,之后才是並行,我們說G1和CMS調優的目標其中之一就是盡量避免FGC
G1如果產生FGC,你應該怎么辦?
- 擴內存
- 提高CPU性能(回收的快,業務邏輯產生對象的速度固定,垃圾回收越快,可用空間就越大)
- 降低MixedGC的閾值,讓MixedGC提早發生(默認是45%),設置這個參數:-XX:G1HeapWastePercent ?
並發標記的算法
CMS和G1並發標記采用的算法都是三色標記法,把對象在邏輯上分成三種顏色:
- 黑色:自己是不是垃圾已經被標記完了,並且成員變量是不是垃圾也已經被標記完畢
- 灰色:本身標記完了,但是還沒有標記到它所引用的那些對象
- 白色:沒有被標記的對象
三色算法的缺陷:漏標
什么情況下會產生漏標呢?黑色指向了白色,但同時指向白色的其他引用沒了,在remark過程中,黑色A指向了白色C,如果不對黑色重新掃描,就會漏標,會把白色C對象當成垃圾回收掉
如何解決漏標?
- 關注引用的增加(incremental update)
比如A指向D的時候跟蹤這個引用,產生了這個引用之后,把A重新標記位灰色,原來是黑色不會掃描,但是被標記位灰色之后再下一次掃描的時候還會重新掃描一遍,因此D就會被找到,CMS采用的是這種算法 - 關注引用的刪除(SATB:snaphot at the begining)
剛開始有一個快照,當B和D消失的時候要把這個引用push到堆棧,保證D還能被GC掃描到,最重要的是要把這個引用push到堆棧,是灰色對象指向白色對象的引用,如果一旦某一個引用消失就會把它放進堆棧,因此還是可以掃描到它,這樣白色的C就不會被漏標了,G1采用的就是這個方案。
為什么G1使用SATB,而不使用incremental update?
因為變成灰色還要重新掃描一遍,效率偏低
G1與CMS相比有什么優勢?
G1不會產生內存空間碎片。與CMS的“標記-清除”算法不同,G1從整體來看是基於“標記-整理”算法實現的收集器,但從局部(兩個Region之間)上看又是基於“標記-復制”算法實現,無論如何,這兩種算法都意味着G1運作期間不會產生內存空間碎片,垃圾收集完成之后能提供規整的可用內存。這種特性有利於程序長時間運行,在程序為大對象分配內存時不容易因無法找到連續內存空間而提前觸發下一次收集。
G1與CMS相比有什么劣勢?
如在用戶程序運行過程中,G1無論是為了垃圾收集產生的內存占用(Footprint)還是程序運行時的額外執行負載(Overload)都要比CMS要高。
- 更高的內存占用。雖然G1和CMS都使用卡表來處理跨代指針,但G1的卡表實現更為復雜,而且堆中每個Region,無論扮演的是新生代還是老年代角色,都必須有一份卡表,這導致G1的記憶集(和其他內存消耗)可能會占整個堆容量的20%乃至更多的內存空間;相比起來CMS的卡表就相當簡單,只有唯一一份,而且只需要處理老年代到新生代的引用,反過來則不需要,由於新生代的對象具有朝生夕滅的不穩定性,引用變化頻繁,能省下這個區域的維護開銷是很划算的
- 更高的負載占用。在執行負載的角度上,同樣由於兩個收集器各自的細節實現特點導致了用戶程序運行時的負載會有不同,譬如它們都使用到寫屏障,CMS用寫后屏障來更新維護卡表;而G1除了使用寫后屏障來進行同樣的(由於G1的卡表結構復雜,其實是更煩瑣的)卡表維護操作外,為了實現原始快照搜索(SATB)算法,還需要使用寫前屏障來跟蹤並發時的指針變化情況。相比起增量更新算法,原始快照搜索能夠減少並發標記和重新標記階段的消耗,避免CMS那樣在最終標記階段停頓時間過長的缺點,但是在用戶程序運行過程中確實會產生由跟蹤引用變化帶來的額外負擔。由於G1對寫屏障的復雜操作要比CMS消耗更多的運算資源,所以CMS的寫屏障實現是直接的同步操作,而G1就不得不將其實現為類似於消息隊列的結構,把寫前屏障和寫后屏障中要做的事情都放到隊列里,然后再異步處理
以上的優缺點對比僅僅是針對G1和CMS兩款垃圾收集器單獨某方面的實現細節的定性分析,通常說哪款收集器要更好、要好上多少,往往是針對具體場景才能做的定量比較。按照實踐經驗,目前在小內存應用上CMS的表現大概率仍然要會優於G1,而在大內存應用上G1則大多能發揮其優勢,這個優劣勢的Java堆容量平衡點通常在6GB至8GB之間,當然,以上這些也僅是經驗之談,不同應用需要量體裁衣地實際測試才能得出最合適的結論,隨着HotSpot的開發者對G1的不斷優化,也會讓對比結果繼續向G1傾斜