java垃圾回收及gc全面解析(全面覆蓋cms、並行gc、g1、zgc、openj9)


  一般來說,gc的停頓時間和活躍對象的堆大小成比例,視gc線程的數量,每1GB可能會停頓1-3秒,且cpu數量通常和gc呈現阿姆達爾定律(Amdahl’s Law),而非我們直觀計算的線性變化。如下:

  

   體現在gc中的時候,不同cpu數量下的gc成本如下:

  

  使用不同類型的gc將會在停頓和吞吐量之間發生很大的變化(一般都是基於這兩個目標之一),不恰當的設置gc甚至可能會導致OOM,但是無論如何都不會存在一個最好的gc,就如linux的cpu調度算法一樣,不同的負載類型下都有最好的gc,但是沒有打遍天下無敵手的招式。包括azul引以為傲的C4(采用連續性並發壓縮實現,完全無STW)也一樣,停頓幾乎消失了,但是吞吐量降下來了。如下:

  

  

    要理解GC機制,可以從:“GC的區域在哪里”,“GC的對象是什么”,“GC的時機是什么”,“GC做了哪些事”幾方面來分析。

  和gc相關的核心定義包括:

  • Concurrent collector:並發收集器,指的是和應用線程一起運行,不會發生STW(stop the world)。
  • Generational Concurrent Garbage Collector:分代並發gc,適合於大量對象朝生夕死,即OLTP環境。如下:

  

 

   下面是IBM JDK的截圖,更細,但是可讀性略差。

  

  IBM OPENJ9 JVM

  

   ORACLE HOTSPOT JVM,這個鏈接對GC描述的圖畫的最形象

  • Parallel collector:指的是多線程。
  • Stop-the-world (STW):和Concurrent collector相反,垃圾收集期間,應用線程全部停止。
  • Incremental:增量gc,采用分而治之的算法實現。
  • Moving:垃圾回收期在gc期間移動存活的對象,並更新指向它們的引用。
  • parallelNew:一個新生代收集器,CMS默認的新生代收集器,使用復制算法(如果大量對象不能朝生暮死(一般來說每次Min GC就能干掉大部分,具體間隔見下文“合適發生GC”,也可以使用參數GCPauseIntervalMillis設置最小間隔),不直接在eden區分配就非常重要,否則gc會很厲害)。
  • Remeber-Set:不同分代/Region之間對象引用關系的存儲容器,所以操作的時候需要維護Remeber-Set。
  • Scanvenge:用於新生代掃描。
  • parallelScavenge:一個新生代收集器,也使用復制算法,目標是吞吐量優先,而不是響應時間(見下文核心參數一節)。
  • parallelOld:一個老年代收集器,目標是吞吐量優先,而不是響應時間(見下文核心參數一節)。parallelScavenge+parallelOld=parallelgc,1.6版本開始提供。

  GC的步驟。總體來說,gc的過程分為下列幾步:

  • Mark:標記。現在gc一般采用是否有指向GC根(gc root,可作為GC root的對象哪些?其實JVM規范其實給了答案。1、系統類加載器加載的類;2、活躍線程持有的對象;調用棧(包括JVM棧、本地方法棧)持有的對象;3、常量引用的對象;4、靜態屬性實體引用的對象。IBM、ECLIPSE給的都是這個范圍,見:https://www.ibm.com/support/knowledgecenter/en/SS3KLZ/com.ibm.java.diagnostics.memory.analyzer.doc/gcroots.html、https://help.eclipse.org/2020-03/index.jsp?topic=%2Forg.eclipse.mat.ui.help%2Fconcepts%2Fgcroots.html、https://www.ibm.com/support/knowledgecenter/en/SS3KLZ/com.ibm.java.diagnostics.memory.analyzer.doc/path_to_gcroots.html)決定是否應該回收對象,以便正確識別相互引用的對象。它的工作量跟對象數有關、跟對象大小無關,因為理論上所有可到達的對象都要遍歷一遍,只不過大多數jvm實現的時候采用分代區域管理對象,因此掃描的對象大大減少。由於大多數對象都是請求級臨時性的,所以大多數很快就會回收,所以eden區留下需要每次gc時重復檢測的就很少了(如果很長時間駐留內存的,說明需要評估是否應該采用堆外存儲)(注:老年代由於存活時間長,所以不會采用拷貝這種算法,而是采用修改引用移動+指針實現)。原因如下:

  

   

  由於在標記過程中,引用關系是會變的,主要是原來不引用的、現在引用了,所以gc一般采用寫屏障來跟蹤這些變化。

  • Sweep:清理。它跟堆大小有關,無論如何都要掃描一遍。
  • Compact:壓縮。這一步通常只能STW。大多數商業gc為了盡可能降低延遲,這一步通常選擇盡可能的延后執行,除非碎片太大,否則不進行壓縮。

  不同的垃圾回收器會采用不同方式實現,有的完全分三步,有的合並某些部分,但總體為標記-清理、復制、標記-壓縮這三種算法。不同的實現會導致不同的后果,包括gc占用的額外內存大小、速度、堆碎片、gc速度等等。

常用算法

  三種算法的由來及關系,標記-清除,復制,標記-整理。

何時發生GC

  1、eden區不夠,且對象不直接在old區分配(受-XX:PretenureSizeThreshold控制),則虛擬機發起Minor GC。

  2、old區超過給定閾值(由參數InitiatingHeapOccupancyPercent控制(G1專有,該參數的驗證),默認為45%,可配置)或擔保分配失敗,程序調用System.gc,虛擬機發起Mixed GC(前者其實很難模擬)或Full GC。

核心參數

  命令java -XX:+UnlockDiagnosticVMOptions -XX:+PrintFlagsFinal -version可以打印所有jvm參數默認值,也可以啟動時帶上。有好幾百個選項,比oracle優化器提示以及隱含參數還多。

  -XX:MaxGCPauseMillis=<nnn>:提示優化器應努力達到的最大暫停時間,gc會據此調整堆棧大小以及gc頻率、其它參數,但是它和數據庫優化器提示一樣,只是盡力遵守。

  -XX:GCTimeRatio=nnn:提示優化器應努力達到的最大gc時間占比。公式為1/ (1+nnn)。設置為19代表最多5%用於gc。如果該時間無法達到,gc會考慮加大堆大小(默認初始:1/64物理內存,最大1/4物理內存),推薦優先使用它代替-XX:MaxGCPauseMillis

  -XX:UseAdapativeSizePolicy。讓gc自動根據上面兩個參數的大小動態調整新生代(eden、survivor)、老年代的大小。

  -XX:+UseTLAB:是否啟用線程本地分配緩沖(類似oracle的private redo buffer),能夠降低分配鎖爭用。

  -XX:PretenureSizeThreshold:超過多大的對象直接的old區分配,默認為0,首先在eden區分配。這個就得看情況了,一般來說大對象不應該朝生暮死,但是有些批處理系統就比較復雜了,設置該值的仔細測試,因為有可能每次請求也需要處理很大的數據;就OLTP而言,該值應該設置。

  -XX:NewRatio=2:老生代/新年代默認比例。

  -XX:SurvivorRatio:控制Eden和Survivor的比例舊生代。

  -XX:NewSize=2m:新年代默認大小,新生代大小對性能的影響可見https://blogs.oracle.com/poonam/can-young-generation-size-impact-the-application-response-times。

  -XX:MaxNewSize=2m:新年代最大大小。-Xmn,相當於NewSize和MaxNewSize同時設置了,如果要設置新生代,推薦使用Xmn

  -XX:ParallelGCThreads=cpu count/jvm數量。設置並行gc線程數,在多JVM環境中,一定要小於cpu count/jvm數量。在超大核心的服務器中,也盡量不超過內存GB/4或2。

  如果堆已經是最大大小,但是吞吐量未達到預想,說明堆最大值太小,比如默認值;如果吞吐量達到了,但是暫停太長,可以設置最大暫停時間。但是它倆通常無法同時100%滿足,需要取舍(當然如果系統負載很低,通常都能達到。所以重點是負載高的時候)。

  影響gc的核心因素是堆大小以及年輕代/老年代的比例。

  默認情況下,如果服務器線程數小於8,則gc線程數量為8;如果大於8,則為5/8(在某些特殊環境中,則為5/16)。當使用多個gc線程時,堆會產生一些碎片,因為每個gc線程都會都老年代划一部分空間用於臨時存儲從年輕代移動到老年代的對象(此舉是為了降低堆分配的競爭),gc線程數越少、意味着碎片也越少。

主流GC及其實現

  • HotSpot ParallelGC:jdk 8的默認gc,吞吐量優先。對新生代對象的拷貝(mark-copy)使用STW,對老年代的Mark/Sweep/Compact(mark-sweep-compact)三步驟均采用STW實現,無論新生代還是老年代,都是STW。它和ParallelOldGC的區別在於Compact也並發進行,而非串行進行。其各目標的優先級分別是:1, 首先滿足暫停時間目標;2, 滿足吞吐量目標; 3, 最后考慮最小化堆大小。
  • Concurrent Mark/Sweep collector (CMS):jdk1.5引入(jdk 14中徹底刪除了cms,jdk 9標記為deprecated)。並發標記(准確的說,又分為初始標記、並發標記、重新標記,第1、3通常需要STW)、清理收集器,響應時間優先。其目標是盡可能對老年代的回收並發進行,並避免壓縮,最小化延遲。但是一旦老年代碎片化太嚴重,壓縮就需要STW。新生代和ParallelGC一樣,拷貝采用STW(在JDK9中被標記為過期,JDK14中移除)。CMS的過程如下:

  

 

   其中初始標記和重新標記速度一般非常快,並發標記則慢得多。因為GC過程中用戶線程仍然運行,所以CMS的一個缺點是有些不再使用的對象會遺留到下一次才會被回收。當然還有一些和老年代碎片相關的問題也需要注意,在jdk 8u100+之后,g1應該來說要比cms合適了,這里就不細講了,有興趣可以一個個參數研究一下。

  • Shenandoah GC:JDK12新增的gc。它是redhat旗下的一個項目,作為JEP 189貢獻給openjdk,不在oracle jdk包內,用於大型配置環境,如20GB以下就不適合使用ShenandoahGC。其evacuation階段工作能通過與正在運行中Java工作線程同時進行(即並發,concurrent),從而減少GC的停頓時間,其主要是為了和zgc以及g1競爭,從其測試來看比g1效果更好,參見https://blog.51cto.com/14230003/2435438。Shenandoah的停頓時間和堆的大小沒有任何關系,這就意味着無論你的堆是200MB,2GB還是200GB,停頓時間是一樣的。可參見https://cloud.tencent.com/developer/article/1405874,https://www.linuxidc.com/Linux/2017-01/139427.htm,http://openjdk.java.net/projects/shenandoah/,https://wiki.openjdk.java.net/display/shenandoah/Main,http://clojure-goes-fast.com/blog/shenandoah-in-production/,https://developers.redhat.com/blog/2019/07/01/shenandoah-gc-in-jdk-13-part-3-architectures-and-operating-systems/,https://shipilev.net/talks/jugbb-Sep2019-shenandoah.pdf,對該gc LZ目前沒有深入研究與測試。

  

 

   https://wiki.openjdk.java.net/display/shenandoah/main

  • g1(Garbage First):其目標是盡可能完全避免full gc,即老年代的STW,優先考慮暫停時間、其次才是吞吐量,所以更像是cms的升級版。它是通過分塊(每塊的大小可以通過-XX:G1HeapRegionSize設置,默認值根據堆初始和最大值自動計算,確保大約有2048塊左右,jvm啟動的時候會在一開始打印出來)gc實現的。-G1PrintRegionLivenessInfo(可打印每次標記后分塊的大小和實際占用) -G1PrintHeapRegions(可在gc中輸出分庫的分配和回收情況)。其布局如下所示:

  

 

   在分代上,不再那么的涇渭分明。

  但是和parallel gc一樣,一旦這些區域碎片太嚴重需要壓縮,壓縮仍然需要STW的方式完成,但是盡可能的避免了region內碎片的產生。新生代和parallelgc以及cms一樣,拷貝也需要STW。在jdk 9中被作為默認gc(OLTP下用於代替CMS效果可以),而不是Parallel GC(吞吐量優先,但一定要設置並行gc數量,否則在大型服務器中負載會巨高),因為g1 gc在回收前會先評估對哪些分塊進行gc能夠得到更高的回收率,因此整體而言,內存需求會比parallel gc要更高,參見https://blogs.oracle.com/poonam/increased-heap-usage-with-g1-gc。

  

  • zgc:jdk 11引入,適用於20GB以上內存(注意,最好不要一個JVM進程的堆分配超過32GB),jdk 13開始支持釋放未使用內存給操作系統。啟用方法:-XX:+UnlockExperimentalVMOptions -XX:+UseZGC,-XX:ZUncommitDelay=<seconds>控制內存釋放的閾值,我們使用它也是為了能夠釋放未使用內存給其他進程或JVM使用。其原理介紹參見https://my.oschina.net/u/943305/blog/1838872,https://zhuanlan.zhihu.com/p/56486728,https://blog.csdn.net/j3t9z7h/article/details/87128403。截止目前除非出於釋放內存目的,否則還不適合生產,見https://www.cnblogs.com/JunFengChan/p/11707360.html。https://wiki.openjdk.java.net/display/zgc/Main,https://hub.packtpub.com/getting-started-with-z-garbage-collectorzgc-in-java-11-tutorial/

          從JDK 14開始,zgc已經是GA特性了,修復了很多的bug,並且比JDK 13更加穩定,參見https://malloc.se/blog/zgc-jdk14。但是論高負載穩定性,G1是JDK 11之后至今最穩定的,畢竟投入最多,見https://jet-start.sh/blog/2020/06/23/jdk-gc-benchmarks-rematch

  • openj9 optthruput或gencon(默認):相當於parallelgc。openj9的優勢在於在內存高度緊張時的延后OOM,在我們多次測試中,有一次因為代碼存在bug,緩存內容過多,其他gc直接啟動不了,換成openj9還能啟動訪問。
  • openj9 optavgpause:有些相當於cms。
  • openj9 balanced:相當於oracle g1。

  捐獻給eclipse基金會后,現在的openj9還可以使用hotspot jvm,意味着可以使用open jdk的gc如zgc。

典型的gc優化策略

  • 參數優化。對gc優化來說,首先最重要的是開啟gc相關的日志(-Xlog:gc*=debug)分別觀察mark、sweep和compact、新生代、老年代的延時以及回收情況,然后確定gc的大小、暫停時間是不是偏高,並判斷相關設置是否最合理。還需要注意的是,不同的m/s/c對內存的要求是不一樣的,內存越少、gc所需時間越長,因此應確保留有一定的空閑內存供gc使用(如何設置???)。尤其是要避免老年代的分配失敗(Allocation Failure),它通常是頻繁的分配大對象所致(在g1中,它要比cms下占用內存更大,可通過jvm選項gc+heap=info在日志中跟蹤該信息,在日志中體現為"Humongous regions: X->Y”),也可能是並發標記不夠快(此時可以通過參數-XX:ConcGCThreads顯示設置標記線程數)。如果是因為System.gc()太多導致且無法避免的話,可以增加參數-XX:+ExplicitGCInvokesConcurrent,讓顯示gc回收並發進行,這樣STW就能夠避免,雖然吞吐量可能會有一些下降(前提是負載足夠高了)。

  • 多JVM。多JVM的缺點是如果使用了一級緩存的話,需要做好同步保障。優點在於每個JVM的GC壓力會大大下降。
  • largePageHeap。雖然JVM參數-XX:+AlwaysPreTouch可以設置讓操作系統預分配內存而不是按需分配,但是其速度會比較慢。因此如果希望JVM內存預分配且常駐內存的話,還不如使用largePageHeap特性(使用largePage的情況下,ZGC是否生效)。
  • 堆外存儲(mapdb、ehcache,https://www.ehcache.org/documentation/2.8/get-started/storage-options.html)。如果很多數據為了提升性能需要在一級緩存中,且數據不是均衡訪問的話(即符合80/20原則),可以考慮堆外緩存和堆內緩存的結合。這樣雖然性能略低於直接存儲在JVM緩存在,但也遠高於在redis中,同時可以大大降低GC的壓力。具體需要詳細測試性能下降的比例,所以它適合於數據量不小的情況,例如超過10萬行。如果堆足夠大的話,足夠容納運行所需的工作區的話,直接在內存中也是可以的。不過最好優先考慮多JVM以及大頁面堆。

GC日志的詳細分析

  不同JVM的gc日志差異比較大,這里主要分析CMS、G1、zgc以及openj9 zgc的日志。不同的gc日志選項輸出的日志內容差異也比較大,詳見gc日志輸出深入解析-覆蓋CMS、並行GC、G1、ZGC、openj9

各種gc及編譯模式下jdk8實際應用啟動的性能對比

  • mixed模式(CMS):[] 2019-12-01 16:41:54 [327768] [c.h.t.t.ProviderStarter]-[INFO] main Started ProviderStarter in 104.406 seconds (JVM running for 104.875)
  • mixed模式(ParallelGC):[] 2019-12-01 16:49:31 [97004] [c.h.t.t.ProviderStarter]-[INFO] main Started ProviderStarter in 94.663 seconds (JVM running for 97.089)
  • -Xcomp模式(ParallelGC):[] 2019-12-01 16:41:54 [327768] [c.h.t.t.ProviderStarter]-[INFO] main Started ProviderStarter in 300.406 seconds (JVM running for 327.875)
  • -XX:CompileThreshold=1000(ParallelGC)(服務器模式默認10000)[] 2019-12-01 16:47:02 [100403] [c.h.t.t.ProviderStarter]-[INFO] main Started ProviderStarter in 97.969 seconds (JVM running for 100.498)
  • mixed模式(ParallelGC, openj9):[] 2019-12-01 17:03:08 [112323] [c.h.t.t.ProviderStarter]-[INFO] main Started ProviderStarter in 108.987 seconds (JVM running for 112.323)

相關參考資料

  • azul Understanding_Java_Garbage_Collection_v4.pdf及PPT

  • HotSpot Virtual Machine Garbage Collection Tuning Guide

  • 深入理解java虛擬機第二版(翻了一下實戰JAVA虛擬機  JVM故障診斷與性能優化,講真的,如果讀者讀過深入理解java虛擬機的話,說翻版也不為過;垃圾回收的算法與實現,不針對jvm,更像是普及型)
  • Frequently Asked Questions about Garbage Collection in the HotspotTM JavaTM Virtual Machine
  • https://docs.oracle.com/javase/8/docs/technotes/guides/vm/gctuning/g1_gc.html
  • http://dinfuehr.github.io/blog/a-first-look-into-zgc/

  • https://blog.csdn.net/jiankunking/article/details/85626279
  • http://www.west999.com/cms/wiki/code/2018-07-20/41686.html
  • https://www.jianshu.com/p/f36ca4e4bd10

 


免責聲明!

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



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