java垃圾回收及gc全面解析(全面覆盖cms、g1、zgc、openj9) 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相关的核心定义包括:

  • Concurrent collector:并发收集器,指的是和应用线程一起运行,不会发生STW(stop the world)。
  • 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。
  • parallelScavenge:一个新生代收集器,也使用复制算法,目标是吞吐量优先,而不是响应时间(见下文核心参数一节)。
  • parallelOld:一个老年代收集器,目标是吞吐量优先,而不是响应时间(见下文核心参数一节)。parallelScavenge+parallelOld=parallelgc,1.6版本开始提供。

  GC的步骤。总体来说,gc的过程分为下列几步:

  • Mark:标记。现在gc一般采用是否有指向GC根(gc root,可作为GC root的对象哪些?)决定是否应该回收对象,以便正确识别相互引用的对象。它的工作量跟对象数有关、跟对象大小无关,因为理论上所有可到达的对象都要遍历一遍,只不过大多数jvm实现的时候采用分代区域管理对象,因此扫描的对象大大减少。由于大多数对象都是请求级临时性的,所以大多数很快就会回收,所以eden区留下需要每次gc时重复检测的就很少了(如果很长时间主流内存的,说明需要评估是否应该采用堆外存储)(注:老年代由于存活时间长,所以不会采用拷贝这种算法,而是采用修改引用移动+指针实现)。原因如下:

  

   

  由于在标记过程中,引用关系是会变的,主要是原来不引用的、现在引用了,所以gc一般采用写屏障来跟踪这些变化。

  • Sweep:清理。它跟堆大小有关,无论如何都要扫描一遍。
  • Compact:压缩。这一步通常只能STW。大多数商业gc为了尽可能降低延迟,这一步通常选择尽可能的延后执行,除非碎片太大,否则不进行压缩。

  不同的垃圾回收器会采用不同方式实现,有的完全分三步,有的合并某些部分。不同的实现会导致不同的后果,包括gc占用的额外内存大小、速度、堆碎片、gc速度等等。

何时发生GC

  1、eden区不够,且对象不直接在old区分配,则虚拟机发起Minor GC。

  2、old区超过给定阈值(由参数InitiatingHeapOccupancyPercent控制,默认为45%,可配置)或担保分配失败,虚拟机发起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:UseAdapativeSizePolicy。让gc自动根据上面两个参数的大小动态调整新生代(eden、survivor)、老年代的大小。

  -XX:+UseTLAB:是否启用线程本地分配缓冲(类似oracle的private redo buffer),能够降低分配锁争用。

  -XX:PretenureSizeThreshold:超过多大的对象直接的old区分配,默认为0,首先在eden区分配。这个就得看情况了,一般来说大对象不应该朝生暮死,但是有些批处理系统就比较复杂了,设置该值的仔细测试,就OLTP而言,该值应该设置。

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

  -XX:NewSize=2m:新年代默认大小。

  -XX:MaxNewSize=2m:新年代最大大小。

  如果堆已经是最大大小,但是吞吐量未达到,说明堆最大值太小,比如默认值;如果吞吐量达到了,但是暂停太长,可以设置最大暂停时间。但是它俩通常无法同时100%满足,需要取舍(当然如果系统负载很低,通常都能达到。所以重点是负载高的时候)。

  影响gc的核心因素是堆大小以及年轻代的比例。

  默认情况下,如果服务器线程数小于8,则gc线程数量为8;如果大于8,则为5/8(在某些特殊环境中,则为5/16)。当使用多个gc线程时,堆会产生一些碎片,因为每个gc线程都会都老年代划一部分空间用于临时存储从年轻代移动到老年代的对象(此举是为了降低堆分配的竞争),gc线程数越少、意味着碎片也越少。

主流GC

  • HotSpot ParallelGC:jdk 8的默认gc,吞吐量优先。对新生代对象的拷贝使用STW,对老年代的Mark/Sweep/Compact三步骤均采用STW实现。它和ParallelOldGC的区别在于Compact也并发进行,而非串行进行。其各目标的优先级分别是:1, 首先满足暂停时间目标;2, 满足吞吐量目标; 3, 最后考虑最小化堆大小。
  • Concurrent Mark/Sweep collector (CMS):jdk1.5引入。并发标记(准确的说,又分为初始标记、并发标记、重新标记,第1、3通常需要STW)、清理收集器,响应时间优先。其目标是尽可能对老年代的回收并发进行,并避免压缩,最小化延迟。但是一旦老年代碎片化太严重,压缩就需要STW。新生代和ParallelGC一样,拷贝采用STW(在JDK9中被标记为过期)。CMS的过程如下:

  

   其中初始标记和重新标记速度一般非常快,并发标记则慢得多。因为GC过程中用户线程仍然运行,所以CMS的一个缺点是有些不再使用的对象会遗留到下一次才会被回收。当然还有一些和老年代碎片相关的问题也需要注意,在jdk 8u100+之后,g1应该来说要比cms合适了,这里就不细讲了,有兴趣可以一个个参数研究一下。

  • Shenandoah GC:JDK12新增的gc。其evacuation阶段工作能通过与正在运行中Java工作线程同时进行(即并发,concurrent),从而减少GC的停顿时间,其主要是为了和zgc以及g1竞争,从其测试来看比g1效果更好,参见https://blog.51cto.com/14230003/2435438。Shenandoah的停顿时间和堆的大小没有任何关系,这就意味着无论你的堆是200MB,2GB还是200GB,停顿时间是一样的。
  • g1(Garbage First):其目标是尽可能完全避免full gc,即老年代的STW,优先考虑暂停时间、其次才是吞吐量,所以更像是cms的升级版。它是通过分块(每块的大小可以通过-XX:G1HeapRegionSize设置,默认值根据堆初始和最大值自动计算,确保大约有2048块左右,jvm启动的时候会在一开始打印出来)gc实现的,但是和parallel gc一样,一旦这些区域碎片太严重需要压缩,压缩仍然需要STW的方式完成,但是尽可能的避免了region内碎片的产生。新生代和parallelgc以及cms一样,拷贝也需要STW。在jdk 9中被作为默认gc(OLTP下用于代替CMS效果可以),而不是Parallel GC(吞吐量优先,但一定要设置并行gc数量,否则在大型服务器中负载会巨高)。

  

  • zgc:jdk 11引入,适用于20GB以上内存,jdk 13开始支持释放未使用内存给操作系统。启用方法:-XX:+UnlockExperimentalVMOptions -XX:+UseZGC,-XX:ZUncommitDelay=<seconds>控制内存释放的阈值。其原理介绍参见https://my.oschina.net/u/943305/blog/1838872,https://zhuanlan.zhihu.com/p/56486728,https://blog.csdn.net/j3t9z7h/article/details/87128403
  • openj9 optthruput:相当于parallelgc。
  • openj9 optavgpause或gencon(默认):有些相当于cms。
  • openj9 balanced:相当于oracle g1。

  捐献给eclispse基金会后,现在的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

相关参考资料

  • 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/

猜你喜欢

转载自www.cnblogs.com/zhjh256/p/11877069.html