Java G1 GC 垃圾回收深入淺出


1. G1概覽 

G1 GC 全稱是Garbage First Garbage Collector,垃圾優先垃圾回收器,以下簡稱G1。G1是HotSpot JVM的短停頓垃圾回收器。其實關於G1的論文早在2004年就有了,但是G1是在2012年4月發布的JDK 7u4中才實現。從長期來說,G1旨在取代CMS(Concurrent Mark Sweep)垃圾回收器。G1從JDK9開始已經作為默認的垃圾回收器。如果對於應用程序來說停頓時間比吞吐量更重要,G1是非常合適的選擇。

總體來說G1具有如下特點:

  • G1仍舊是分代(年輕代,老年代)的垃圾回收器
  • G1實現了兩種垃圾回收算法。
  • 年輕代垃圾回收具有Stop-The-World,並行,和通過對象復制實現壓縮的特點
  • 老年代垃圾回收具有並發標記,逐步壓縮的特點,並且老年代回收前需要先進行一次年輕代的回收。 

 

2. G1垃圾回收過程

2.1.  G1垃圾回收過程概述 

G1垃圾回收過程主要包括三個:

  • 年輕代回收(young gc)過程
  • 老年代並發標記(concurrent marking)過程
  • 混合回收過程(mixed gc)。

應用程序分配內存,當年輕代的Eden區用盡時開始年輕代回收過程;當堆內存使用達到一定值(默認45%)時,開始老年代並發標記過程;標記完成馬上開始混合回收過程。

舉個例子:我曾經工作的一個Web服務器,Java進程最大堆內存為4G,每分鍾響應1500個請求,每45秒鍾會新分配大約2G的內存。G1會每45秒鍾進行一次年輕代回收,每31個小時整個堆的使用率會達到45%,會開始老年代並發標記過程,標記完成后開始四到五次的混合回收。

下面將會詳細介紹這個三個過程。

 

2.2.  G1的內存結構 

理解垃圾回收機制,必須先了解G1的內存結構,內存結構如下圖:

 

盡管G1堆內存仍然是分代的,但是同一個代的內存不再采用連續的內存結構。這個是如何實現的呢?

這里有三個關於內存的概念:代,區和內存分段。

G1把堆內存分為年輕代和老年代。年輕代分為Eden和Survivor兩個區,老年代分為Old和Humongous兩個區。代和區都是邏輯概念。

G1把堆內存分為大小相等的內存分段,默認情況下會把內存分為2048個內存分段,可以用-XX:G1HeapRegionSize調整內存分段的個數。比如32G堆內存,2048個內存分段每段的大小為16M。這相當於把內存化整為零。內存分段是物理概念,代表實際的物理內存空間。

每個內存分段都可以被標記為Eden區,Survivor區,Old區,或者Humongous區。這樣屬於不同代,不同區的內存分段就可以不必是連續內存空間了。

新分配的對象會被分配到Eden區的內存分段上,每一次年輕代的回收過程都會把Eden區存活的對象復制到Survivor區的內存分段上,把Survivor區繼續存活的對象年齡加1,如果Survivor區的存活對象年齡達到某個閾值(比如15,可以設置),Survivor區的對象會被復制到Old區。復制過程是把源內存分段中所有存活的對象復制到空的目標內存分段上,復制完成后,源內存分段沒有了存活對象,變成了可以使用的空的Eden內存分段了;而目標內存分段的對象都是連續存儲的,沒有碎片,所以復制過程可以達到內存整理的效果,減少碎片。Humongous區用於保存大對象,如果一個對象占用的空間超過內存分段的一半(比如上面的8M),則此對象將會被分配在Humongous區。如果對象的大小超過一個甚至幾個分段的大小,則對象會分配在物理連續的多個Humongous分段上。Humongous對象因為占用內存較大並且連續會被優先回收。

 

2.3.  Remembered Set

理解回收過程,需要先了解記憶集合(Remembered Set),以下簡稱RS。為了在回收單個內存分段的時候不必對整個堆內存的對象進行掃描(單個內存分段中的對象可能被其他內存分段中的對象引用)引入了RS數據結構。RS使得G1可以在年輕代回收的時候不必去掃描老年代的對象,從而提高了性能。每一個內存分段都對應一個RS,RS保存了來自其他分段內的對象對於此分段的引用。對於屬於年輕代的內存分段(Eden和Survivor區的內存分段)來說,RS只保存來自老年代的對象的引用。這是因為年輕代回收是針對全部年輕代的對象的,反正所有年輕代內部的對象引用關系都會被掃描,所以RS不需要保存來自年輕代內部的引用。對於屬於老年代分段的RS來說,也只會保存來自老年代的引用,這是因為老年代的回收之前會先進行年輕代的回收,年輕代回收后Eden區變空了,G1會在老年代回收過程中掃描Survivor區到老年代的引用。

