20、JVM 调优实战 - 案例:每日上亿请求量的电商系统,年轻代垃圾回收参数如何优化?

1. 案例背景

背景为电商系统,电商系统一般拆分为多个子系统独立部署,这里以核心的订单系统为例子说明。

假设该电商系统每日上亿请求量,按照每个用户平均访问20次计算,大致需要有500万日活用户。

在按照10%的付费转化率计算,每天大概有50万人会下订单,也就是每天会有50万订单。

如果这50万订单集中在每天4个小时的高峰期内,平均算下来就是每秒钟大概有几十个订单。而这种几十个订单的压力,基本上每秒钟占用一些新生的内存,要很久新生代才会满,然后一次Minor GC后垃圾对象清理掉,内存就空出来了,几乎无压力。

2. 特殊的电商大促场景

如果遇到一些大促场景,比如双11,可能在大促开始的短短10分钟内,瞬间就会有50万订单。

那么对应的是每秒就会有接近1000的下单请求。

3. 抗住大促的瞬时压力需要几台机器?

想要抗住大促期间的瞬时下单压力,就需要多部署几台机器。

按照3台来算,就是每台机器每秒需要抗300个下单请求。这里假设采用标配4核8G机器。

从机器本身的CPU资源和内存资源角度,抗住每秒300个下单请求是没问题的。

但是需要对JVM有限的内存资源进行合理的分配和优化,包括对垃圾回收进行合理的优化,让JVM的GC次数尽可能最少,并尽量避免Full GC,这能尽可能减少JVM的GC对高峰期的系统性能造成影响。

4.大促高峰订单系统的内存使用模型估算

这里来预估订单系统的内存使用模型。

按照每秒钟处理300个下单请求来估算,无论是订单处理性能还是并发情况,都跟生产很接近。

因为处理下单请求是比较耗时的,涉及很多接口的调用,基本上每秒处理 100~300个下单请求是差不多的。

按照买个订单的数据 1kb的大小来计算,300个订单就会有300kb的内存开销。

然后算上订单对象连带的订单条目对象、库存、促销、优惠券等等一系列的其他业务对象,一般需要对单个对象开销放大10倍~20倍。

此外,还有很多订单相关的其他操作,比如订单查询之类的,所以连带算起来,往大了估算,再扩大10倍的量。

那么每秒钟会有大概300kb * 20 * 10 = 60mb的内存开销。在一秒过后,这 60mb的对象就是垃圾了,因为300个订单处理完了,所有相关对象都失去了引用,是可以回收的状态。

 

5. 内存到底该如何分配?

假设使用 4核8G的机器,给JVM的内存为4G。其中堆内存给3G,新生代可以给到1.5G,老年代也是1.5G。

然后每个线程的Java虚拟机栈有1M,那么JVM里如果有几百个线程大概会有几百M。

然后再给永久代256M内存,基本上这4G内存就差不多了。

同时再设置一些必要的参数,比如说打开 “-XX:HandlePromotionFailure” 选项,JVM参数如下所示:

“-Xms3072M -Xmx3072M -Xmn1536M -Xss1M -XX:PermSize=256M -XX:MaxPermSize=256M -XX:HandlePromotionFailure”

由于“-XX:HandlePromotionFailure” 参数在JDK1.6以后被废弃了,所以现在一般不在生产环境里设置该参数。

在JDK1.6以后,只要判断“老年代可用空间” > “新生代对象总和” ,后者“老年代可用空间” > “历次Minor GC升入老年代对象的平均大小”,两个条件满足一个,就可以直接进行Minor GC,不需要提前触发Full GC了。

所以,在JDK1.7或者JDK1.8的环境下,参数保持如下:

“-Xms3072M -Xmx3072M -Xmn1536M -Xss1M

-XX:PermSize=256M -XX:MaxPermSize=256M

 

接着,订单系统的系统程序在大促期间不停的运行,每秒处理300个订单,都会占据新生代60MB的内存空间。

但是在1秒过后这60MB对象都会变成垃圾,那么新生代1.5G的内存空间大概需要25秒就会占满。

然后就要进行Minor GC了,因为有 “-XX:HandlePromotionFailure” 选项,所以可以认为需要进行的检测,主要就是比较 “老年代可用空间大小” 和 “历次Minor GC后进入老年代对象的平均大小” ,刚开始这个检测是可以通过的。

所以Minor GC直接运行,一下子可以回收掉 90%的新生代对象,因为除了最近一秒的订单请求还在处理,大部分订单早就处理完了,所以此时可能存活对象就100MB左右。

如果“-XX:SurvivorRatio” 参数默认值为8,那么此时新生代里 Eden区大概占据了 1.2G内存,每个Survivor区是150MB的内存。

