1. 程式人生 > >G1收集器的收集原理

G1收集器的收集原理

G1收集器的收集原理

來源 http://blog.jobbole.com/109170/

 

JVM 8 記憶體模型

原文:https://blog.csdn.net/bruce128/article/details/79357870 

 

這裡介紹的是JDK1.8 JVM執行時記憶體資料區域劃分。1.8同1.7比,最大的差別就是:元資料區取代了永久代。元空間的本質和永久代類似,都是對JVM規範中方法區的實現。不過元空間與永久代之間最大的區別在於:元資料空間並不在虛擬機器中,而是使用本地記憶體。

 

二、各區域介紹

1. 程式計數器
PC 暫存器,也叫程式計數器。JVM支援多個執行緒同時執行,每個執行緒都有自己的程式計數器。倘若當前執行的是 JVM 的方法,則該暫存器中儲存當前執行指令的地址;倘若執行的是native 方法,則PC暫存器中為空。

  • 當前執行緒所執行的位元組碼的行號指示器;
  • 當前執行緒私有;
  • 不會出現OutOfMemoryError情況

2. Java虛擬機器棧

虛擬機器棧:每個執行緒有一個私有的棧,隨著執行緒的建立而建立,其生命週期與執行緒同進同退。棧裡面存著的是一種叫“棧幀”的東西,每個Java方法在被呼叫的時候都會建立一個棧幀,一旦完成呼叫,則出棧。所有的的棧幀都出棧後,執行緒也就完成了使命。棧幀中存放了區域性變量表(基本資料型別和物件引用)、運算元棧、動態連結(指向當前方法所屬的類的執行時常量池的引用等)、方法出口(方法返回地址)、和一些額外的附加資訊。棧的大小可以固定也可以動態擴充套件。當棧呼叫深度大於JVM所允許的範圍,會丟擲StackOverflowError的錯誤,不過這個深度範圍不是一個恆定的值。

  • 執行緒私有,生命週期與執行緒相同;
  • java方法執行的記憶體模型,每個方法執行的同時都會建立一個棧幀,儲存區域性變量表(基本型別、物件引用)、運算元棧、動態連結、方法出口等資訊;
  • StackOverflowError異常:當執行緒請求的棧深度大於虛擬機器所允許的深度;
  • OutOfMemoryError異常:如果棧的擴充套件時無法申請到足夠的記憶體。

3. 本地方法棧
本地方法棧與Java棧的作用和原理非常相似。區別只不過是Java棧是為執行Java方法服務的,而本地方法棧則是為執行本地方法(Native Method)服務的。在JVM規範中,並沒有對本地方發展的具體實現方法以及資料結構作強制規定,虛擬機器可以自由實現它。在HotSopt虛擬機器中直接就把本地方法棧和Java棧合二為一。

4. 堆

堆是JVM記憶體佔用最大,管理最複雜的一個區域。其唯一的用途就是存放物件例項:所有的物件例項及陣列都在堆上進行分配。1.7後,字串常量池從永久代中剝離出來,存放在堆中。堆有自己進一步的記憶體分塊劃分,按照GC分代收集角度的劃分請參見上圖。

 

4.1 堆空間記憶體分配(預設情況下)

  • 老年代 : 三分之二的堆空間
  • 年輕代 : 三分之一的堆空間
    •   eden區: 8/10 的年輕代空間
    •   survivor0 : 1/10 的年輕代空間
    •   survivor1 : 1/10 的年輕代空間

命令列上執行如下命令,檢視所有預設的jvm引數

java -XX:+PrintFlagsFinal -version

輸出
輸出有大幾百行,這裡只取其中的兩個有關聯的引數

[Global flags]
    uintx InitialSurvivorRatio                      = 8                                   {product}
    uintx NewRatio                                  = 2                                   {product}
    ... ...
java version "1.8.0_91"
Java(TM) SE Runtime Environment (build 1.8.0_91-b14)
Java HotSpot(TM) 64-Bit Server VM (build 25.91-b14, mixed mode)

引數解釋
引數 作用
-XX:InitialSurvivorRatio           新生代Eden/Survivor空間的初始比例
-XX:Newratio                           Old區 和 Yong區 的記憶體比例
一道推算題