RS里的引用信息是怎么樣填充和維護的呢?簡而言之就是JVM會對應用程序的每一個引用賦值語句object.field=object進行記錄和處理,把引用關系更新到RS中。但是這個RS的更新並不是實時的。G1維護了一個Dirty Card Queue。對於應用程序的引用賦值語句object.field=object,JVM會在之前和之后執行特殊的操作以在dirty card queue中入隊一個保存了對象引用信息的card。在年輕代回收的時候,G1會對Dirty Card Queue中所有的card進行處理,以更新RS,保證RS實時准確的反映引用關系。那為什么不在引用賦值語句處直接更新RS呢?這是為了性能的需要,RS的處理需要線程同步,開銷會很大,使用隊列性能會好很多。

 

2.4.  年輕代回收過程(Young GC

JVM啟動時,G1先准備好Eden區,程序在運行過程中不斷創建對象到Eden區,當所有的Eden區都滿了,G1會啟動一次年輕代垃圾回收過程。年輕代只會回收Eden區和Survivor區。首先G1停止應用程序的執行(Stop-The-World),G1創建回收集(Collection Set),回收集是指需要被回收的內存分段的集合,年輕代回收過程的回收集包含年輕代Eden區和Survivor區所有的內存分段。然后開始如下回收過程:

第一階段,掃描根。

根是指static變量指向的對象,正在執行的方法調用鏈條上的局部變量等。根引用連同RS記錄的外部引用作為掃描存活對象的入口。

第二階段,更新RS。

處理dirty card queue中的card,更新RS。此階段完成后,RS可以准確的反映老年代對所在的內存分段中對象的引用。

第三階段,處理RS。

識別被老年代對象指向的Eden中的對象,這些被指向的Eden中的對象被認為是存活的對象。

第四階段,復制對象。

此階段,對象樹被遍歷,Eden區內存段中存活的對象會被復制到Survivor區中空的內存分段,Survivor區內存段中存活的對象如果年齡未達閾值,年齡會加1,達到閥值會被會被復制到Old區中空的內存分段。

第五階段,處理引用。

處理Soft,Weak,Phantom,Final,JNI Weak 等引用。

 

2.5.  G1老年代並發標記過程(Concurrent Marking

當整個堆內存(包括老年代和新生代)被占滿一定大小的時候(默認是45%,可以通過-XX:InitiatingHeapOccupancyPercent進行設置),老年代回收過程會被啟動。具體檢測堆內存使用情況的時機是年輕代回收之后或者houmongous對象分配之后。老年代回收包含標記老年代內的對象是否存活的過程,標記過程是和應用程序並發運行的(不需要Stop-The-World)。應用程序會改變指針的指向,並發執行的標記過程怎么能保證標記過程沒有問題呢?並發標記過程有一種情形會對存活的對象標記不到。假設有對象A,B和C,一開始的時候B.c=C,A.c=null。當A的對象樹先被掃描標記,接下來開始掃描B對象樹,此時標記線程被應用程序線程搶占后停下來,應用程序把A.c=C,B.c=null。當標記線程恢復執行的時候C對象已經標記不到了,這時候C對象實際是存活的,這種情形被稱作對象丟失。G1解決的方法是在對象引用被設置為空的語句(比如B.c=null)時,把原先指向的對象(C對象)保存到一個隊列,代表它可能是存活的。然后會有一個重新標記(Remark)過程處理這些對象,重新標記過程是Stop-The-World的,所以可以保證標記的正確性。上述這種標記方法被稱為開始時快照技術(SATB,Snapshot At The Begging)。這種方式會造成某些是垃圾的對象也被當做是存活的,所以G1會使得占用的內存被實際需要的內存大。

具體標記過程如下:

  1. 先進行一次年輕代回收過程,這個過程是Stop-The-World的。

     老年代的回收基於年輕代的回收(比如需要年輕代回收過程對於根對象的收集,初始的存活對象的標記)。

  2. 恢復應用程序線程的執行。

  3. 開始老年代對象的標記過程。

   此過程是與應用程序線程並發執行的。標記過程會記錄弱引用情況,還會計算出每個分段的對象存活數據(比如分段內存活對象所占的百分比)。

  4. Stop-The-World。

  5. 重新標記(Remark)。

   此階段重新標記前面提到的STAB隊列中的對象(例子中的C對象),還會處理弱引用。

  6. 回收百分之百為垃圾的內存分段。

   注意:不是百分之百為垃圾的內存分段並不會被處理,這些內存分段中的垃圾是在混合回收過程(Mixed GC)中被回收的。

   由於Humongous對象會獨占整個內存分段,如果Humongous對象變為垃圾,則內存分段百分百為垃圾,所以會在第一時間被回收掉。

  7. 恢復應用程序線程的執行。

 

2.6.  混合回收過程(Mixed GC

並發標記過程結束以后,緊跟着就會開始混合回收過程。混合回收的意思是年輕代和老年代會同時被回收。並發標記結束以后,老年代中百分百為垃圾的內存分段被回收了,部分為垃圾的內存分段被計算了出來。默認情況下,這些老年代的內存分段會分8次(可以通過-XX:G1MixedGCCountTarget設置)被回收。混合回收的回收集(Collection Set)包括八分之一的老年代內存分段,Eden區內存分段,Survivor區內存分段。混合回收的算法和年輕代回收的算法完全一樣,只是回收集多了老年代的內存分段。具體過程請參考上面的年輕代回收過程。 

由於老年代中的內存分段默認分8次回收,G1會優先回收垃圾多的內存分段。垃圾占內存分段比例越高的,越會被先回收。並且有一個閾值會決定內存分段是否被回收,-XX:G1MixedGCLiveThresholdPercent,默認為65%,意思是垃圾占內存分段比例要達到65%才會被回收。如果垃圾占比太低,意味着存活的對象占比高,在復制的時候會花費更多的時間。

混合回收並不一定要進行8次。有一個閾值-XX:G1HeapWastePercent,默認值為10%,意思是允許整個堆內存中有10%的空間被浪費,意味着如果發現可以回收的垃圾占堆內存的比例低於10%,則不再進行混合回收。因為GC會花費很多的時間但是回收到的內存卻很少。

 

2.7.  Full GC

Full GC是指上述方式不能正常工作,G1會停止應用程序的執行(Stop-The-World),使用單線程的內存回收算法進行垃圾回收,性能會非常差,應用程序停頓時間會很長。要避免Full GC的發生,一旦發生需要進行調整。什么時候回發生Full GC呢?比如堆內存太小,當G1在復制存活對象的時候沒有空的內存分段可用,則會回退到full gc,這種情況可以通過增大內存解決。

 

3. 其他概念 

3.1.  線程本地分配緩沖區(TLAB: Thread Local Allocation Buffer

由於堆內存是應用程序共享的,應用程序的多個線程在分配內存的時候需要加鎖以進行同步。為了避免加鎖,提高性能每一個應用程序的線程會被分配一個TLAB。TLAB中的內存來自於G1年輕代中的內存分段。當對象不是Humongous對象,TLAB也能裝的下的時候,對象會被優先分配於創建此對象的線程的TLAB中。這樣分配會很快,因為TLAB隸屬於線程,所以不需要加鎖。

 

3.2.  GC“提升”線程本地分配緩沖區(PLAB: Promotion Thread Local Allocation Buffer

前面提到過,G1會在年輕代回收過程中把Eden區中的對象復制(“提升”)到Survivor區中,Survivor區中的對象復制到Old區中。G1的回收過程是多線程執行的,為了避免多個線程往同一個內存分段進行復制,那么復制的過程也需要加鎖。為了避免加鎖,G1的每個線程都關聯了一個PLAB,這樣就不需要進行加鎖了。

 

3.3.  Remembered Set 粒度

其實RS的存儲分三種粒度,前面提到的Card是最小的一種粒度。粒度的存在是因為某些內存分段中的對象可能很熱門,被來自非常多的區的對象所引用,為了避免保存太多的數據,會以更大的粒度來保存這些引用,比如最大的粒度是用一個bitmap來保存其他內存分段對RS所對應的內存分段的引用。每一個內存分段對應一個bit,如果bit為0表示該bit對應的內存分段中沒有引用,為1表示有引用。這種方式會減少RS的數據,但是會增加掃描和標記時的開銷,因為需要掃描所有bit為1的內存分段中的對象以確定具體是來自哪個對象的引用。 

 

后續文章會分析G1 GC的日志,介紹常見的G1 GC性能問題和常用的G1 GC參數調優。

 

 

作者公眾號(碼年)掃碼關注:

 

 

 

參考文獻:

G1 Garbage Collector Details and Tuning (Simone Bordet)

Java Performance Companion (Charlie Hunt, Monica Beckwith, Poonam Parhar, Bengt Rutisson 

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM