深入理解JVM - 实战老年代优化

1,286 阅读9分钟

深入理解JVM - 实战老年代优化

「本文已参与好文召集令活动,点击查看:后端、大前端双赛道投稿,2万元奖池等你挑战!

前言

​ 通过前面的文章可以了解到JVM优化中老年代的FULL GC对于系统以及垃圾收集器的行为有着十分大的影响,比如CMS并发标记或者回收撑不住的时候要暂停用户线程并且呼叫serrial收集器帮忙进行单线程的高效回收的动作,但是也伴随着"漫长"的stop world时间。

​ 综上所述,老年代的优化是JVM优化的一个核心知识点,所以这一节就来讲解如何优化老年代的回收,尽量让对象在新生代回收而不是在老年代进行回收。

前文回顾

​ 之前的文章我们对于JVM分代以及垃圾回收有了一个详细的了解,同时了解了jdk9之前主流的垃圾收集器ParNew收集器和CMS收集器,并且对于cms的细节进行了完整的讲述,这些内容都是十分基础但是又十分重要的知识点,这篇文章则根据前面的知识点,根据一个模拟的案例来讲解一下如何对于JVM老年代进行调优。

案例实战

​ 案例实战会根据一个非常理想化的模拟场景,因为应用的性能实际上会有各种的影响,甚至代码的质量也会影响系统的运行性能,所以下面的模拟场景均为假想的情况,切勿过于认真的对待这个案例的各种参数。

一个电商系统的大致背景:

​ 如果一个电商网站每天的访问量是20次/人,如果要上亿次的请求需要每天500万次的请求,同时如果这500万人按照10%的下单的标准,则是每天50万人会进行下单的操作,而下单操作按照2/8原则在4小时之内付款完成,那么此时的占用大概是50万/4小时 == 500000 / 14400,大概每秒也就 34个订单左右,这种情况下发现系统的影响并不会很大,老年代发生回收大概为几个小时一次,完全可以接受。

高并发的场景

​ 但是如果在秒杀的场景,情况又不一样了,如果在一秒内来1000笔订单,该如何处理?我们假设如果是3台机器,则每台需要处理至少300条请求。

计算JVM消耗

​ 根据上文模拟场景,假设每秒300个请求按照每个对象1KB来看,每一台机器要处理大概300KB的内存,把一个订单系统的处理对象放大10倍,则是3000KB,如果在算上其他的操作比如订单处理,则需要30000KB = 30MB的占用。

​ 如果虚拟机栈每个占用1M,则几百个线程需要几百M的空间。如果是4核心8G的机器,则分4G给JVM,4G中分1G给虚拟机栈500M多M,方法区:256M,堆外内存给256M。同时开启内存担保机制(jdk6之后不需要制定参数)然后新生代和老年代各分配1.5G。

​ 按照上面的换算,我们发现如果每秒都来30M对象,那么1200M左右的EDEN区域(8:1:1的比例大概是1200给EDEN),大概会留下200M左右的内存会进入SURVIOR区域,但是如果SURVIOR区域放不下则会进入老年代,根据之前的参数分配150M的Survior区域肯定是无法存放的,根据内存分配担保的机制,这些对象会分配到老年代。

​ 按照这样的分配效率不到一分钟新生代就会塞满。大概8、9次minor gc就会导致full gc,也就是说 8、9分钟就会触发老年代回收,这个触发的概率就十分高了,这会严重导致系统卡顿并且出现用户线程的停顿现象。

​ 但是如果Survior空间足够,那么此时回收进入到Survior空间之后,在下一次minor gc基本也为垃圾对象被回收了。

垃圾回收的优化

上面的案例优化也非常简单,在讲解最终的优化方案之前,我们按照下面的步骤进行分析:

检查Survior区域是否可以保证每次minor gc都可以全部进入

​ 首先我们需要确认新生代的内存在垃圾回收之后是否都可以进入到Survior区域,很明显,根据案例Survior区域的大小为150M左右,而每次Minor GC之后存活对象通常为200M左右,这时候明显是无法存放下的,所以我们需要。

多大的对象应该进入老年代