預設引數下,如果僅給出eden區40M,求堆空間總大小

根據比例可以推算出,兩個survivor區各5M,年輕代50M。老年代是年輕代的兩倍,即100M。那麼堆總大小就是150M。

4.2 字串常量池
JDK1.7 就開始“去永久代”的工作了。 1.7把字串常量池從永久代中剝離出來,存放在堆空間中。

a. jvm引數配置

-XX:MaxPermSize=10m
-XX:PermSize=10m
-Xms100m
-Xmx100m
-XX:-UseGCOverheadLimit

 

b. 測試程式碼

public class StringOomMock {

    public static void main(String[] args) {
        try {
            List<String> list = new ArrayList<String>();
            for (int i = 0; ; i++) {
                System.out.println(i);
                list.add(String.valueOf("String" + i++).intern());
            }
        } catch (java.lang.Exception e) {
            e.printStackTrace();
        }
    }
}

 

c. jdk1.6 下的執行結果
jdk1.6 環境下是永久代OOM

153658
153660
Exception in thread "main" java.lang.OutOfMemoryError: PermGen space
    at java.lang.String.intern(Native Method)
    at com.jd.im.StringOomMock.main(StringOomMock.java:17)


d. jdk1.7 下的執行結果
jdk1.7 下是堆OOM,並且伴隨著頻繁的FullGC, CPU一直高位執行

2252792
2252794
2252796
2252798
*** java.lang.instrument ASSERTION FAILED ***: "!errorOutstanding" with message can't create name string at ../../../src/share/instrument/JPLISAgent.c line: 807
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
    at java.nio.CharBuffer.wrap(CharBuffer.java:369)
    at sun.nio.cs.StreamEncoder.implWrite(StreamEncoder.java:265)
    at sun.nio.cs.StreamEncoder.write(StreamEncoder.java:125)
    at java.io.OutputStreamWriter.write(OutputStreamWriter.java:207)
    at java.io.BufferedWriter.flushBuffer(BufferedWriter.java:129)
    at java.io.PrintStream.write(PrintStream.java:526)
    at java.io.PrintStream.print(PrintStream.java:597)
    at java.io.PrintStream.println(PrintStream.java:736)
    at com.jd.im.StringOomMock.main(StringOomMock.java:16)

 

e. jdk1.8 下的執行結果
jdk1.8的執行結果同1.7的一樣,都是堆空間OOM。

2236898
2236900
2236902
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
    at java.lang.Integer.toString(Integer.java:403)
    at java.lang.String.valueOf(String.java:3099)
    at java.io.PrintStream.print(PrintStream.java:597)
    at java.io.PrintStream.println(PrintStream.java:736)
    at com.jd.im.StringOomMock.main(StringOomMock.java:16)

 

5. 元資料區
元資料區取代了1.7版本及以前的永久代。元資料區和永久代本質上都是方法區的實現。方法區存放虛擬機器載入的類資訊、常量、靜態變數、即時編譯器編譯後的程式碼等資料。虛擬機器規範是把這個區域(方法區)描述為堆的一個邏輯部分的,但實際它應該是要和堆區分開的。一般情況下為了與堆進行區分,通常又叫“非堆”。

在HotSpot中,方法區≈永久代。不過1.7版本之後,我們使用的HotSpot就沒有永久代這個概念了,而是在本地記憶體中使用 元空間 取代了 方法區。在1.7版本之前,“PermGen space”(永久代空間) 其實指的就是方法區。不過方法區和“PermGen space”又有著本質的區別。前者是 JVM 的規範,而後者則是 JVM 規範的一種實現,並且只有 HotSpot 才有 “PermGen space”,而對於其他型別的虛擬機器,如 JRockit(Oracle)、J9(IBM) 並沒有“PermGen space”。由於方法區主要儲存類的相關資訊,所以對於動態生成類的情況比較容易出現永久代的記憶體溢位。最典型的場景就是,在 jsp 頁面比較多的情況,容易出現永久代記憶體溢位。我們可以通過動態生成類來模擬這個 "java.lang.OutOfMemoryError: PermGen space " 記憶體溢位的異常。

JDK 8 中永久代向元空間的轉換的幾點原因

1、字串存在永久代中,容易出現效能問題和記憶體溢位。
2、類及方法的資訊等比較難確定其大小,因此對於永久代的大小指定比較困難,太小容易出現永久代溢位,太大則容易導致老年代溢位。
3、永久代會為 GC 帶來不必要的複雜度,並且回收效率偏低。
4、Oracle 可能會將HotSpot 與 JRockit 合二為一。

其實,移除永久代的工作從JDK1.7就開始了。JDK1.7中,儲存在永久代的部分資料就已經轉移到了Java Heap或者是 Native Heap。但永久代仍存在於JDK1.7中,並沒完全移除,譬如符號引用(Symbols)轉移到了native heap;字面量(interned strings)轉移到了java heap;類的靜態變數(class statics)轉移到了java heap。我們可以通過一段程式來比較 JDK 1.6 與 JDK 1.7及 JDK 1.8 的區別。

 

元資料區OOM測試:

a. jvm引數配置

-XX:MetaspaceSize=8m 
-XX:MaxMetaspaceSize=50m

 

b. 測試程式碼
藉助cglib框架生成新類。

public class MetaSpaceOomMock {

    public static void main(String[] args) {
        ClassLoadingMXBean loadingBean = ManagementFactory.getClassLoadingMXBean();
        while (true) {
            Enhancer enhancer = new Enhancer();
            enhancer.setSuperclass(MetaSpaceOomMock.class);
            enhancer.setCallbackTypes(new Class[]{Dispatcher.class, MethodInterceptor.class});
            enhancer.setCallbackFilter(new CallbackFilter() {
                @Override
                public int accept(Method method) {
                    return 1;
                }

                @Override
                public boolean equals(Object obj) {
                    return super.equals(obj);
                }
            });

            Class clazz = enhancer.createClass();
            System.out.println(clazz.getName());
            //顯示數量資訊(共載入過的型別數目,當前還有效的型別數目,已經被解除安裝的型別數目)
            System.out.println("total: " + loadingBean.getTotalLoadedClassCount());
            System.out.println("active: " + loadingBean.getLoadedClassCount());
            System.out.println("unloaded: " + loadingBean.getUnloadedClassCount());
        }
    }
}

 

c. 執行輸出:

jvm.MetaSpaceOomMock$$EnhancerByCGLIB$$567f7ec0
total: 6265
active: 6265
unloaded: 0
jvm.MetaSpaceOomMock$$EnhancerByCGLIB$$3501581b
total: 6266
active: 6266
unloaded: 0
Exception in thread "main" net.sf.cglib.core.CodeGenerationException: java.lang.reflect.InvocationTargetException-->null
    at net.sf.cglib.core.AbstractClassGenerator.generate(AbstractClassGenerator.java:345)
    at net.sf.cglib.proxy.Enhancer.generate(Enhancer.java:492)
    at net.sf.cglib.core.AbstractClassGenerator$ClassLoaderData$3.apply(AbstractClassGenerator.java:93)
    at net.sf.cglib.core.AbstractClassGenerator$ClassLoaderData$3.apply(AbstractClassGenerator.java:91)
    at net.sf.cglib.core.internal.LoadingCache$2.call(LoadingCache.java:54)
    at java.util.concurrent.FutureTask.run(FutureTask.java:266)
    at net.sf.cglib.core.internal.LoadingCache.createEntry(LoadingCache.java:61)
    at net.sf.cglib.core.internal.LoadingCache.get(LoadingCache.java:34)
    at net.sf.cglib.core.AbstractClassGenerator$ClassLoaderData.get(AbstractClassGenerator.java:116)
    at net.sf.cglib.core.AbstractClassGenerator.create(AbstractClassGenerator.java:291)
    at net.sf.cglib.proxy.Enhancer.createHelper(Enhancer.java:480)
    at net.sf.cglib.proxy.Enhancer.createClass(Enhancer.java:337)
    at jvm.MetaSpaceOomMock.main(MetaSpaceOomMock.java:38)
Caused by: java.lang.reflect.InvocationTargetException
    at sun.reflect.GeneratedMethodAccessor1.invoke(Unknown Source)
    at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
    at java.lang.reflect.Method.invoke(Method.java:498)
    at net.sf.cglib.core.ReflectUtils.defineClass(ReflectUtils.java:413)
    at net.sf.cglib.core.AbstractClassGenerator.generate(AbstractClassGenerator.java:336)
    ... 12 more
