【JVM】十九、上亿请求轻松应对,老年代垃圾回收参数调整技巧大公开
本文将通过一个每日处理上亿请求量的电商系统,介绍如何在不同的场景下,预测系统的老年代内存使用模型
1、前文回顾
在上一篇文章中,我们已经向大家介绍了一个日活跃用户百万级别,处理请求量上亿的电商系统案例。我们选择了这个中型电商系统在大促期间的瞬时高峰下单场景,作为我们的JVM优化分析的场景。通过预测,我们得出在大促高峰期,每台机器每秒需要处理300个订单请求。
根据这个数据,我们进一步推测出每秒钟会使用60MB的内存。在这个背景下,我们需要计算出在一台4核8G的机器上,如何合理地为JVM的各个区域分配内存。
为了保证每20多秒一次的新生代GC后,100MB左右的存活对象能够进入200MB的Survivor区域,我们进行了合理的内存分配。这样可以确保Survivor区域不会被填满,或者由于动态年龄判定规则,对象不会过早地进入老年代。
同时,我们还根据Minor GC的频率,合理降低了大龄对象进入老年代的年龄。这样可以让一些长期存活的对象尽快进入老年代,避免它们停留在新生代中。如下图所示。
此时的JVM参数如下所示:
-Xms3072M -Xmx3072M -Xmn2048M -Xss1M -XX:PermSize=256M -XX:MaxPermSize=256M -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=5 -XX:PretenureSizeThreshold=1M -XX:+UseParNewGC -XX:+UseConcMarkSweepGC”
2、从新生代到老年代,详解对象生命周期的转折点
接下来,我们将探讨在当前优化环境下,通常在什么情况下会将一些对象转移到老年代。
首先,我们考虑的第一个情况是关于参数“-XX:MaxTenuringThreshold=5”。这个参数的设置意味着,如果一个对象在一两分钟内连续逃避了5次Minor GC(次要垃圾回收),那么它将被迅速转移到老年代。
这类对象通常是由@Service、@Controller等注解标记的系统业务逻辑组件。这些对象的实例通常全局只需要一个,并且会持续使用。因此,它们通常会被GC Roots(垃圾回收根)长期引用。这种类型的对象通常不会太多,最多一个系统可能会有几十MB这样的对象。
因此,在这种情况下,像这样的长寿命对象将会被转移到老年代中。如下图所示。

在我们的JVM参数设置中,如果分配的对象超过1MB,例如创建大型数组或大List等,这些对象会直接进入老年代。然而,在这个案例中,我们假设没有这样的大对象,因此可以忽略不计。
另外,当执行Minor GC后,可能会有一部分对象存活下来,如果这些对象的总大小超过200MB,无法放入Survivor区,或者一次性占用超过Survivor区的50%,那么这些对象就会进入老年代。
然而,我们已经对新生代的JVM参数进行了优化,目的就是为了防止这种情况的发生。经过我们的计算,这种情况发生的概率应该是非常低的。
虽然概率很低,但并不能完全排除这种情况的发生。例如,在某次GC后,可能恰好有超过200MB的对象,这些对象就会进入老年代。
我们可以做一个假设,即在订单系统的大促期间,每5分钟,执行一次Minor GC后,可能会有一小批对象(大约200MB)进入老年代。如下图所示。

3、了解Full GC多久来袭一次?
接下来,我们将探讨Full GC的触发频率。根据我们目前了解到的,有四种情况可能会触发Full GC:
当未启用“-XX:HandlePromotionFailure”选项时,老年代的最大可用内存为1G,而新生代的对象总大小最多可以达到1.8G。在这种情况下,每次进行Minor GC之前,如果发现“老年代可用内存”小于“新生代对象总大小”,就会触发一次Full GC。但是值得注意的是,自JDK 1.6版本之后,这个参数已被废弃。实际上,只要满足第二个条件,就可以直接触发Minor GC,无需触发Full GC。
在每次进行Minor GC之前,系统会检查“老年代可用内存空间”是否小于“历次Minor GC后升入老年代的平均对象大小”。在我们的设定背景下,通常需要经过多次Minor GC,才可能有一次或两次会有大约200MB的对象升入老年代,因此这个“历次Minor GC后升入老年代的平均对象大小”通常是较小的。
在某一时刻,可能需要将几百MB的对象从新生代晋升到老年代,但此时老年代的可用空间不足。
如果设置了“-XX:CMSInitiatingOccupancyFaction”参数,比如设置为92%,那么即使前面的条件都未满足,只要发现老年代的空间使用超过了92%,就会触发Full GC。
在实际的系统运行过程中,可能会有对象逐渐进入老年代,但由于我们对新生代的内存分配进行了优化,对象进入老年代的速度是较慢的。可能在系统运行半小时至一小时后,才会有足够的对象(接近1GB)进入老年代。这时,可能会因为上述的第二、第三和第四个条件中的任何一个得到满足,从而触发Full GC。然而,这三个条件通常都需要老年代的内存几乎被占满,才有可能触发。
让我们考虑一种情况,假设在大促期间,订单系统运行了1小时后,大促下单高峰期基本已经过去,此时可能会触发一次Full GC。请注意,这个推论非常重要,因为按照大促开始10分钟就有50万订单来计算,实际上在大促开始后的一段时间内,大量用户正在等待下单购物,那么1小时后可能会产生两到三百万的订单。这种情况通常只在年度大型促销活动中出现,然后在这个高峰期过后,订单系统的访问压力就相对较小,GC的问题几乎可以忽略不计。
因此,通过优化新生代的内存管理,我们可以推断出,在大型促销活动的高峰期内,可能每小时只会触发一次Full GC,而在高峰期过后,随着订单系统的稳定运行,可能需要几个小时才会触发一次Full GC。
4、老年代中“Concurrent Mode Failure”究竟会不会发生?
根据之前的分析和计算,我们能够得出以下结论:假设订单系统运行1小时后,老年代中大约会积累900MB的对角线对象,而此时剩余的可用空间将仅有100MB。在这种情况下,将会触发一次Full GC。如下图。

