JVM垃圾回收的Tips

FAQ About JVM GC

Posted by S.L on March 23, 2019

整理了JVM垃圾回收的一些问题

为什么Young Gen适合使用复制算法

一句话:因为Young Gen的特点是大批对象快速死去,仅有少量对象存活。对于复制算法来说,每次复制的内容并不多,成本较低。

为什么是复制算法

一句话:算法简单,效率高,内存分配时也不需要考虑内存碎片等复杂情况,只需要移动指针,按照顺序分配即可 。虽然会浪费一定的空间,但放到合适的位置如Young Gen的Survivor中,则大小可控,因为Young Gen回收后的对象占用很少。

现在的商用虚拟机都采用这种算法来回收新生代,不过研究表明1:1复制的比例非常不科学,因此新生代的内存被划分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden 和其中一块Survivor。 每次回收时,将Eden和Survivor中还存活着的对象一次性复制到另外一块Survivor空间上(无碎片),最后清理掉Eden和刚才用过的Survivor空间,也就无需整理碎片了。

Survivor区的意义

一句话:作为Young Gen和Old Gen之间对象promotion的一个缓冲地带(可以脑补它们夹在Young Gen和Old Gen之间),经过几轮复制后,「扛不住了」再给Old Gen。

如果没有Survivor,Eden每进行一次Minor GC,存活的对象就会进入老年代,老年代很快被填满就会进入Major GC。 由于老年代空间一般很大,所以进行一次GC耗时要长的多,尤其是频繁进行Full GC,对程序的响应和连接都会有影响。 Survivor存在就是减少被送到老年代的对象,进而减少Full GC的发生。默认设置是经历了16次Minor GC还在新生代中存活的对象才会被送到老年代。

那为什么有两个Survivor

一句话:主要是为了解决内存碎片化和效率问题,内存碎片多会影响大对象的分配,导致频繁GC,复制简单,效率高。

如果只有一个Survivor时,每触发一次Minor GC都会有数据从Eden放到Survivor,一直这样循环下去。注意的是,Survivor区也会进行垃圾回收,这样就会出现内存碎片化问题。 碎片化会导致堆中可能没有足够大的连续空间存放一个大对象,影响程序性能。如果有两块Survivor就能将剩余对象集中到其中一块Survivor上,避免碎片问题。

如何调整Survivor的比例

  • -XX:SurvivorRatio:设置年轻代中Eden区与Survivor区的大小比值。默认为8,则两个Survivor区与一个Eden区的比值为2:8,一个Survivor 区占整个年轻代的1/10。

Survivor注意事项

  • -XX:MaxTenuringThreshold:设置垃圾最大年龄,默认15。如果设置为0的话,则年轻代对象不经过Survivor区,直接进入年老代。这是很危险的,因为这会加速Full GC的频率。 对于年老代比较大的应用,可以提高效率。如果将此值设置为一个较大值,则年轻代对象会在Survivor区进行多次复制,这样可以增加对象在年轻代的存活时间,增加在年轻代被回收的概率。

Old Gen为什么不用复制算法

一句话:复制算法在对象存活率较高的场景下要进行大量的复制操作,效率很低。

老年代都是不易被回收的对象,对象存活率高,那么需要有额外的空间进行分配担保(就像Young Gen中的Survivor的作用一样),因此一般不能直接选用复制算法。

为什么用分代收集

一句话:JVM的堆分配和对象的生存周期不同,所以设计了不同的代来对不同的存活对象进行维护,进而还可以采用不同的回收算法,因地制宜。

  • 新生代:大批对象死去、少量对象存活的,使用复制算法,复制成本低,效率高;
  • 年老带:对象存活率高、没有「足够的」额外空间进行分配担保的,采用「标记-清理」算法(如Concurrent-Mark-Sweep)或者「标记-整理」(如Mark-Compact)算法。

GC Root可能包括什么

所谓“GC roots”,或者说tracing GC的“根集合”,就是一组必须活跃的引用,而不是对象。

  • 所有Java线程当前活跃的栈帧里指向GC队里的对象的引用,换句话说,当前所有正在被调用的方法的引用类型的参数、局部变量、临时变量
  • 所有当前被加载的Java类
  • Java类的引用类型静态变量
  • Java类的运行时常量池里的引用类型常量(String或Class类型)
  • String常量池(StringTable)里的引用

Tracing GC的根本思路就是:给定一个集合的引用作为根出发,通过引用关系遍历对象图,能被遍历到的(可到达的)对象 就被判定为存活,其余对象(也就是没有被遍历到的)就自然被判定为死亡。

注意:tracing GC的本质是通过找出所有活对象来把其余空间认定为“无用”,而不是找出所有死掉的对象并回收它们占用的空间。

CMS的并发清理阶段有什么问题