Caused by: java.lang.OutOfMemoryError: Metaspace
    at java.lang.ClassLoader.defineClass1(Native Method)
    at java.lang.ClassLoader.defineClass(ClassLoader.java:763)
    ... 17 more

如果是1.7的jdk,那麼報OOM的將是PermGen區域。

 

6. 直接記憶體

直接記憶體並不是虛擬機器執行時資料區的一部分,也不是Java虛擬機器規範中定義的記憶體區域。但是這部分記憶體也被頻繁地使用,而且也可能導致記憶體溢位問題。JDK1.4中新增加了NIO,引入了一種基於通道與緩衝區的I/O方式,它可以使用Native函式庫直接分配堆外記憶體,然後通過一個儲存在Java堆中的DirectByteBuffer物件作為這塊記憶體的引用進行操作。這樣能在一些場景中顯著提高效能,因為避免了在Java堆和Native堆中來回複製資料。顯然,本機直接記憶體的分配不會受到Java堆大小的限制,但是,既然是記憶體,肯定還是會受到本機總記憶體(包括RAM、SWAP區)大小以及處理器定址空間的限制。

  • NIO可以使用Native函式庫直接分配堆外記憶體,堆中的DirectByteBuffer物件作為這塊記憶體的引用進行操作;
  • 大小不受Java堆大小的限制,受本機(伺服器)記憶體限制;
  • OutOfMemoryError異常:系統記憶體不足時。


-----------------------------------------------------------------

 

本文首先簡單介紹了垃圾收集的常見方式,然後再分析了G1收集器的收集原理,相比其他垃圾收集器的優勢,最後給出了一些調優實踐。

一,什麼是垃圾回收

首先,在瞭解G1之前,我們需要清楚的知道,垃圾回收是什麼?簡單的說垃圾回收就是回收記憶體中不再使用的物件。

垃圾回收的基本步驟

回收的步驟有2步:

  1. 查詢記憶體中不再使用的物件
  2. 釋放這些物件佔用的記憶體

1,查詢記憶體中不再使用的物件

那麼問題來了,如何判斷哪些物件不再被使用呢?我們也有2個方法:

  1. 引用計數法

引用計數法就是如果一個物件沒有被任何引用指向,則可視之為垃圾。這種方法的缺點就是不能檢測到環的存在。

2.根搜尋演算法

根搜尋演算法的基本思路就是通過一系列名為”GC Roots”的物件作為起始點,從這些節點開始向下搜尋,搜尋所走過的路徑稱為引用鏈(Reference Chain),當一個物件到GC Roots沒有任何引用鏈相連時,則證明此物件是不可用的。

現在我們已經知道如何找出垃圾物件了,如何把這些物件清理掉呢?

2. 釋放這些物件佔用的記憶體

常見的方式有複製或者直接清理,但是直接清理會存在記憶體碎片,於是就會產生了清理再壓縮的方式。

總得來說就產生了三種類型的回收演算法。

1.標記-複製

它將可用記憶體容量劃分為大小相等的兩塊,每次只使用其中的一塊。當這一塊用完之後,就將還存活的物件複製到另外一塊上面,然後在把已使用過的記憶體空間一次理掉。它的優點是實現簡單,效率高,不會存在記憶體碎片。缺點就是需要2倍的記憶體來管理。

2.標記-清理

標記清除演算法分為“標記”和“清除”兩個階段:首先標記出需要回收的物件,標記完成之後統一清除物件。它的優點是效率高,缺點是容易產生記憶體碎片。

3.標記-整理

標記操作和“標記-清理”演算法一致,後續操作不只是直接清理物件,而是在清理無用物件完成後讓所有 存活的物件都向一端移動,並更新引用其物件的指標。因為要移動物件,所以它的效率要比“標記-清理”效率低,但是不會產生記憶體碎片。

基於分代的假設

由於物件的存活時間有長有短,所以對於存活時間長的物件,減少被gc的次數可以避免不必要的開銷。這樣我們就把記憶體分成新生代和老年代,新生代存放剛建立的和存活時間比較短的物件,老年代存放存活時間比較長的物件。這樣每次僅僅清理年輕代,老年代僅在必要時時再做清理可以極大的提高GC效率,節省GC時間。

