Java 9中的GC調優基礎

在通過了幾回跳票以後,Java 9終於在原計劃日期的整整一年以後發佈了正式版。Java 9引入了不少新的特性,除了閃瞎眼的Module SystemREPL,最重要的變化我認爲是默認GC(Garbage Collector)修改成新一代更復雜、更全面、性能更好的G1(Garbage-First)。JDK的維護者在GC選擇上一直是比較保守的,G1從JDK 1.6時代就開始進入開發者的視野,直到今天正式成爲Hotspot的默認GC,也是走了很長的路。html

本文將主要講解GC調優須要知道的一些基礎知識,會涉及到一些GC的實現細節,但不會對實現細節作很全面的闡述,若是你看完本文以後,能對GC有一個大體的認識,那本文的寫做目的也就達到了。因爲在此次寫做過程當中,恰逢Java 9正式版發佈,以前都是依賴Java 8的文檔寫的,若是有不正確的地方還望指正。本文將包含如下內容:java

  1. GC的做用範圍
  2. GC負責的事情
  3. JVM中的4種GC
  4. G1的一些細節
  5. 使用Java 9正式版對G1進行測試
  6. 一些簡單的GC調優方法

1、GC的做用範圍

要談GC的做用範圍,首先要談JVM的內存結構,JVM內存中主要有如下幾個區域:堆、方法區(JVM規範中的叫法,Hotspot大體對應的是Metaspace)、棧、本地方法棧、PC等,其中GC主要做用在堆上,以下圖所示:git

JVM內存結構
JVM內存結構

其中堆和方法區是全部線程共享的,其餘則爲線程獨有,HotSpot JVM使用基於分代的垃圾回收機制,因此在堆上又分爲幾個不一樣的區域(在G1中,各年齡代再也不是連續的一整片內存,爲了描述方便,這裏還使用傳統的表示方法),具體以下圖所示:github

JVM堆中的分區
JVM堆中的分區

2、GC負責的事情

GC的發展是隨着JDK(Standard Edition)的發展一步步發展起來的,垃圾回收(Garbage Collection)能夠說是JDK裏最影響性能的行爲了。GC作的事情,說白了就是「經過對內存進行管理,以保障在內存足夠的時候,程序能夠正常的使用內存」。具體而言,GC一般作的事情有如下3個:算法

1. 分配對象和對象的年齡管理

一般而言,GC須要管理「在上圖中的年輕代(Young)分配對象,而後經過一系列的年齡管理,將之銷燬或晉升到老年代(Tenured)中去」的過程。這個過程會伴隨着若干次的Minor GC。安全

對於普通的對象而言,分配內存是一件很簡單並且快速的事情。在對象還未建立時,其所佔內存大小經過類的元數據就能夠肯定,而Eden區域的內存能夠認爲是連續的,因此給對象分配內存要作的只是在上圖中Eden區域中把指針移動相應的長度,並將地址返回給對象的引用便可。固然實際的過程比這個複雜,在下文中會提到。bash

不過,有時候一個對象會直接在老年代中建立,這個點也會在後邊提到。服務器

2. 在老年代中進行標記

老年代的GC算法能夠大體是認爲是一個標記-整理(Mark-Compact,實際上是混合了標記-清理,標記-複製和標記-整理)算法,因此老年代的垃圾清理首先要作的就是在老年代對存活的對象(可達性分析,關於不一樣的可達性能夠參考JDK解構 - Java中的引用和動態代理的實現)進行標記,對於尋求大吞吐量的服務器應用來講,這個過程每每須要是併發的。多線程

標記的過程發生在Major GC被觸發以後,不一樣的GC對於MajorGC的觸發條件和標記過程的實現也不盡相同。併發

3. 在老年代中進行壓縮

在上一條的基礎上,將還存活的對象進行壓縮(CMS和G1的行爲與此有些不一樣之處),壓縮的過程就是將存活的對象從老年代的起點進行挨個複製,使得老年代維持在一片連續的內存中,消除內存碎片,對於內存分配速度的提高會有很大的幫助。

3、GC的種類

Hotspot會根據宿主機的硬件特性和操做系統類型,將之分爲客戶端型(client-class)或者服務器型(server-class),若是是服務器型主機,Java 9以前默認使用Parallel GC,Java 9中默認使用G1。對於服務器型主機的選擇標準是「CPU核心數大於1,內存大於2GB」,因此如今大部分的主機均可以認爲是服務器型主機。

這裏討論的全部GC都是基於分代垃圾回收算法的。

1. Serail

