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
  • https://blog.csdn.net/swimming_in_IT_/article/details/63286849 jvm gc种类及算法

c++ gc

为什么 C++ 11 标准不加入 GC 功能呢? 

标准C++为什么没有垃圾回收(Garbage Collection)

https://www.educba.com/c-plus-plus-garbage-collection/

go gc

https://www.researchgate.net/publication/326369017_From_Manual_Memory_Management_to_Garbage_Collection

http://gchandbook.org/editions.html

https://golangpiter.com/system/attachments/files/000/001/718/original/GP_2019_-_An_Insight_Into_Go_Garbage_Collection.pdf?1572419303

Memory Management: Go vs Rust 

posted @ 2019-12-21 15:45  zhjh256  阅读(4580)  评论(0编辑  收藏  举报