java垃圾收集器的歷史

第一階段,Serial(序列)收集器

在jdk1.3.1之前,java虛擬機器僅僅能使用Serial收集器。 Serial收集器是一個單執行緒的收集器,但它的“單執行緒”的意義並不僅僅是說明它只會使用一個CPU或一條收集執行緒去完成垃圾收集工作,更重要的是在它進行垃圾收集時,必須暫停其他所有的工作執行緒,直到它收集結束。

PS:開啟Serial收集器的方式

-XX:+UseSerialGC

第二階段,Parallel(並行)收集器

Parallel收集器也稱吞吐量收集器,相比Serial收集器,Parallel最主要的優勢在於使用多執行緒去完成垃圾清理工作,這樣可以充分利用多核的特性,大幅降低gc時間。

PS:開啟Parallel收集器的方式

-XX:+UseParallelGC -XX:+UseParallelOldGC

第三階段,CMS(併發)收集器

CMS收集器在Minor GC時會暫停所有的應用執行緒,並以多執行緒的方式進行垃圾回收。在Full GC時不再暫停應用執行緒,而是使用若干個後臺執行緒定期的對老年代空間進行掃描,及時回收其中不再使用的物件。

PS:開啟CMS收集器的方式

-XX:+UseParNewGC -XX:+UseConcMarkSweepGC

第四階段,G1(併發)收集器

G1收集器(或者垃圾優先收集器)的設計初衷是為了儘量縮短處理超大堆(大於4GB)時產生的停頓。相對於CMS的優勢而言是記憶體碎片的產生率大大降低。

PS:開啟G1收集器的方式

-XX:+UseG1GC

二,瞭解G1

G1的第一篇paper(附錄1)發表於2004年,在2012年才在jdk1.7u4中可用。oracle官方計劃在jdk9中將G1變成預設的垃圾收集器,以替代CMS。為何oracle要極力推薦G1呢,G1有哪些優點?

首先,G1的設計原則就是簡單可行的效能調優

開發人員僅僅需要宣告以下引數即可:

-XX:+UseG1GC -Xmx32g -XX:MaxGCPauseMillis=200

其中-XX:+UseG1GC為開啟G1垃圾收集器,-Xmx32g 設計堆記憶體的最大記憶體為32G,-XX:MaxGCPauseMillis=200設定GC的最大暫停時間為200ms。如果我們需要調優,在記憶體大小一定的情況下,我們只需要修改最大暫停時間即可。

其次,G1將新生代,老年代的物理空間劃分取消了。

這樣我們再也不用單獨的空間對每個代進行設定了,不用擔心每個代記憶體是否足夠。

取而代之的是,G1演算法將堆劃分為若干個區域(Region),它仍然屬於分代收集器。不過,這些區域的一部分包含新生代,新生代的垃圾收集依然採用暫停所有應用執行緒的方式,將存活物件拷貝到老年代或者Survivor空間。老年代也分成很多區域,G1收集器通過將物件從一個區域複製到另外一個區域,完成了清理工作。這就意味著,在正常的處理過程中,G1完成了堆的壓縮(至少是部分堆的壓縮),這樣也就不會有cms記憶體碎片問題的存在了。

在G1中,還有一種特殊的區域,叫Humongous區域。 如果一個物件佔用的空間超過了分割槽容量50%以上,G1收集器就認為這是一個巨型物件。這些巨型物件,預設直接會被分配在年老代,但是如果它是一個短期存在的巨型物件,就會對垃圾收集器造成負面影響。為了解決這個問題,G1劃分了一個Humongous區,它用來專門存放巨型物件。如果一個H區裝不下一個巨型物件,那麼G1會尋找連續的H分割槽來儲存。為了能找到連續的H區,有時候不得不啟動Full GC。

PS:在java 8中,持久代也移動到了普通的堆記憶體空間中,改為元空間。

物件分配策略

說起大物件的分配,我們不得不談談物件的分配策略。它分為3個階段:

  1. TLAB(Thread Local Allocation Buffer)執行緒本地分配緩衝區
  2. Eden區中分配
  3. Humongous區分配