Serail是最先的一款GC,它只使用一個線程來作全部的Minor和Major垃圾回收。它在運行時,其餘全部的事情都會暫停。其工做方式十分簡單,在須要GC的安全點,它會中止全部其餘線程(Stop-The-World),對年輕代進行標記-複製,或對老年代進行標記-整理。

可使用JVM參數-XX:+UseSerialGC來開啓此GC,當使用此參數時,年輕代和老年代將都是用Serial來作垃圾回收。在年輕代使用標記-複製算法,將Eden中存活的對象和非空的Suvivor區(From)中存活的對象複製到空的Suvivor區(To)中去,同時將一部分Suvivor中的對象晉升到老年代去。在老年代則使用標記-整理算法。

看起來Serial古老而簡陋,但在宿主機資源緊張或者JVM堆很小的狀況下(好比堆內存大小隻有不到100M),Serial反而能夠達到更好的效果,由於其餘併發或並行GC都是基於多線程的,會帶來額外的線程切換和線程間通訊的開銷。

2. Parallel/Throughput

Parallel在Java 9以前是服務器型宿主機中JVM的默認GC,其垃圾回收的算法和Serial基本相同,不一樣之處在與它使用多線程來執行。因爲使用了多線程,能夠享受多核CPU帶來的優點,能夠經過參數-XX:+UseParallelGC -XX:+UseParallelOldGC顯示指定。

3. CMS

CMS和G1都屬於「Mostly Concurrent Mark and Sweep Garbage Collector」,可使用-XX:+UseConcMarkSweepGC參數打開。CMS的年輕代垃圾回收使用的是Parallel New來作,其行爲和Parallel中的差很少相同,他們的實現上有一些不一樣的地方,好比Parallel能夠自動調節年輕代中各區的大小,用的是廣度優先搜索等。

老年代使用CMS,CMS的回收和Parallel也基本相似,不一樣點在與,CMS使用的更復雜的可達性分析步驟,而且不是每次都作壓縮的動做,這樣達到的效果就是,Stop-The-World的時長會下降,JVM運行中斷的時間減小,適合在對延遲敏感的場景下使用。

CMS在Java 9中已經被廢棄,但瞭解CMS的行爲對理解G1會有一些幫助,因此這裏仍是會簡單的敘述一下。CMS的步驟大體以下:

  1. 第一次標記
    從GC Roots開始,找到它們在老年代中第一個可達的對象,這些對象或者是直接被GC Roots引用,或者經過年輕代中的對象被GC Roots引用。這一步會Stop-The-World。

  2. 併發標記
    在第一次標記的基礎上,進一步進行可達性分析,從而標記存活的對象。這一步叫「併發」標記,是由於作標記的線程是和應用的工做線程併發執行的,也就是說,這一步不會Stop-The-World。

  3. 第二次標記
    在併發標記的過程當中,因爲程序仍在執行,會致使在併發標記完成後,有一些對象的可達性會發生變化,因此須要再次對他們進行標記。這一步會Stop-The-World。

  4. 清理
    回收不使用的對象,留做之後使用。

CMS的設計比較複雜,因此也帶來了一些問題,好比浮動垃圾(Floating Garbage,指的是在第一步標記可達,但在第二步執行的同時已經不可達的對象),因爲不作老年代壓縮,致使老年代會出現較多的內存碎片。

4. G1

因爲「引入了併發標記」和「不作老年代壓縮」,CMS能夠帶來更好的響應時延表現,但同時也帶來了一些問題。G1自己就是做爲CMS的替代品出現的,在它的使用場景裏,堆再也不是連續的被分爲上文所說的各類代,整個堆會被分爲一個個區域(Region),每一個區域能夠是任何代。以下圖所示:

使用G1的JVM某時刻的堆內存
使用G1的JVM某時刻的堆內存

其中有紅色方框的爲年輕代(標S的爲Survivor區域,其餘爲Eden),其餘藍色底的區域爲老年代(標H的爲大對象區域,用以存儲大對象)。

4、G1的一些細節

G1與以上3種GC相同,也是基於分代的垃圾回收器。它的垃圾回收步驟能夠分爲年輕代回收(Young-only phase,相似於Minor GC)和混合垃圾回收階段(Space-reclamation phase)。下圖是Oracle文檔中對於此兩個階段的示意圖:

jsgct_dt_001_grbgcltncyl.png
jsgct_dt_001_grbgcltncyl.png

G1設計目標和適用對象

