深入解析 JVM 中的 G1 垃圾回收器
G1(garbage first)垃圾回收器作為jdk9默認的垃圾回收器,其設計理念完全綜合了cms和parallel scavenge的優點,采用了一種獨特的內存管理策略,實現了整堆維度的內存管理,本文針對該垃圾回收器進行一個較為綜合全面的分析,希望對你有幫助。
一、G1垃圾回收器基本概念
1. 設計理念
G1相較于傳統垃圾回收器而言,它并沒有明確的內存區域的劃分,即在給定的一塊固定的內存空間后,G1垃圾回收器將其以region為最小單位進行劃分,默認情況下,每個region的大小是基于給定堆內存大小除2048從而得出結果。假設我們分配堆內存為4G,那么對應的堆內存的每個region大小就是4096/2048,也就是2M,當然region的大小我們也可以通過參數進行設定。
region的特別之處在于它可以是堆內存中的任何角色,它可以作為Eden,也可以是survivor或者是old,需要注意的是G1有些特殊區域,當對象大小大于region大小的50%的情況下,g1就判定這個對象為大對象,這些對象就會分配到一個老年代一個連續的堆內存空間即Humongous region內存區中。
相較于cms和傳統的parallel scavenge,它汲取了二者的優點,它并不像傳統垃圾回收器那樣等到堆內存空間快滿了再進行垃圾回收。而是提出少量垃圾多次回收保證短耗時高吞吐的概念,通過這個一個基調使得g1有著如下幾個特點:
- 充分利用CPU資源:因為它是提前進行少量多次的垃圾回收,這也就意味著該垃圾回收器會盡可能利用CPU資源進行并行的垃圾回收,提升CPU資源利用率,也保證垃圾回收器良好的性能表現。
- 盡可能少的內存碎片:g1無論是新生代還是老年代,都一律采用高效的標記復制法,解決cms垃圾回收器遺留的大量內存碎片問題,從而提升內存利用率。
- 暫停時間短:g1支持在用戶給定的最大暫停時間內完成一次垃圾回收,由此避免了長時間的STW,保證程序的高響應。
通過上述的這些設計理念,它在吞吐量、內存管理上都有比較明顯的優勢,這也是為什么JDK9將G1作為默認垃圾回收器的原因(JDK8最新版G1也已經比較穩定,同樣建議在堆內存分配大于4G的情況下,采用G1垃圾回收器)。
2. 常見參數
g1垃圾回收器有下面幾個比較常用的參數,首先自然是配置垃圾回收器:
// 整堆啟用G1的垃圾回收器
-XX:+UseG1GC
然后就是設置最大的STW時間,默認情況下是200ms:
# 設置最大暫停時間(默認200ms)
-XX:MaxGCPauseMillis=n
還有就是設置Region的內存大小:
# 指定Region的內存大小,n必須是2的指數冪,其取值范圍是從1M到32M
-XX:G1HeapRegionSize=n
設置垃圾回收線程數:
# 指定垃圾回收工作的線程數量
-XX:ParallelGCThreads=n
二、詳解GC中的Young GC
1. Young GC工作流程
和常規的新生代算法過程一樣,G1垃圾回收器新生代回收也只是針對Eden區和survivor區,默認情況下,當整堆內存空間中Eden區使用率超過60%或回收時間接近用戶設定的最大STW時間時,就會觸發Young GC,通過標記復制法將無用的過期對象回收,同時將存活的對象復制到另外的survivor區中(年齡加上1),對于年齡達到閾值的(默認15)會直接晉升到老年代。
后續這塊Eden區就會被清空變為一塊空閑region并維護到region空閑池中等待后續被分配使用:
2. Young GC如何解決跨代引用問題
我們已經從一個比較宏觀的角度說明的新生代回收的流程,這里我們來聊一個新生代回收時需要注意的一個問題——跨代引用。 跨代引用一直是垃圾回收器一個老生常談的問題了,無論是新生代還是老年代進行垃圾回收的時候,對應的內存區域都無法感知對方是否持自己內存區域的對象,同時考慮到g1垃圾回收器在物理空間排布上,新生代和老年代還是不連續的,所以對于跨代問題就顯得更加棘手了。
對此g1提出了一個卡頁、卡表、記憶集、寫屏障幾個重要的概念概念,我們先來說說卡頁的概念,為了方便后續后續記憶集的維護,g1將每個region都進行了更小維度的切割將其稱為頁并加上編號這就是卡頁(Card Page)。
可以看到筆者在上圖中留了一塊空間,這就是我們需要了解的第二個概念——卡表(Card Table),在物理實現上它是在每個region中預留一個空間并用數組實現,記錄持有當前region的非回收區域老年代對于新生代對象的持有情況,例如有了老年代區域的11號卡頁上某個對象持有當前新生代region的某個對象,對應的我們的region的記憶集就會將數組索引10(11號對應索引0)標記為1,代表卡頁11這個區域持有我們的對象,而數組對應索引未知的元素也被稱為臟卡。
如此一來,進行新生代回收時,通過遍歷即得到對應的臟卡構成一個跨代引用的記憶集,記憶集中對應的老年代對象也會被作為GC root進行可達性算法分析,保證跨代引用分析的準確性。
通過記憶集解決了各個region跨代引用關系的維護之后,我們就需要考慮并發一致性的問題,例如:當前這個老年代的卡頁對象引用這個新生代對象,在多線程并發操作過程中這個老年代對象就放棄了對這個新生代對象的引用,此時我們就需要找到一個手段盡可能保證記憶集的準確性,這就是我們要提到的最后一個概念——寫屏障。 我們還是以上文一個老年代region引用持有新生代對象為例,當這個老年代region持有了一個新生代對象時,除了會將新生代的記憶集標記為1設為臟卡以外,還會通過一個寫后屏障將當前老年代region的臟卡寫入到一個臟卡隊列中,交由g1回收器的某個異步線程輪詢處理,以保證每個region的記憶集能夠盡可能拿到最新的結果。
三、詳解G1中的Mixed GC
1. Mixed GC工作過程
mixed gc是g1垃圾回收器中的一個獨有的概念,它是一種混合回收的垃圾回收模式,該模式遇到以下幾個條件時就會觸發:
- 新生代分配大對象時。
- 老年代內存空間占有率率達到45%。
進行混合回收時,它會回收所有年輕代和一部分老年代,這里部分的老年代region的選取策略是找到垃圾對象最多的那部分region,以保證在有限的暫停時間內做到最有性價比的垃圾回收。
2. Full GC和Mixed GC的區別
這里我們需要補充說明一下mixed gc和full gc的一點區別,可能通過上述描述提及mixed gc涉及新生代和老年代的空間回收,導致很多讀者認為mixed gc和full gc是一個概念,其實并不是,原因如下:
- mixed gc是在整堆空間利用率到達45%時觸發的,而full gc則是mixed gc在進行清理之后仍然無法給出空閑區域分配對象的一種退化擔保策略。
- 從回收的過程來說,mixed gc在宏觀上是并行回收的(少部分階段會進行停頓),而full gc則是采用serial old垃圾回收器進行一個完完全全是STW的垃圾回收,且回收過程采用的是標記整理法,耗時較長。
3. Mixed GC工作原理
我們都知道Mixed GC是一種混合的垃圾回收模式,涉及到老年代的回收,由于老年代存在較多的對象,所以為保證老年代垃圾回收的效率,減少STW的時長,g1設計的垃圾回收通過一種三色標記的并行垃圾回收技術來做到這一點,整體步驟為:
- 初始標記:該階段會掃描當前堆內存中所有的GC Root及其關聯的對象將其標為灰色(意意為該對象在GC Root的引用鏈上,但該對象的所有引用對象都還未標記過),會有短暫的STW暫停用戶線程。
- 并發標記:該階段會不斷從灰色隊列中取出待處理的對象,找到其下一級引用對象并標記為灰色存入到灰色隊列中,同時將自己設置為黑色,隨后不斷重復下一級入隊、本級染黑的這個步驟,直到灰色隊列為空為止。
- 最終標記:將所有用戶線程暫停,修復并發標記期間產生變動的對象,完成最終標記確認,總體耗時相對于并發標記會短一些。
- 篩選回收:完成最終標記之后,G1垃圾回收器會基于用戶給定的最大停頓時間內,找到回收性價比最高的region采用標記復制法(將存活對象復制到空閑的region)完成垃圾回收。
需要注意的是在并發標記階段會涉及一個多標和漏標的問題:
- 在用戶線程并發操作期間,原本標記為黑(即存活的對象)的對象被解引用,導致本該被回收的對象還是黑色,那就是多標的情況。
- 在并發標記期間,原本未被引用的白色對象被其他對象持有,卻還是處于白色標記狀態導致誤回收,那就是漏標的情況。
相比之下多標也就是一個浮動垃圾的問題,但是漏標就會很嚴重了,因為它可能會導致垃圾誤回收的情況。于是G1就提出了一個SATB快照技術(Snapshot At The Beginning)和寫前屏障來解決漏標問題,對應的執行步驟為:
(1) 進行標記時記錄前,通過創建原始快照記錄當前標記對象存活情況。
(2) 基于上述快照,在此之后新創建的對象一律標記為黑色,例如下圖新創建的obj-e:
- 并發期間,原本被標記為白色的對象被其他對象持有,即出現對象賦值操作,例如下面的obj-e持有obj-d,G1垃圾回收器就則會通過寫前屏障技術將被其他引用持有的白色對象(參考本例中被obj-b持有的obj-d)放到一個SATB隊列中,注意這個隊列每個線程獨有,最終這些隊列的結果會匯總到全局的SATB隊列中:
- 最終標記階段,進行STW,全局SATB會將各個線程的SATB結果歸并收集,這些對象一律視為黑色不處理,通過這種快照技術保證解決了漏標的問題,但還是會存在多標的情況,所以G1回收器還是會存在一些浮動垃圾。
四、垃圾回收器常見參數調優技巧
1. 動態調整新生代大小
日常進行JVM參數配置會看到有些同學會通過-xmn來指定新生代堆內存,相對來說這種做法存在因為堆內存預估失敗而導致的響應時間激增的問題。例如,我們定死新生代堆內存為128m,而Eden區默認情況下占用80%差不多102M,默認情況下Eden區達到60%時就會觸發minor GC,這也就意味著每當Eden區達到60M左右時就會觸發新生代GC。
因為定死了新生代的堆內存大小覆蓋了G1的自動調節,所以在流量激增的情況下,60M的堆內存是很容易被打滿的,這種情況下就非常可能出現頻繁新生代GC進而導致響應時間長:
所以我們建議通過-XX:G1NewSizePercent(新生代占用堆內存的最小比,默認為5%)以及-XX:G1MaxNewSizePercent(新生代占用堆內存的最大比,默認為60%),所以針對g1垃圾回收器,我們建議通過設置最大堆和新生代比例來避免新生代堆內存分配不當導致頻繁minor gc的開銷:
java -XX:+UseG1GC \
-XX:G1NewSizePercent=10 \
-XX:G1MaxNewSizePercent=50 \
-Xmx4g \
-jar myapp.jar
2. 調整并發回收stw耗時
上文已提及,G1垃圾回收器支持用戶給定的暫停時間內完成mixed gc,為了保證進行并發回收時停頓的時間盡可能少,保證進程的并發性能表現,我們建議暫停時間MaxGCPauseMillis上可以適當調低一點,以筆者為例,日常設置的MaxGCPauseMillis都是200ms:
# 啟動應用時添加JVM參數
java -XX:+UseG1GC \ # 啟用G1垃圾收集器
-Xmx4g \ # 堆內存最大4G
-Xms4g \ # 堆內存初始4G
-XX:MaxGCPauseMillis=100 \ # 設置最大GC停頓時間目標
-jar your-app.jar
3. 提升降低并發回收間隔避免full gc
為避免并發回收不及時,導致堆內存被打滿觸發full gc導致程序并發性能下降,對于高并發讀請求的系統,它們的堆內存中老年代死亡率相對較高即短期老年代對象多,對此我們可以適當調低-InitiatingHeapOccupancyPercent(觸發混合回收的閾值,默認情況下的45即老年代內存占用達到45%觸發)盡早回收老年代對象來規避這個問題:
-XX:InitiatingHeapOccupancyPercent=35