一句话:无法处理因用户线程并发执行时产生的内存垃圾————浮动垃圾。

因为在并发清理阶段(Concurrent Sweeping)用户线程还在运行,自然就会产生新的垃圾,而在此次收集中无法处理掉它们, 只能留到下次GC收集,这部分垃圾为「浮动垃圾」。

同时,由于用户线程并发执行,所以需要预留一部分老年代空间提供并发收集时程序运行使用,所以CMS不能再年老代快用光时才触发。

CMS和Full GC的关系

CMS 不等于Full GC,我们可以看到CMS分为多个阶段,只有stop the world的阶段被计算到了Full GC的次数和时间,而和业务线程并发的GC的次数和时间则不被认为是Full GC。

个人理解Major GC针对Old区,此区域的gc算法包括CMS、G1等。而Full GC的次数是由STW(stop the world)决定的, 则当使用CMS(initial mark、concurrent mark、remark和concurrent sweep)时,对Old区进行gc时,Full GC的个数会加2,因为CMS中STW的次数是2(分别为initial mark和remark阶段)。

CMS的Remark的作用是什么

重新标记(Remark) 的作用在于:

之前在并发标记时,因为是 GC 和用户程序是并发执行的,可能导致一部分已经标记为 从 GC Roots 不可达 的对象,因为用户程序的(并发)运行,又可达 了,Remark 的作用就是将这部分对象又标记为 可达对象。

CMS的Remark阶段时间过长怎么办

在CMS的Remark重新标记阶段的任务是标记整个年老代的所有存活对象,它标记的内存范围是整个堆,包括Young Gen和Old Gen。 由于 YoungGen 存在引用 OldGen 对象的情况,因此 CMS-remark 阶段会将 YoungGen 作为 OldGen 的 “GC ROOTS” 进行扫描,防止回收了不该回收的对象。而配置 -XX:+CMSScavengeBeforeRemark 参数,在 CMS GC 的 CMS-remark 阶段开始前先进行一次 Young GC,有利于减少 Young Gen 对 Old Gen 的无效引用,降低 CMS-remark 阶段的时间开销。

可以开启-XX:+CMSScavengeBeforeRemark和XX:+CMSParallelRemarkEnabled并行收集

这个参数是用在 Remark 停顿太长的情况下。开启这个参数,在Remark之前先做一次Minor GC,减少Young区剩余待标记的对象数量(这些也叫做GC Roots), 因此Remark需要重新标记的数据就会少很多,进而缩短时间。

CMS并发标记阶段与用户线程并发进行,此阶段会产生已经被标记了的对象又发生变化的情况,若打开此开关,可在一定程度上降低CMS 重新标记阶段对上述又发生变化对象的扫描时间,当然,“清除尝试”也会消耗一些时间。

Old Gen如何处理碎片

因为Old Gen由于采用的「标记-清除」算法,并不会进行压缩和整理,故会产生大量的内存碎片,不利于大对象的分配,可能会提前触发一次Full GC,影响运行效率。

虚拟机提供了:

  • -XX:+UseCMSCompactAtFullCollection参数来进行碎片的合并整理过程,这样会使得停顿时间变长,默认是true。
  • -XX:+CMSFullGCsBeforeCompaction=x,用于设置执行多少次的Full GC后会执行一次带压缩的Full GC,默认为0,即每一次Full GC都会进行压缩。

CMSFullGCsBeforeCompaction 说的是,在上一次CMS并发GC执行过后,到底还要再执行多少次Full GC才会做压缩。默认是0,也就是在默认配置下每次CMS GC顶不住了而要转入Full GC的时候都会做压缩。

把CMSFullGCsBeforeCompaction配置为10,就会让上面说的第一个条件变成每隔10次真正的Full GC才做一次压缩(而不是每10次CMS并发GC就做一次压缩,目前VM里没有这样的参数)。这会使Full GC更少做压缩,也就更容易使CMS的old gen受碎片化问题的困扰。

本来这个参数就是用来配置降低Full GC压缩的频率,以期减少某些Full GC的暂停时间。CMS回退到Full GC时用的算法是mark-sweep-compact,但compaction是可选的,不做压缩的话碎片化会严重些但这次Full GC的暂停时间会短些,这是个取舍。

CMSInitiatingOccupancyFraction,这个参数设置有很大技巧,基本上满足(Xmx-Xmn)*(100-CMSInitiatingOccupancyFraction)/100>=Xmn就不会出现promotion failed。 在我的应用中Xmx是6000,Xmn是500,那么Xmx-Xmn是5500MB,也就是年老代有5500MB,CMSInitiatingOccupancyFraction=90说明年老代到90 %满的时候开始执行对年老代的并发垃圾回收(CMS),这时还剩10%的空间是5500*10%=550MB,所以即使Xmn(也就是年轻代共500MB)里所有对象都搬到年老代里,550 MB的空间也足够了,所以只要满足上面的公式,就不会出现垃圾回收时的promotion failed。