TLAB為執行緒本地分配緩衝區,它的目的為了使物件儘可能快的分配出來。如果物件在一個共享的空間中分配,我們需要採用一些同步機制來管理這些空間內的空閒空間指標。在Eden空間中,每一個執行緒都有一個固定的分割槽用於分配物件,即一個TLAB。分配物件時,執行緒之間不再需要進行任何的同步。

對TLAB空間中無法分配的物件,JVM會嘗試在Eden空間中進行分配。如果Eden空間無法容納該物件,就只能在老年代中進行分配空間。

最後,G1提供了兩種GC模式,Young GC和Mixed GC,兩種都是Stop The World(STW)的。下面我們將分別介紹一下這2種模式。

三,G1 Young GC

Young GC主要是對Eden區進行GC,它在Eden空間耗盡時會被觸發。在這種情況下,Eden空間的資料移動到Survivor空間中,如果Survivor空間不夠,Eden空間的部分資料會直接晉升到年老代空間。Survivor區的資料移動到新的Survivor區中,也有部分資料晉升到老年代空間中。最終Eden空間的資料為空,GC停止工作,應用執行緒繼續執行。

這時,我們需要考慮一個問題,如果僅僅GC 新生代物件,我們如何找到所有的根物件呢? 老年代的所有物件都是根麼?那這樣掃描下來會耗費大量的時間。於是,G1引進了RSet的概念。它的全稱是Remembered Set,作用是跟蹤指向某個heap區內的物件引用。

在CMS中,也有RSet的概念,在老年代中有一塊區域用來記錄指向新生代的引用。這是一種point-out,在進行Young GC時,掃描根時,僅僅需要掃描這一塊區域,而不需要掃描整個老年代。

但在G1中,並沒有使用point-out,這是由於一個分割槽太小,分割槽數量太多,如果是用point-out的話,會造成大量的掃描浪費,有些根本不需要GC的分割槽引用也掃描了。於是G1中使用point-in來解決。point-in的意思是哪些分割槽引用了當前分割槽中的物件。這樣,僅僅將這些物件當做根來掃描就避免了無效的掃描。由於新生代有多個,那麼我們需要在新生代之間記錄引用嗎?這是不必要的,原因在於每次GC時,所有新生代都會被掃描,所以只需要記錄老年代到新生代之間的引用即可。

需要注意的是,如果引用的物件很多,賦值器需要對每個引用做處理,賦值器開銷會很大,為了解決賦值器開銷這個問題,在G1 中又引入了另外一個概念,卡表(Card Table)。一個Card Table將一個分割槽在邏輯上劃分為固定大小的連續區域,每個區域稱之為卡。卡通常較小,介於128到512位元組之間。Card Table通常為位元組陣列,由Card的索引(即陣列下標)來標識每個分割槽的空間地址。預設情況下,每個卡都未被引用。當一個地址空間被引用時,這個地址空間對應的陣列索引的值被標記為”0″,即標記為髒被引用,此外RSet也將這個陣列下標記錄下來。一般情況下,這個RSet其實是一個Hash Table,Key是別的Region的起始地址,Value是一個集合,裡面的元素是Card Table的Index。

Young GC 階段:

  • 階段1:根掃描
    靜態和本地物件被掃描
  • 階段2:更新RS
    處理dirty card佇列更新RS
  • 階段3:處理RS
    檢測從年輕代指向年老代的物件
  • 階段4:物件拷貝
    拷貝存活的物件到survivor/old區域
  • 階段5:處理引用佇列
    軟引用,弱引用,虛引用處理

四,G1 Mix GC

Mix GC不僅進行正常的新生代垃圾收集,同時也回收部分後臺掃描執行緒標記的老年代分割槽。

它的GC步驟分2步:

  1. 全域性併發標記(global concurrent marking)
  2. 拷貝存活物件(evacuation)

在進行Mix GC之前,會先進行global concurrent marking(全域性併發標記)。 global concurrent marking的執行過程是怎樣的呢?