G1的設計目標是讓大型的JVM能夠動態的控制GC的行爲以知足用戶配置的性能目標。G1會在平衡吞吐和響應時延的基礎上,儘量的知足用戶的需求。它適用的JVM每每有如下特徵:

  1. 堆的大小可能達到數十G(或者更大),同時存活的對象數量也不少。
  2. 對象的分配和年齡增加的行爲隨着程序的運行不斷的變化
  3. 堆上很容易造成碎片
  4. 要求較少的Stop-The-World暫停時間,一般小於數百毫秒
對G1的行爲進行測試

若是想要看垃圾回收的具體執行過程,可使用虛擬機參數-Xlog:gc*=debug或者-Xlog:gc*=info,前一個會打印更多的細節。注意傳統的VM參數-XX:+PrintGCDetails在Java9中已經廢棄,會有Warning信息。可使用如下代碼中的程序去測試:

static int TOTAL_SIZE = 1024 * 5;
static Object[] floatingObjs= new Object[TOTAL_SIZE];
static LinkedList<Object> immortalObjs = new LinkedList<Object>();
//釋放浮動垃圾
synchronized static void renewFloatingObjs() {
    System.err.println("存活對象滿========================================");
    if (floatingSize + 5 >= TOTAL_SIZE) {
        floatingObjs= new Object[TOTAL_SIZE];
        floatingSize = 0;
    }
}
//添加浮動垃圾
synchronized static void addObjToFloating(Object obj) {
    if (floatingSize++ < TOTAL_SIZE) {
        floatingObjs[floatingSize] = obj;
        if (immortalSize++ < TOTAL_SIZE) {
            immortalObjs.add(obj);
        } else {
            immortalObjs.remove(new Random().nextInt(TOTAL_SIZE));
            immortalObjs.add(obj);
        }
    }
}

