從G1設計到堆空間調整
引言:如果你在使用Java8,或者計劃使用Java9,有很大可能是要麼在評估G1垃圾收集器,要麼已經在使用它。本文將從G1設計開始向您介紹係統介紹G1垃圾收集器如何工作,助您更加係統的學習了解G1。
本文選自《Java性能調優指南》。
G1設計
G1將Java堆分成多個分區。分區的大小可以依據堆的尺寸而改變,但必須是2的冪,同時最小為1MB,最大為32MB。由此得出可能的分區尺寸是1 MB、2MB、4 MB、8 MB、16 MB和32MB。所有分區的大小都一樣,在JVM運行過程中它們的尺寸也不會發生變化。分區尺寸是基於Java堆內存的初始值和最大值的平均數來進行計算的,這樣對於這個平均堆尺寸就會有2000個左右的分區。舉個例子,對一個16G的Java堆使用-Xmx16g -Xms16g命令行選項,G1就會選擇采用16GB/2000 = 8MB的分區尺寸。
如果Java堆內存初始值和最大值相差很遠,或者這個堆內存的尺寸非常大,很有可能就會產生遠超過2000個的分區。類似地,若堆內存很小,那分區數量會遠遠小於2000。
每個分區都有一個關聯的已記憶集合(remembered set,該集合用來記錄跟蹤分區外指向分區內的引用,簡稱RSet),這樣就避免了對整個堆的掃描,使得各個分區的GC更加獨立。RSet總體大小有限,但也不容忽視,因此分區的數量對HotSpot的內存空間占用有直接的影響。RSet總體的尺寸嚴重依賴應用的行為。RSet最少時大概會占用1%左右的堆空間,最多時可能會達到20%。
一個特定的分區一次隻能用於一個目的,但一旦這個分區被包含進一次收集,它就會被徹底轉移,同時被釋放為一個可用分區。
G1有多種類型的分區。可用分區是當前未被使用的。eden(新生代)分區組成了年輕代的eden空間,survivor(存活代)分區組成了年輕代的survivor空間。所有eden分區和survivor分區的總的集合,就是年輕代。eden分區或survivor分區的數量隨著一次次的垃圾收集發生改變,包括年輕代收集、混合收集或者full收集。老年代分區由絕大部分老年代組成。最後,通常認為巨型分區是老年代的一個組成部分,它用來容納那些大小達到或超過一個分區50%空間的對象。在JDK 8u40之前,巨型分區是作為老年代的一部分被收集的,但在JDK 8u40裏,某些巨型分區是作為一個年輕代的一部分被收集的。本章後續還會提到更多關於巨型分區的細節。
實際上,一個分區可以用於任何目的,也就是說沒有必要把內存堆劃分成相鄰的年輕代段和老年代段。G1的啟發式算法會估算年輕代需要多少個分區,以及按照指定的GC暫停時間估算目前還有多少分區要被回收。一旦應用開始生產對象,G1就選中一個可用分區並將它指定為eden分區,然後從中取出內存塊交給Java線程。當這個分區滿了之後,另一個未被使用的分區會再被指定為eden分區。這個操作會一直持續下去,直到達到eden分區的上限數量,就觸發一次年輕代垃圾收集。
一次年輕代垃圾收集會回收所有年輕代分區,包括eden分區和survivor分區。這些分區裏的所有存活對象都會被轉移到另外一個新的survivor分區或者老年代分區。在當前轉移的目標分區滿了之後,就會將新的可用分區標記為survivor分區或老年代分區,繼續轉移操作。
一次GC之後,當老年代的空間占用達到甚至超過了堆空間的占用門檻,G1就會啟動一次老年代收集。通過命令行選項-XX:InitiatingHeapOccupancyPercent來控製占用門檻,缺省情況是Java堆內存的45%。
當標記階段顯示某些老年代分區中沒有任何存活對象,G1會提前將它們回收。這些分區將被添加到可用分區集合裏。那些包含存活對象的老年代分區則被安排到將來的混合收集中。
G1使用多個並發標記線程,為了盡量避免從應用線程中“偷取”太多CPU,標記線程的工作往往是爆發式的。它們在一個給定的時間段裏拚命幹活,然後暫定一段時間,讓Java線程得以執行。
巨型(Humongous)對象
G1對大尺寸對象(G1被稱為“巨型對象”)分配會做特殊處理。前麵講過,巨型對象就是大小達到甚至超過一個分區50%空間的對象。這個尺寸包括Java對象頭。對象頭的尺寸在32位和64位的HotSpot虛擬機中是不一樣的。一個指定HotSpot虛擬機中某個指定對象的頭尺寸可以通過Java對象布局工具來獲取,也就是JOL。到寫這本書時,在網上已經能找到Java對象布局工具了。
當發生巨型對象分配時,G1會找出一個連續的可用分區集合,這樣就能匯總出足夠的內存來容納巨型對象。第一個分區別被標記為“巨型開始”(humongous start)分區,其他的分區別被標記為“巨型連續”(humongous continues)分區。如果沒有足夠的連續可用空間,G1就會啟動一次full GC來壓縮Java堆空間。
巨型分區被認為是老年代的組成部分,但它們隻包含一個對象。這個性質允許G1一旦在並發標記階段發現該對象已經不再存活,就可以盡早回收這個巨型分區。一旦發生這種情況,所有用來容納這個巨型對象的分區都將被回收。
G1麵臨的一個潛在的挑戰,就是某些“短命的”巨型對象雖然已經變成未被引用了,但可能一直沒有被回收。JDK 8u40中實現了一個方法,某些情況下在年輕代收集時回收巨型分區。使用G1時避免過於頻繁的巨型對象分配,對達成應用性能目標有決定性的幫助。對那些有大量短命巨型對象的應用來說,增強JDK 8u40有一定幫助,但不是最終的解決方案。
Full垃圾收集
G1裏full GC使用的是與串行垃圾收集器相同的算法。當發生full GC時,就會執行對整個內存堆的全麵壓縮。這確保最大數量的空閑內存可以被係統使用。很重要的一點是G1的full GC活動是單線程的,結果就是可能導致異常長的暫停時間。當然,G1的設計方式也希望使full GC不再是必需的。G1希望不用full GC就能滿足應用的性能目標,然後通過不斷地調優從而不再需要full GC。
並發周期
一個G1並發周期包含了幾個階段的活動:初始標記、並發根分區掃描、並發標記,重新標記以及清除。一個並發周期從初始標記開始,到清除階段結束。除了清除階段,所有這些階段都是“標記存活對象圖”的組成部分。
初始標記階段的目的是收集所有的GC根。根是對象圖的起點。為了從應用線程中收集根引用,必須先暫停這些應用線程,所以初始標記階段是stop-the-world方式的。在G1裏,完成初始標記是年輕代GC暫停的一個組成部分,因為無論如何年輕代GC都必須收集所有根。
標記操作的同時還必須掃描和跟蹤survivor分區裏所有對象的引用。這也是並發根分區掃描所要做的事。在這個階段,所有Java線程都允許執行,所以不會發生應用暫停。唯一的限製就是在下一次GC啟動前必須先完成掃描。這樣做的原因是一次新的GC會產生一個新的存活對象集合,它們跟初始標記的存活對象是有區別的。
大部分標記工作是在並發標記階段完成的。多個線程協同標示存活對象圖。所有Java線程都可以與並發標記線程同時運行,所以應用就不存在暫停,盡管會受到吞吐量下降的一些影響。
完成並發標記後就需要另一個stop-the-world方式的階段來最終完成所有的標記工作。這個階段被稱為“重新標記階段”,通常它隻是一個非常短暫的stop-the-world的暫停。
並發標記的最終階段是清除階段。在這個階段,找出來的那些沒有任何存活對象的分區將被回收。正因為它們沒有任何存活對象,這些分區也不會被包含在年輕代或混合GC中,它們會被添加到可用分區的隊列裏。
完成標記階段之後,就能找出哪些對象是存活的,進而確定哪些分區要被包含在混合GC裏。既然G1裏混合GC是釋放內存的基本手段,那麼在G1用光可用分區之前完成標記階段就顯得至關重要,如果做不到的話,G1隻能退回去發起一次full GC來釋放內存,這雖然可靠卻很慢。
堆空間調整
G1裏的Java堆尺寸通常是分區尺寸的整數倍。除去這個限製,G1和其他HotSpot垃圾收集器一樣,可以在 -Xms與 -Xmx之間動態地擴大或縮小堆大小。
基於以下幾個理由,G1可能會增加Java堆尺寸:
- 在一次full GC中,基於堆尺寸的計算結果會調整堆的空間。
- 當發生年輕代收集或混合收集,G1會計算執行GC所花費的時間以及執行Java應用所花費的時間。根據命令行配置-XX:GCTimeRatio,如果將太多時間用在垃圾收集上,Java堆尺寸就會增加。這個情況下增加Java堆尺寸,其背後的想法就是允許GC減少發生頻度,這樣與花在應用上的時間相比,花在GC上的時間也可以隨之降低。 G1中-XX:GCTimeRatio的缺省值為9,而其他所有HotSpot垃圾收集器都缺省使用99。GCTimeRatio的值越大,Java堆尺寸的增長就會更加得積極。其他HotSpot收集器在增加Java堆尺寸的策略上會更加得激進,因為它們的目標是:相對於執行應用的開銷,用於GC的時間越少越好。
- 如果一個對象分配失敗了(甚至是在做了一次GC之後),G1會嚐試通過增加堆尺寸來滿足對象分配,而不是馬上退回去做一次full GC。
- 如果一個巨型對象分配無法找到足夠的連續分區來容納這個對象,G1會嚐試擴展Java堆來獲得更多可用分區,而不是做一次full GC。
- 當GC需要一個新的分區來轉移對象時,G1更傾向於通過增加Java堆空間來獲得一個新的分區,而不是通過返回GC失敗並開始做一次full GC來找到一個可用分區。
本文選自《Java性能調優指南》,點此鏈接可在博文視點官網查看此書。
想及時獲得更多精彩文章,可在微信中搜索“博文視點”或者掃描下方二維碼並關注。
最後更新:2017-04-01 16:42:10