如何调整线程的个数

-Xss:设置每个线程的堆栈大小。JDK5.0以后每个线程堆栈大小为1M,以前每个线程堆栈大小为256K。

根据应用的线程所需内存大小进行调整。在相同物理内存下,减小这个值能生成更多的线程。但是操作系统对一个进程内的线程数还是有限制的,不能无限生成,经验值在3000~5000左右。

线程堆栈中存放了方法调用的出参、入参、局部变量等,有人喜欢设小点节约内存开更多线程。但反正内存够也就不必要设小,有人喜欢再设大点,特别是有JSON 解析之类的递归调用时不能设太小。

Promotion failed产生的原因和解决方案

Promotion failed是在进行Minor GC时,Survivor空间放不下、对象只能放入旧生代,而此时旧生代也放不下造成的。多数是由于年老带有 足够的空闲空间,但是由于碎片较多,新生代要转移到年老带的对象比较大,找不到一段连续区域存放这个对象导致的。

  • 如果是因为内存碎片导致的大对象提升失败,CMS需要设置多少次FullGC后进行压缩;
  • 如果是因为提升过快导致的,说明Survivor 空闲空间不足,那么可以尝试调大-XX:SurvivorRatio参数;
  • 如果是因为老年代空间不够导致的,尝试将CMS触发的阈值调低。

如果你想知道默认的JVM的一些参数的设置,可以随意在IDEA中运行一个程序,然后用jmap -heap <pid>进行查看:

➜  ~ jmap -heap 11956
Attaching to process ID 11956, please wait...
Debugger attached successfully.
Server compiler detected.
JVM version is 25.151-b12

using thread-local object allocation.
Parallel GC with 8 thread(s)

Heap Configuration:
   MinHeapFreeRatio         = 0
   MaxHeapFreeRatio         = 100
   MaxHeapSize              = 4294967296 (4096.0MB) -- 最大Heap是4G
   NewSize                  = 89128960 (85.0MB) -- 默认New区是85M
   MaxNewSize               = 1431306240 (1365.0MB) -- 最大New区是1.33G
   OldSize                  = 179306496 (171.0MB) -- 默认Old区是171M,则总的HeapSize默认是256M
   NewRatio                 = 2
   SurvivorRatio            = 8 -- 默认的Survivor和Eden的比例是1:8
   MetaspaceSize            = 21807104 (20.796875MB)
   CompressedClassSpaceSize = 1073741824 (1024.0MB)
   MaxMetaspaceSize         = 17592186044415 MB
   G1HeapRegionSize         = 0 (0.0MB)

Heap Usage:
PS Young Generation
Eden Space:
   capacity = 67108864 (64.0MB)
   used     = 12112176 (11.551071166992188MB)
   free     = 54996688 (52.44892883300781MB)
   18.048548698425293% used
From Space:
   capacity = 11010048 (10.5MB)
   used     = 0 (0.0MB)
   free     = 11010048 (10.5MB)
   0.0% used
To Space:
   capacity = 11010048 (10.5MB)
   used     = 0 (0.0MB)
   free     = 11010048 (10.5MB)
   0.0% used
PS Old Generation
   capacity = 179306496 (171.0MB)
   used     = 0 (0.0MB)
   free     = 179306496 (171.0MB)
   0.0% used

844 interned Strings occupying 57024 bytes.

Concurrent mode failed产生的原因和解决方案

Concurrent mode failed的产生是由于CMS回收年老代的速度太慢,导致年老代在CMS完成前由于用户线程也在并发执行,当年轻代空间满了, 执行YoungGC,需要将存活的对象放入到年老代,而此时年老代已经被占满(清理工作太慢),引起Full GC。然后就会使用串行收集器回收老年代的垃圾,导致停顿的时间非常长。

避免这个现象的产生就是调小-XX:CMSInitiatingOccupancyFraction参数的值,让CMS更早更频繁的触发,降低年老代被占满的可能。

CMSInitiatingOccupancyFraction参数要设置一个合理的值,设置大了,会增加concurrent mode failure发生的频率, 设置的小了,又会增加CMS频率,所以要根据应用的运行情况来选取一个合理的值。 如果发现这两个参数设置大了会导致Gull GC,设置小了会导致频繁的CMS GC,说明你的老年代空间过小,应该增加老年代空间的大小了。

G1

References

  • http://chen-tao.github.io/2017/01/10/jvm-param-rcmd-2016/
  • https://www.zhihu.com/question/53613423

本文首次发布于 S.L’s Blog, 作者 @stuartlau , 转载请保留原文链接.