​ 之前说过一秒钟会产生30M对象,而Minor gc之后对象基本只剩下100M左右了,也就是说1分钟大概存活100M对象,那么平摊下来一秒大概产生1-2M的大对象。

​ 所以一般情况下设置个1M的阈值就差不多了

对象经过多少年龄进入老年代

​ 一般情况下默认的15就是一个不错的值。但是对于高并发的业务来说,大对象早点进入老年代反而是好事。因为survior存在一个控制值50%,累加对象大小超过Survior区域50%之后大于等于此年龄全部会进入老年代,所以有时候让新生代的对象提前进入老年代也是一种值得考虑的事情。

​ 比如我们可以将进入老年代年龄的对象设置为7或者8。

指定垃圾收集器

​ 注意一定要在参数里面指定垃圾收集器,这是十分重要的内容。

比如:-XX:+UseParNewGC

最终优化参数结果

​ 经过上面的一系列分析,我们可以确定根本问题出在了对象提前进入了老年代导致Survior区域成为摆设并且老年代的对象不断扩展,最终老年代塞满而导致频繁full gc,所以案例最后的优化参数如下:

老年代的内存要如何优化呢?

​ 针对上面的案例,我们再分析几点内容:

老年代需要开启分配担保失败么?

​ 我们看下如果没有开启分配担保失败会如何?首先如果没有开启,如果此时老年代的可用内存为400M,并且发现新生代总大小 < 老年代可用内存大小,每次Minor Gc都将会伴随着Full Gc,所以jdk6也关闭了这个参数并且这个参数也是默认开启的,绝大多数的情况下这个参数不用去管,默认都是要开启的。

​ 如果老年代的总大小<新生代的大小,那么如果没有开启分配担保每一次请求都是会发生Full Gc的。

如果使用CMS收集器,是否需要改动92%的参数

​ 从案例的角度来看,优化之后如果老年代到达900M说明此时订单系统已经运行很久了(几个小时),一般情况下秒杀早就结束了,此时进行Full GC也不会影响业务的处理和请求的处理。

进入老年代触发full Gc对系统影响可能性

​ 如果某一次gc之后新生代存活对象大于200M,发现 Survior 区域放不下,此时老年代判断历次晋升平均大小,发现基本都是可以分配的,因为调整过后一般情况下只有几十M大小的对象进入,所以这样的概率还是比较小的,即使出现这种情况,此时系统也过去了很久了,高峰下单一般在前10分钟,如果前10分钟没有进行Fu'll Gc 而1小时之后进行了,这样也是不影响订单系统的运行的,因为此时的压力很小了.

Concurrent Mode fail 这种情况的有无影响

​ 和前面说的分析一样,如果900M的老年代空间已经被占用满了,此时系统进程和垃圾回收线程同时进行,如果在并发整理的时候进入200M的对象,那么最坏的情况是触发失败导致stop world 并且serial 进行单线程的回收处理动作,但是需要考虑的是这种情况假设已经过去1个小时了,而此时的订单压力也小很多,Full Gc的影响也是可以承受的,在进行完这次Full GC之后,下一次可能就在几个小时之后的,这种情况虽然有可能发生,但是几率十分小。

内存碎片整理的概率有多大

​ 还是和上面的情况一样,如果出现FULL GC也说明此时系统运行比较久了,一次FULL GC的间隔十分长的情况下每次FULL GC进行内存碎片整理的代价是可以接受的。

总结

​ 下面根据这个案例总结一下如何思考优化的点

  1. 首先业务的对象都是生命周期十分短暂的对象,新生代的压力比老年代要大,所以适当缩小老年代空间是十分划算的
  2. 预测在高并发的场景下对象进入老年代的时机,如果对象经常“跨区”说明有一部分内容空间是浪费了,那就是Survior区域
  3. 对象在各分区需要大致多少的内存空间,比如每个线程需要占用多少的内存空间
  4. 对象的年龄判断是否需要改动,提前让对象进入老年代是好处还是坏处
  5. 关注收集器对于对象垃圾回收的影响,同时在启动的时候要强制使用某一垃圾收集器,因为不同的JDK版本默认的垃圾收集器是不一样的。