public static void main(String[] args) {
    for (int i = 0; i < 10; i++) {
        new Thread(() -> {
            while (true) {
                try {
                    Thread.sleep(1);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                Byte[] garbage = new Byte[1024 * (1 + new Random().nextInt(20))];
                if (new Random().nextInt(20) < 2) {
                    if (floatingSize + 5 >= TOTAL_SIZE) {
                        renewFloatingObjs();
                    }
                    addObjToFloating(garbage);
                }
            }
        }).start();
    }
}複製代碼

在這段代碼中,模擬了常規程序的使用狀況。不斷的生成新的大小不等的對象,這些對象中會有大約10%的機會進入浮動垃圾floatingObjs,浮動垃圾會被按期清除。同時會有一部分的對象進入immortalObjs,這些對象被釋放的機會更少,它們大機率將成爲老年代的常住用戶。

從上邊的測試能夠獲得以下GC日誌1,這是一次完整的年輕代GC,從中能夠看到,默認的區域大小爲1M,同時將開始一次Full GC,其格式大體爲[<虛擬機運行的時長>][<日誌級別>][<標籤>] GC(<GC的標識>) <其餘信息>

//日誌1
[0.014s][info][gc,heap] Heap region size: 1M
//一次完整的年輕代垃圾回收,伴隨着一次暫停
[12.059s][info ][gc,start             ] GC(18) Pause Young (G1 Evacuation Pause)                            
[12.059s][info ][gc,task              ] GC(18) Using 8 workers of 8 for evacuation                            
[12.078s][info ][gc,phases            ] GC(18)   Pre Evacuate Collection Set: 0.0ms                            
[12.078s][info ][gc,phases            ] GC(18)   Evacuate Collection Set: 18.6ms                            
[12.079s][info ][gc,phases            ] GC(18)   Post Evacuate Collection Set: 0.3ms                            
[12.079s][info ][gc,phases            ] GC(18)   Other: 0.3ms                            
[12.079s][info ][gc,heap              ] GC(18) Eden regions: 342->0(315)                            
[12.079s][info ][gc,heap              ] GC(18) Survivor regions: 38->35(48)                            
[12.079s][info ][gc,heap              ] GC(18) Old regions: 425->463                            
[12.079s][info ][gc,heap              ] GC(18) Humongous regions: 0->0                            
[12.078s][debug][gc,ergo,ihop         ] GC(18) Request concurrent cycle initiation (occupancy higher than threshold) occupancy: 485490688B allocation request: 0B threshold: 472331059B (45.00) source: end of GC
[12.078s][debug][gc,ihop              ] GC(18) Basic information (value update), threshold: 472331059B (45.00), target occupancy: 1049624576B, current occupancy: 521069456B, recent allocation size: 20640B, recent allocation duration: 817.38ms, recent old gen allocation rate: 25251.50B/s, recent marking phase length: 0.00ms
[12.078s][debug][gc,ihop              ] GC(18) Adaptive IHOP information (value update), threshold: 472331059B (47.37), internal target occupancy: 997143347B, occupancy: 521069456B, additional buffer size: 367001600B, predicted old gen allocation rate: 318128.08B/s, predicted marking phase length: 0.00ms, prediction active: false
[12.078s][debug][gc,ergo,refine       ] GC(18) Updated Refinement Zones: green: 15, yellow: 45, red: 75
[12.079s][info ][gc,heap              ] GC(18) Eden regions: 342->0(315)
[12.079s][info ][gc,heap              ] GC(18) Survivor regions: 38->35(48)
[12.079s][info ][gc,heap              ] GC(18) Old regions: 425->463
[12.079s][info ][gc,heap              ] GC(18) Humongous regions: 0->0
[12.079s][info ][gc,metaspace         ] GC(18) Metaspace: 5172K->5172K(1056768K)
[12.079s][debug][gc,heap              ] GC(18) Heap after GC invocations=19 (full 0):        
[12.079s][info ][gc                   ] GC(18) Pause Young (G1 Evacuation Pause) 803M->496M(1001M) 19.391ms                                
[12.079s][info ][gc,cpu               ] GC(18) User=0.05s Sys=0.00s Real=0.02s            複製代碼
年輕代回收(Young-only)

對於純粹的年輕代回收,其算法很簡單,與Parallel和CMS的年輕代十分相似,這是一個多線程並行執行的過程,一樣須要Stop-The-World(對應上邊日誌中的Pause Young),停下來全部的工做線程,而後將Eden上存活的對象拷貝到Suvivor區域,這裏會將不少個對象從多個不一樣的區域拷貝到少數的幾個區域內,因此這一步在G1中叫作疏散(Evacuation),同時把Suvivor上觸及年齡閾值的對象晉升到老年代區域。

老年代回收(concurrent cycle)

G1的老年代回收是在老年代空間觸及一個閾值(Initiating Heap Occupancy Percent)以後,這個回收伴隨着年輕代的回收工做,但與上邊所說的回收有些不一樣。

  1. 年輕代回收:伴隨着年輕代的回收工做,同時會執行併發標記和一部分清理的工做,這樣能夠共用年輕代垃圾回收的Stop-The-World。

    1. 第一次標記:對應一次Pause Initial Mark
      和CMS的步驟相似,首先進行第一次標記。但實現方法上有很大的區別,G1首先對當前堆上的對象狀況進行一個虛擬快照(Snapshot-At-The-Beginning),而後根據這個快照對老年代的對象和區域進行標記,並執行以後的垃圾回收。以後像CMS同樣會有併發標記的過程。
      這樣會產生一個問題,在此次回收結束以後,會有些對象在併發標記的過程當中,它的可達性已經變化,致使已經不可達的對象仍然沒有被回收。可是這樣能帶來更好的響應時間。

    2. 從新標記:對應一次Pause Remark
      在這個階段,G1首先完成上一步開始的標記工做,以後會對特殊引用的對象進行處理(具體能夠參考JDK解構 - Java中的引用和動態代理的實現),還有對Metaspace區域進行垃圾回收。這一步會進行Stop-The-World。

    3. 清理:對應一次Pause Cleanup
      這一步主要作的是收集當前堆中的內存區域信息,對空的區域進行回收,爲接下來的空間回收作一些準備工做,清理結束以後,一般會伴隨着一次年輕代回收,若是判斷不須要進行空間回收,則會進入下一個年輕代回收的工做。這一步會進行Stop-The-World。
    1. 混合垃圾回收:對應一次或屢次Pause Mixed
      主要作的是對老年代的區域內存進行疏散(Evacuation),也包含對年輕代的區域回收工做。同時這一步也會動態地調整IHOP

從對G1的GC日誌的分析,能夠看到G1的垃圾回收行爲是基於一個可預測的模型:GC會不斷的主動觸發垃圾回收,在這個過程當中不斷地進行信息統計和系統GC參數的設置,而後將上邊這些步驟安排在這些垃圾回收過程當中。

大對象的分配

正常狀況下,一個對象會在年輕代的Eden中建立,而後經過垃圾回收和年齡管理以後,晉升到老年代。但對於某些比較大的對象,可能會直接分配到老年代去。

對於G1,對象大多數狀況都會在Eden上分配,若是JVM判斷一個對象爲大對象(其閾值能夠經過-XX:G1HeapRegionSize來設置),則會直接分配如老年代的大對象區域中。

對於其餘的內存區域連續的GC,下面是從StackOverflow上搬運過來的對象在堆上的分配過程:

  1. 使用 thread local allocation buffer (TLAB), 若是空間足夠,則分配成功。
    從名稱即可知,TLAB是線程獨佔的,因此線程安全,且速度很是快。若是一個TLAB滿了,線程會被分配一個新的TLAB。

  2. 若是TLAB 空間不夠此次分配對象,但其中還有不少空間可用,則不使用TLAB,直接在Eden中分配對象。
    直接在Eden上分配對象要去搶佔Eden中的指針操做,其代價較使用TLAB要大一些。

  3. 若是Eden的對象分配失敗,出發Minor GC。

  4. 若是Minor GC完成後還不夠,則直接分配到老年代。

一些簡單的GC調優方法

1. 使用不一樣的索引對象

引用的類型會直接影響其所引用對象的GC行爲,當要作一些內存敏感的應用時,能夠參考使用合適的引用類型。具體能夠參考JDK解構 - Java中的引用和動態代理的實現

2. 使用Parallel

從上文中可知,Java 8默認的GC是Parallel,它也叫Throughput,因此它的目的是儘量的增長系統的吞吐量。在Parallel裏,能夠經過參數調節最大中止時間(-XX:MaxGCPauseMillis,默認無設置)和吞吐量(-XX:GCTimeRatio,默認值是99,即最大使用1%的時間來作垃圾回收)來調優GC的行爲。其中設置最大中止時間可能會致使GC調節各年齡代分區的尺寸(經過增量來實現)。

3. 使用G1

從Java 9開始G1變成了默認的GC,G1中有一些細節的概念在上文中沒有敘述,這裏先介紹一下:

  1. Remembered Sets(Rsets):對於每一個區域,都有一個集合記錄這個區域中全部的引用。
    1. G1 refinement:G1中須要有一系列的線程不斷地維護Rsets。
    2. Collection Sets(Csets):在垃圾回收中須要被回收的區域,這些區域中的可達對象(活着的對象)會被疏散。這些區域多是任何年齡代。
    3. 寫屏障(Write Barriers):對於每一次賦值操做,G1都會有兩個寫屏障,寫以前(Pre-Write)一個,寫以後(Post-Write)一個。Pre-write主要與SATB相關,Post-write主要與Rsets相關
    4. Dirty Card Queue:寫屏障會將寫的記錄放入這個隊列,會有線程將這裏的對象不斷的刷入Rsets。
    5. Green/Yellow/Red Zone:三個會影響處理Dirty Card Queue線程數的閾值。根據Dirty Card Queue中元素的個數,能夠來設置一些GC行爲(能夠認爲是邏輯上將Dirty Card Queue分隔成多個區域)。Green表示超過此閾值則開始新建線程來處理這個隊列,Yellow表示超過此閾值,強制啓動這些線程,Red表示超過此閾值則會讓寫操做的線程本身來執行G1 refinement。

G1提供了豐富的基於不一樣目的的可調優的參數,列表以下:

參數 描述
-XX:+G1UseAdaptiveConcRefinement, 調節G1 refinement所使用的資源。
-XX:G1ConcRefinementGreenZone=, 調節G1 refinement所使用的資源。
-XX:G1ConcRefinementYellowZone=, 調節G1 refinement所使用的資源。
-XX:G1ConcRefinementRedZone=, 調節G1 refinement所使用的資源。
-XX:G1ConcRefinementThreads= 調節G1 refinement所使用的資源。
-XX:G1RSetUpdatingPauseTimePercent=10 調節G1 refinement所須要的時間在整個垃圾回收時間的比例,G1會根據這個時間動態地調節第一行的各個參數。
-XX:+ReduceInitialCardMarks 批量執行對象的生成,以減小初始標記的時間
-XX:-ParallelRefProcEnabled 使用多線程處理上文中所說的在從新標記階段對引用的處理
-XX:G1SummarizeRSetStatsPeriod= 設置n次垃圾回收後,打印Rsets的總結性報告。
-XX:GCTimeRatio= 設置GC吞吐量。GC總共應該使用的時間是1 / (1 + n),這個參數會影響不一樣年齡代尺寸的增加。
-XX:G1HeapRegionSize 設置區域的大小

主要參考文檔:

  1. Getting Started with the G1 Garbage Collector
  2. Garbage-First Garbage Collector Tuning
  3. Evaluating and improving remembered sets in the HotSpot G1 garbage collector
  4. G1GC Internals
  5. GC Algorithms: Basics
  6. Java中幾種常量池的區分、、
相關文章
相關標籤/搜索