但是有一个很大的问题,就是CMS在垃圾回收的时候,尤其是并发清理期间,系统程序是可以并发运行的,所以此时老年代空闲空间仅剩100MB了
然后此时系统程序还在不停的创建对象,万一这个时候系统运行触发了某个条件,比如说有200MB对象要进入老年代,此时会如何?
然而,我们面临着一个显著的问题,那就是在CMS垃圾回收过程中,尤其是在并发清理阶段,系统程序可以同时运行。因此,在这种情况下,如果老年代的空闲空间仅剩100MB,问题就出现了。
此时,系统程序持续创建新的对象,如果此时触发了某个特定条件,比如需要将200MB的对象放入老年代,那么会如何呢?如下图:

在这个阶段,可能会触发“Concurrent Mode Failure”的问题。这是因为在这个时刻,老年代的内存空间不足以容纳这200MB的对象。当这种情况发生时,会立即进入“Stop the World”阶段,这是为了暂停所有应用程序线程,以便进行垃圾回收。
在这个过程中,系统会将CMS垃圾回收器切换为Serial Old垃圾回收器。Serial Old是一个单线程的垃圾回收器,它会独占CPU资源,进行老年代的垃圾回收工作。
在这个例子中,Serial Old垃圾回收器需要回收900MB的老年代对象。完成这个任务后,系统将恢复正常运行,允许应用程序线程继续执行。如下图。

因此,我们可以考虑以下问题:这种情况有可能发生吗?
虽然概率较低,但并非完全不可能。要发生这种情况,必须满足两个条件:首先,CMS在触发Full GC的时候,系统正在运行;其次,同时有200MB的对象被分配到老年代。实际上,这两个条件同时满足的概率本身是相当小的,但在理论上,这种可能性确实存在。
那么,针对这种低概率事件,我们需要思考是否有必要调整参数呢?
目前来看,似乎没有必要。对于这种低概率事件,我们不需要特意去优化参数。此时JVM参数如下:
-Xms3072M -Xmx3072M -Xmn2048M -Xss1M -XX:PermSize=256M -XX:MaxPermSize=256M -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=5 -XX:PretenureSizeThreshold=1M -XX:+UseParNewGC -XX:+UseConcMarkSweepGC -XX:CMSInitiatingOccupancyFaction=92
5、如何高效安排CMS垃圾回收后的碎片整理?
接下来,我们来探讨最后一个问题。在CMS完成Full GC之后,通常需要执行内存碎片整理。我们可以设置在多少次完全垃圾回收之后进行一次内存碎片整理。然而,我们真的有必要修改这些参数吗?
实际上,并无此必要。根据我们之前的分析,在大促高峰期,完全垃圾回收可能每小时只会执行一次。而当大促高峰期过后,订单量将大幅减少,此时可能几个小时才会触发一次完全垃圾回收。因此,在这种情况下,我们并不需要修改这些参数。
所以就保持默认的设置,每次Full GC之后都执行一次内存碎片整理就可以,目前JVM参数如下:
-Xms3072M -Xmx3072M -Xmn2048M -Xss1M -XX:PermSize=256M -XX:MaxPermSize=256M -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=5 -XX:PretenureSizeThreshold=1M -XX:+UseParNewGC -XX:+UseConcMarkSweepGC -XX:CMSInitiatingOccupancyFaction=92 -XX:+UseCMSCompactAtFullCollection -XX:CMSFullGCsBeforeCompaction=0
从本文中我们可以看出,Full GC优化的基础是Minor GC的优化。而要实现Minor GC的优化,关键在于合理地分配内存空间。而要合理地分配内存空间,就需要对系统在运行过程中的内存使用模型进行准确的预估。
对于许多常见的Java系统来说,如果能够准确地预估系统在运行过程中的内存使用模型,并据此合理地分配内存空间,那么就能尽量确保Minor GC后的存活对象留在Survivor区域,而不是进入老年代。这样一来,即使对其他的GC参数不做过多的优化,系统的性能也不会差到哪去。
因此,我们可以得出这样的结论:在优化Java系统的垃圾收集性能时,首要任务是对系统运行时的内存使用模型进行准确的预估,然后根据这个预估结果来合理地分配内存空间。这样,即使在不进行其他复杂的GC参数优化的情况下,也能保证系统的性能不会受到太大的影响。