在G1 GC中,它主要是為Mixed GC提供標記服務的,並不是一次GC過程的一個必須環節。global concurrent marking的執行過程分為五個步驟:

  • 初始標記(initial mark,STW)
    在此階段,G1 GC 對根進行標記。該階段與常規的 (STW) 年輕代垃圾回收密切相關。
  • 根區域掃描(root region scan)
    G1 GC 在初始標記的存活區掃描對老年代的引用,並標記被引用的物件。該階段與應用程式(非 STW)同時執行,並且只有完成該階段後,才能開始下一次 STW 年輕代垃圾回收。
  • 併發標記(Concurrent Marking)
    G1 GC 在整個堆中查詢可訪問的(存活的)物件。該階段與應用程式同時執行,可以被 STW 年輕代垃圾回收中斷
  • 最終標記(Remark,STW)
    該階段是 STW 回收,幫助完成標記週期。G1 GC 清空 SATB 緩衝區,跟蹤未被訪問的存活物件,並執行引用處理。
  • 清除垃圾(Cleanup,STW)
    在這個最後階段,G1 GC 執行統計和 RSet 淨化的 STW 操作。在統計期間,G1 GC 會識別完全空閒的區域和可供進行混合垃圾回收的區域。清理階段在將空白區域重置並返回到空閒列表時為部分併發。

三色標記演算法

提到併發標記,我們不得不瞭解併發標記的三色標記演算法。它是描述追蹤式回收器的一種有用的方法,利用它可以推演回收器的正確性。 首先,我們將物件分成三種類型的。

  • 黑色:根物件,或者該物件與它的子物件都被掃描
  • 灰色:物件本身被掃描,但還沒掃描完該物件中的子物件
  • 白色:未被掃描物件,掃描完成所有物件之後,最終為白色的為不可達物件,即垃圾物件

當GC開始掃描物件時,按照如下圖步驟進行物件的掃描:

根物件被置為黑色,子物件被置為灰色。

繼續由灰色遍歷,將已掃描了子物件的物件置為黑色。

遍歷了所有可達的物件後,所有可達的物件都變成了黑色。不可達的物件即為白色,需要被清理。

這看起來很美好,但是如果在標記過程中,應用程式也在執行,那麼物件的指標就有可能改變。這樣的話,我們就會遇到一個問題:物件丟失問題

我們看下面一種情況,當垃圾收集器掃描到下面情況時:

這時候應用程式執行了以下操作:

A.c=C
B.c=null

這樣,物件的狀態圖變成如下情形:

這時候垃圾收集器再標記掃描的時候就會下圖成這樣:

很顯然,此時C是白色,被認為是垃圾需要清理掉,顯然這是不合理的。那麼我們如何保證應用程式在執行的時候,GC標記的物件不丟失呢?有如下2中可行的方式:

  1. 在插入的時候記錄物件
  2. 在刪除的時候記錄物件

剛好這對應CMS和G1的2種不同實現方式:

在CMS採用的是增量更新(Incremental update),只要在寫屏障(write barrier)裡發現要有一個白物件的引用被賦值到一個黑物件 的欄位裡,那就把這個白物件變成灰色的。即插入的時候記錄下來。

在G1中,使用的是STAB(snapshot-at-the-beginning)的方式,刪除的時候記錄所有的物件,它有3個步驟:

1,在開始標記的時候生成一個快照圖示記存活物件

2,在併發標記的時候所有被改變的物件入隊(在write barrier裡把所有舊的引用所指向的物件都變成非白的)

3,可能存在遊離的垃圾,將在下次被收集

這樣,G1到現在可以知道哪些老的分割槽可回收垃圾最多。 當全域性併發標記完成後,在某個時刻,就開始了Mix GC。這些垃圾回收被稱作“混合式”是因為他們不僅僅進行正常的新生代垃圾收集,同時也回收部分後臺掃描執行緒標記的分割槽。混合式垃圾收集如下圖:

混合式GC也是採用的複製的清理策略,當GC完成後,會重新釋放空間。

至此,混合式GC告一段落了。下一小節我們講進入調優實踐。

五,調優實踐

MaxGCPauseMillis調優

前面介紹過使用GC的最基本的引數:

-XX:+UseG1GC -Xmx32g -XX:MaxGCPauseMillis=200

