《Java 9中的GC調優基礎》要點:
本文介紹了Java 9中的GC調優基礎,希望對您有用。如果有疑問,可以聯系我們。
在經過了幾次跳票之后,Java 9終于在原計劃日期的整整一年之后發布了正式版.Java 9引入了很多新的特性,除了閃瞎眼的Module System和REPL,最重要的變化我認為是默認GC(Garbage Collector)修改為新一代更復雜、更全面、性能更好的G1(Garbage-First).JDK的維護者在GC選擇上一直是比較保守的,G1從JDK 1.6時代就開始進入開發者的視野,直到本日正式成為Hotspot的默認GC,也是走了很長的路.
本文將主要講解GC調優需要知道的一些基礎知識,會涉及到一些GC的實現細節,但不會對實現細節做很全面的闡述,如果你看完本文之后,能對GC有一個大致的認識,那本文的寫作目的也就達到了.由于在這次寫作過程中,恰逢Java 9正式版發布,之前都是依賴Java 8的文檔寫的,如果有不正確的地方還望指正.本文將包括以下內容:
GC的作用范圍
GC負責的事情
JVM中的4種GC
G1的一些細節
使用Java 9正式版對G1進行測試
一些簡單的GC調優辦法
一、GC的作用范圍
要談GC的作用范圍,首先要談JVM的內存結構,JVM內存中主要有以下幾個區域:堆、辦法區(JVM規范中的叫法,Hotspot大致對應的是Metaspace)、棧、本地辦法棧、PC等,其中GC主要作用在堆上,如下圖所示:
JVM內存布局
其中堆和辦法區是所有線程共享的,其他則為線程獨有,HotSpot JVM使用基于分代的垃圾回收機制,所以在堆上又分為幾個不同的區域(在G1中,各年齡代不再是連續的一整片內存,為了描述方便,這里還使用傳統的表示辦法),具體如下圖所示:
JVM堆中的分區
二、GC負責的事情
GC的發展是隨著JDK(Standard Edition)的發展一步步發展起來的,垃圾回收(Garbage Collection)可以說是JDK里最影響性能的行為了.GC做的事情,說白了便是「通過對內存進行管理,以保障在內存足夠的時候,程序可以正常的使用內存」.具體而言,GC通常做的事情有以下3個:
1. 分配對象和對象的年齡管理
通常而言,GC需要管理「在上圖中的年輕代(Young)分配對象,然后通過一系列的年齡管理,將之銷毀或晉升到老年代(Tenured)中去」的過程.這個過程會隨同著若干次的Minor GC.
對于普通的對象而言,分配內存是一件很簡單并且快速的事情.在對象還未創建時,其所占內存大小通過類的元數據就可以確定,而Eden區域的內存可以認為是連續的,所以給對象分配內存要做的只是在上圖中Eden區域中把指針移動相應的長度,并將地址返回給對象的引用即可.當然實際的過程比這個復雜,在下文中會提到.
不外,有時候一個對象會直接在老年代中創建,這個點也會在后邊提到.
2. 在老年代中進行標志
老年代的GC算法可以大致是認為是一個標記-整理(Mark-Compact,其實是混合了標記-清理,標記-復制和標記-整理)算法,所以老年代的垃圾清理首先要做的就是在老年代對存活的對象(可達性分析,關于不同的可達性可以參考JDK解構 - Java中的引用和動態代理的實現)進行標記,對于尋求大吞吐量的服務器應用來說,這個過程往往必要是并發的.
標志的過程發生在Major GC被觸發之后,不同的GC對于MajorGC的觸發條件和標志過程的實現也不盡相同.
3. 在老年代中進行壓縮
在上一條的基礎上,將還存活的對象進行壓縮(CMS和G1的行為與此有些不同之處),壓縮的過程就是將存活的對象從老年代的起點進行挨個復制,使得老年代維持在一片連續的內存中,消除內存碎片,對于內存分配速度的提升會有很大的贊助.
三、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的步驟大致如下:
第一次標志
從GC Roots開始,找到它們在老年代中第一個可達的對象,這些對象或者是直接被GC Roots引用,或者通過年輕代中的對象被GC Roots引用.這一步會Stop-The-World.
并發標志
在第一次標記的基礎上,進一步進行可達性分析,從而標記存活的對象.這一步叫「并發」標記,是因為做標記的線程是和應用的工作線程并發執行的,也便是說,這一步不會Stop-The-World.
第二次標志
在并發標記的過程中,由于程序仍在執行,會導致在并發標記完成后,有一些對象的可達性會發生變化,所以必要再次對他們進行標記.這一步會Stop-The-World.
清理
回收不使用的對象,留作以后使用.
CMS的設計比擬復雜,所以也帶來了一些問題,比如浮動垃圾(Floating Garbage,指的是在第一步標記可達,但在第二步執行的同時已經不可達的對象),由于不做老年代壓縮,導致老年代會出現較多的內存碎片.
4. G1
由于「引入了并發標記」和「不做老年代壓縮」,CMS可以帶來更好的響應時延表現,但同時也帶來了一些問題.G1自己就是作為CMS的替代品出現的,在它的使用場景里,堆不再是連續的被分為上文所說的各種代,整個堆會被分為一個個區域(Region),每個區域可以是任何代.如下圖所示:
使用G1的JVM某時刻的堆內存
其中有紅色方框的為年輕代(標S的為Survivor區域,其他為Eden),其他藍色底的區域為老年代(標H的為大對象區域,用以存儲大對象).
四、G1的一些細節
G1與以上3種GC相同,也是基于分代的垃圾回收器.它的垃圾回收步調可以分為年輕代回收(Young-only phase,類似于Minor GC)和混合垃圾回收階段(Space-reclamation phase).下圖是Oracle文檔中對于此兩個階段的示意圖:
jsgct_dt_001_grbgcltncyl.png
G1設計目標和適用對象
G1的設計目標是讓大型的JVM可以動態的控制GC的行為以滿足用戶配置的性能目標.G1會在平衡吞吐和響應時延的基礎上,盡可能的滿足用戶的需求.它適用的JVM往往有以下特征:
堆的大小可能達到數十G(或者更大),同時存活的對象數量也很多.
對象的分配和年齡增長的行為隨著程序的運行賡續的變化
堆上很容易形成碎片
要求較少的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)之后,這個回收隨同著年輕代的回收工作,但與上邊所說的回收有些不同.
年輕代回收:隨同著年輕代的回收工作,同時會執行并發標記和一部分清理的工作,這樣可以共用年輕代垃圾回收的Stop-The-World.
第一次標志:對應一次Pause Initial Mark
和CMS的步驟類似,首先進行第一次標記.但實現辦法上有很大的區別,G1首先對當前堆上的對象情況進行一個虛擬快照(Snapshot-At-The-Beginning),然后根據這個快照對老年代的對象和區域進行標記,并執行之后的垃圾回收.之后像CMS一樣會有并發標記的過程.
這樣會產生一個問題,在這次回收結束之后,會有些對象在并發標志的過程中,它的可達性已經變化,導致已經不可達的對象仍然沒有被回收.但是這樣能帶來更好的響應時間.
重新標志:對應一次Pause Remark
在這個階段,G1首先完成上一步開始的標志工作,之后會對特殊引用的對象進行處理(具體可以參考JDK解構 - Java中的引用和動態代理的實現),還有對Metaspace區域進行垃圾回收.這一步會進行Stop-The-World.
清理:對應一次Pause Cleanup
這一步主要做的是收集當前堆中的內存區域信息,對空的區域進行回收,為接下來的空間回收做一些準備工作,清理結束之后,通常會隨同著一次年輕代回收,如果判斷不需要進行空間回收,則會進入下一個年輕代回收的工作.這一步會進行Stop-The-World.
混合垃圾回收:對應一次或多次Pause Mixed
主要做的是對老年代的區域內存進行疏散(Evacuation),也包括對年輕代的區域回收工作.同時這一步也會動態地調整IHOP
從對G1的GC日志的分析,可以看到G1的垃圾回收行為是基于一個可預測的模型:GC會不斷的主動觸發垃圾回收,在這個過程中不斷地進行信息統計和系統GC參數的設置,然后將上邊這些步驟支配在這些垃圾回收過程中.
大對象的分配
正常情況下,一個對象會在年輕代的Eden中創建,然后通過垃圾回收和年齡管理之后,晉升到老年代.但對于某些比擬大的對象,可能會直接分配到老年代去.
對于G1,對象大多數情況都會在Eden上分配,如果JVM判斷一個對象為大對象(其閾值可以通過-XX:G1HeapRegionSize來設置),則會直接分配如老年代的大對象區域中.
對于其他的內存區域連續的GC,下面是從StackOverflow上搬運過來的對象在堆上的分配過程:
使用 thread local allocation buffer (TLAB), 如果空間足夠,則分配成功.
從名稱便可知,TLAB是線程獨占的,所以線程平安,且速度非常快.如果一個TLAB滿了,線程會被分配一個新的TLAB.
如果TLAB 空間不夠這次分配對象,但其中還有很多空間可用,則不使用TLAB,直接在Eden中分配對象.
直接在Eden上分配對象要去搶占Eden中的指針操作,其代價較使用TLAB要大一些.
如果Eden的對象分配失敗,出發Minor GC.
如果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中有一些細節的概念在上文中沒有敘述,這里先介紹一下:
Remembered Sets(Rsets):對于每個區域,都有一個集合記錄這個區域中所有的引用.
G1 refinement:G1中必要有一系列的線程不斷地維護Rsets.
Collection Sets(Csets):在垃圾回收中必要被回收的區域,這些區域中的可達對象(活著的對象)會被疏散.這些區域可能是任何年齡代.
寫屏障(Write Barriers):對于每一次賦值操作,G1都會有兩個寫屏障,寫之前(Pre-Write)一個,寫之后(Post-Write)一個.Pre-write主要與SATB相關,Post-write主要與Rsets相關
Dirty Card Queue:寫屏障會將寫的記錄放入這個隊列,會有線程將這里的對象賡續的刷入Rsets.
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=<ergo>, | 調節G1 refinement所使用的資源. |
-XX:G1ConcRefinementYellowZone=<ergo>, | 調節G1 refinement所使用的資源. |
-XX:G1ConcRefinementRedZone=<ergo>, | 調節G1 refinement所使用的資源. |
-XX:G1ConcRefinementThreads=<ergo> | 調節G1 refinement所使用的資源. |
-XX:G1RSetUpdatingPauseTimePercent=10 | 調節G1 refinement所需要的時間在整個垃圾回收時間的比例,G1會根據這個時間動態地調節第一行的各個參數. |
-XX:+ReduceInitialCardMarks | 批量執行對象的生成,以減少初始標記的時間 |
-XX:-ParallelRefProcEnabled | 使用多線程處理上文中所說的在重新標記階段對引用的處理 |
-XX:G1SummarizeRSetStatsPeriod=<n> | 設置n次垃圾回收后,打印Rsets的總結性申報. |
-XX:GCTimeRatio=<n> | 設置GC吞吐量.GC總共應該使用的時間是1 / (1 + n),這個參數會影響不同年齡代尺寸的增長. |
-XX:G1HeapRegionSize | 設置區域的大小 |
主要參考文檔:
Getting Started with the G1 Garbage Collector
Garbage-First Garbage Collector Tuning
Evaluating and improving remembered sets in the HotSpot G1 garbage collector
G1GC Internals
GC Algorithms: Basics
Java中幾種常量池的區分、、
歡迎參與《Java 9中的GC調優基礎》討論,分享您的想法,維易PHP學院為您提供專業教程。
轉載請注明本頁網址:
http://www.snjht.com/jiaocheng/7899.html