所以Eden区1.2GB满了就要进行 Minor GC了,因此大概只需要20秒,就会把Eden区塞满,就要进行Minor GC了。

然后GC 后存活对象在100MB左右,会放入S1区域内存。

然后再运行20秒,把Eden区占满,再次垃圾回收Eden和S1中的对象,存活对象可能还是在100MB左右会进入S2区。

此时JVM参数如下:

“-Xms3072M -Xmx3072M -Xmn1536M -Xss1M -XX:PermSize=256M -XX:MaxPermSize=256M -XX:SurvivorRatio=8”

6. 新生代垃圾回收优化之一:Survivor空间够不够

首先在进行JVM优化的时候,要考虑新生代的Survivor区到底够不够。

按照上述逻辑,每次新生代垃圾回收在100MB左右,有可能突破150MB,这就导致会经常出现Minor GC后的对象无法放入Survivor中,从而导致频繁让对象进入老年代。

即使Minor GC后的对象少于150MB,按100MB算,如果是同龄对象,就会超过Survivor区空间的50%,导致对象直接老年代。

所以按照这个模型来说,Survivor区域是明显不足的。

调整优化

建议的是调整新生代和老年代的大小。由于大部分对象是短生存周期的,不应该频繁进入老年代,也就没必要给老年代维持过大的内存空间,要让对象尽量留在新生代里。

因此可以考虑把新生代调整为 2G,老年代为1G,此时Eden为1.6G,每个Survivor为200MB。

 

如上图,Survivor区域变大,就大大降低了新生代GC过后存活对象在Survivor里放不下的问题,或者是同龄对象超过Survivor 50%的问题。

这就大大降低了新生代对象进入老年代的概率。

此时JVM的参数如下:

“-Xms3072M -Xmx3072M -Xmn2048M -Xss1M -XX:PermSize=256M -XX:MaxPermSize=256M -XX:SurvivorRatio=8”

对任何系统,采用类似上文的内存使用模型预估以及合理的分配内存,尽量让每次Minor GC后的对象都留在Survivor里,不要进入老年代,这是首先要进行优化的一个地方。

7.新生代对象躲过多少次垃圾回收后进入老年代?

除了Minor GC后对象无法放入Survivor会导致一批对象进入老年代之外,还有就是有些对象连续躲过15次垃圾回收后会自动升入老年代。

按照上述内存运行模型,基本上20多秒触发一次Minor GC,按照 “-XX:MaxTenuringThreshold” 参数的默认值15次来说,要连续躲过15次GC,就是一个对象在新生代停留超过几分钟了,此时他进入老年代也是应该的。

至于说,要提高这个参数,比如提高到20次,或者30次,这种说法是不对的。

需要结合系统的运行模型来设置这个参数,如果一个对象躲过15次GC都几分钟了,而不能被回收,说明肯定是系统里类似用 @Service、@Controller之类的注解标注的那种需要长期存活的核心业务逻辑组件。

那么它应该进入老年代,而这种对象一般很少,一个系统累计起来最多也就几十MB而已。

所以说提高 “-XX:MaxTenuringThreshold” 参数的值,让他在新生代多停留几分钟,并没有什么意义。

反过来说,如果将这个值降低到5次,也就是说一个对象如果躲过5次Minor GC,在新生代里停留超过1分钟了,尽快就让他进入老年代,别在新生代里占着内存。

此时JVM参数如下:

“-Xms3072M -Xmx3072M -Xmn2048M -Xss1M -XX:PermSize=256M -XX:MaxPermSize=256M -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=5”

8.多大的对象直接进入老年代?

大对象可以直接进入老年代,因为大对象说明是要长期存活和使用的。

比如在JVM里可能要缓存一些数据。一般来说,给他设置个1MB足以,因为一般很少有超过1MB的大对象。如果有,可能是分配了一个大数组、大list之类的东西用来存放缓存的数据。

此时JVM参数如下:

“-Xms3072M -Xmx3072M -Xmn2048M -Xss1M -XX:PermSize=256M -XX:MaxPermSize=256M -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=5 -XX:PertenureSizeThreshold=1M”

9. 指定垃圾回收器

要指定垃圾回收器,新生代使用ParNew,老年代使用CMS,如下JVM参数:

“-Xms3072M -Xmx3072M -Xmn2048M -Xss1M -XX:PermSize=256M -XX:MaxPermSize=256M -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=5 -XX:PertenureSizeThreshold=1M -XX:+UseParNewGC -XX:+UseConcMarkSweepGC”

ParNew垃圾回收器的核心参数,就是配套的新生代内存大小、Eden和Survivor的比例,只要设置合理,避免Minor GC后对象放不下 Survivor进入老年代,或者是动态年龄判定之后进入老年代,给新生代里的Survivor充足的空间,那么 Minor GC 一般就没什么问题。