前面2個引數都好理解,後面這個MaxGCPauseMillis引數該怎麼配置呢?這個引數從字面的意思上看,就是允許的GC最大的暫停時間。G1儘量確保每次GC暫停的時間都在設定的MaxGCPauseMillis範圍內。 那G1是如何做到最大暫停時間的呢?這涉及到另一個概念,CSet(collection set)。它的意思是在一次垃圾收集器中被收集的區域集合。

  • Young GC:選定所有新生代裡的region。通過控制新生代的region個數來控制young GC的開銷。
  • Mixed GC:選定所有新生代裡的region,外加根據global concurrent marking統計得出收集收益高的若干老年代region。在使用者指定的開銷目標範圍內儘可能選擇收益高的老年代region。

在理解了這些後,我們再設定最大暫停時間就好辦了。 首先,我們能容忍的最大暫停時間是有一個限度的,我們需要在這個限度範圍內設定。但是應該設定的值是多少呢?我們需要在吞吐量跟MaxGCPauseMillis之間做一個平衡。如果MaxGCPauseMillis設定的過小,那麼GC就會頻繁,吞吐量就會下降。如果MaxGCPauseMillis設定的過大,應用程式暫停時間就會變長。G1的預設暫停時間是200毫秒,我們可以從這裡入手,調整合適的時間。

其他調優引數

-XX:G1HeapRegionSize=n

設定的 G1 區域的大小。值是 2 的冪,範圍是 1 MB 到 32 MB 之間。目標是根據最小的 Java 堆大小劃分出約 2048 個區域。

-XX:ParallelGCThreads=n

設定 STW 工作執行緒數的值。將 n 的值設定為邏輯處理器的數量。n 的值與邏輯處理器的數量相同,最多為 8。

如果邏輯處理器不止八個,則將 n 的值設定為邏輯處理器數的 5/8 左右。這適用於大多數情況,除非是較大的 SPARC 系統,其中 n 的值可以是邏輯處理器數的 5/16 左右。

-XX:ConcGCThreads=n

設定並行標記的執行緒數。將 n 設定為並行垃圾回收執行緒數 (ParallelGCThreads) 的 1/4 左右。

-XX:InitiatingHeapOccupancyPercent=45

設定觸發標記週期的 Java 堆佔用率閾值。預設佔用率是整個 Java 堆的 45%。

避免使用以下引數:

避免使用 -Xmn 選項或 -XX:NewRatio 等其他相關選項顯式設定年輕代大小。固定年輕代的大小會覆蓋暫停時間目標。

觸發Full GC

在某些情況下,G1觸發了Full GC,這時G1會退化使用Serial收集器來完成垃圾的清理工作,它僅僅使用單執行緒來完成GC工作,GC暫停時間將達到秒級別的。整個應用處於假死狀態,不能處理任何請求,我們的程式當然不希望看到這些。那麼發生Full GC的情況有哪些呢?

  • 併發模式失敗

G1啟動標記週期,但在Mix GC之前,老年代就被填滿,這時候G1會放棄標記週期。這種情形下,需要增加堆大小,或者調整週期(例如增加執行緒數-XX:ConcGCThreads等)。

  • 晉升失敗或者疏散失敗

G1在進行GC的時候沒有足夠的記憶體供存活物件或晉升物件使用,由此觸發了Full GC。可以在日誌中看到(to-space exhausted)或者(to-space overflow)。解決這種問題的方式是:

a,增加 -XX:G1ReservePercent 選項的值(並相應增加總的堆大小),為“目標空間”增加預留記憶體量。

b,通過減少 -XX:InitiatingHeapOccupancyPercent 提前啟動標記週期。

c,也可以通過增加 -XX:ConcGCThreads 選項的值來增加並行標記執行緒的數目。

  • 巨型物件分配失敗

當巨型物件找不到合適的空間進行分配時,就會啟動Full GC,來釋放空間。這種情況下,應該避免分配大量的巨型物件,增加記憶體或者增大-XX:G1HeapRegionSize,使巨型物件不再是巨型物件。

由於篇幅有限,G1還有很多調優實踐,在此就不一一列出了,大家在平常的實踐中可以慢慢探索。最後,期待java 9能正式釋出,預設使用G1為垃圾收集器的java效能會不會又提高呢?

附錄:

(1),The original G1 paper: Detlefs, D., Flood, C., Heller, S., and Printezis, T. 2004. Garbage-first garbage collection. In Proceedings of the 4th international Symposium on Memory Management (Vancouver, BC, Canada, October 24 – 25, 2004)

 

================== End