三、新生代JVM参数优化
1、背景引入
假设我们的背景是每日上亿请求量的一个订单系统,按照每个用户每日访问次数为20次来算,大致有500万个用户(1亿/20),对这五百万个用户,假设付费转化率为10%,也就是有50万人会去下单,我们把这50万订单集中在4个小时的高峰期内,平均每秒钟也就几十个订单,感觉也没什么大的压力,因为几十个订单根本不需要对JVM做太多关注。
但是如果到了双十一这种活动,就会出问题了。硬件方面来说,如果我们部署到足够的机器上以及机器内存充裕,也不是问题,但就JVM的参数来说,如果我们不能合理的去设置这个参数,就会导致机器资源浪费,硬件成本的增加。
为什么要去调JVM参数?我们的目的就是对JVM有限的内存资源做好合理分配和优化,当然包括垃圾回收的优化,要让GC次数尽可能的少。
假设双十一期间一台机器1秒要处理300个订单(处理订单比较耗时,工作经验上是每秒处理100~300个订单),对于每个订单对象我们按1KB来算,那1秒就是300KB内存开销了,但是这时订单连带对象如库存、促销、优惠券等一系列业务对象,这些对象从经验上来讲好要比订单单个对象的开销再放大10倍,同时还有很多与订单相关操作,比如查询等,往大估算就再扩大十倍,**所以1秒钟,我们要处理60MB(300KB x 20 x 10 = 60000KB)对象。**每1秒过后,这个对象就变成垃圾了。
假设我们使用4核8G的机器,JVM分配4G,其中3G给堆内存,1G给方法区和每个线程的虚拟机栈。虚拟机栈一般都是1MB,假设我们有几百个线程,就是几百MB,这里我们给永久代256MB,给虚拟机栈总共768MB。至于堆内存,我们给新生代1.5G,老年代1.5G。得到如下参数。(注意这里不写 -XX:HandlePromotionFailure
,我们使用JDK 1.8)。每1秒有60MB的垃圾,1.5G的内存大概25秒就满了。此时就要Minor GC
,明显老年代能够存放新生代所有对象,可以放心GC,由于最后一秒订单还在处理,假设存活的对象就100MB,这里来问题了,如果-XX:SurvivorRatio
参数默认值为8,那么此时新生代里Eden区大概占据了1.2GB内存,每个Survivor
区是150MB的内存,如下图。
-Xms3072M -Xmx3072M -Xmn1536M -Xss1M -XX:PermSize=256M -XX:MaxPermSize=256M
所以启动JVM后,大概20秒左右,Eden
区就满了,然后Minor GC
,把存活对象放在Survivor1
中,再过20s,再次回收Eden
和Survivor1
中的对象,存活的如果还是100MB就放入Survivor2
中。
以上就是总体的背景,此时的JVM参数为:
-Xms3072M -Xmx3072M -Xmn1536M -Xss1M -XX:PermSize=256M -XX:MaxPermSize=256M -XX:SurvivorRatio=8
2、参数优化
(1)Survivor空间的设置
JVM优化时,首先就得考虑Survivor
空间够不够。就上述案例,一种情况是,Survivor
中分配了150MB,如果来的对象大于150MB,就会频繁进入老年代,第二种情况是,即使100MB对象能够放入Survivor
区,但是100/150 = 0.67,超过了Survivor
区空间的50%,这样同一批年龄对象也进入老年代了,这种1秒就变成垃圾的短生命周期对象根本不需要进入老年代。我们得让它们留在新生代里。
方案:给Survivor
区更大的容量。如果你的业务都是这种短生命周期的,老年代可以分配少一点的内存,我们可以考虑把新生代调整为2G,老年代为1G,如果-XX:SurvivorRatio=8
那么此时Eden
为1.6G,每个Survivor
为200MB,如图。这时候上述两个问题就同时解决了。
针对任何系统,我们要预估内存并合理分配内存,首要做的就是**尽量让每次Minor GC后的对象都留在Survivor里,不要进入老年代。**此时参数如下
-Xms3072M -Xmx3072M -Xmn2048M -Xss1M -XX:PermSize=256M -XX:MaxPermSize=256M -XX:SurvivorRatio=8
(2)-XX:MaxTenuringThreshold参数的设置
有些对象是可能躲过15次垃圾回收进入老年代的,就上述背景,有些对象在新生代躲了几分钟进入老年代很应该,那为了不让这种数据进入老年代要怎么做?我们需要调-XX:MaxTenuringThreshold
这个参数。这个参数并不是一昧地去调高,一定要结合系统的运行模型,看看Minor GC频率,把这个参数从15调高到20、30,让一个垃圾多在Survivor
中停留几分钟,根本没用,对于我们上述业务场景就要把这个参数调低,比如调到5.记住,一定要结合系统运行的模型。此时参数如下:
-Xms3072M -Xmx3072M -Xmn2048M -Xss1M -XX:PermSize=256M -XX:MaxPermSize=256M
-XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=5
(3)-XX:PretenureSizeThreshold参数设置
大对象是可以直接进入老年代的,但是多大呢?一般来说很少有超过1MB的对象,如果有,那就是你提前分配了一个大数组、大List之类的来存放缓存数据,一般这种数据是要用一段时间的,所以我们可以放到老年代。我们一般把这个参数设置为1。此时JVM参数如下:
-Xms3072M -Xmx3072M -Xmn2048M -Xss1M -XX:PermSize=256M -XX:MaxPermSize=256M
-XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=5 -XX:PretenureSizeThreshold=1M
(4)指定垃圾回收器
针对具体的客户端、服务端来设置垃圾回收器,之前讲过。我们这个系统新生代使用ParNew
,老年代使用CMS
。设置如下的参数:
-Xms3072M -Xmx3072M -Xmn2048M -Xss1M -XX:PermSize=256M -XX:MaxPermSize=256M
-XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=5 -XX:PretenureSizeThreshold=1M
-XX:+UseParNewGC -XX:+UseConcMarkSweepGC
四、老年代JVM参数优化
老年代参数优化主要就是减少Full GC的次数。首先我们得要分析对象进入老年代的几个原因
1、对象进入老年代的原因
第一种:就是-XX:MaxTenuringThreshold
这个参数设置的太低了,就是之前新生代的案例,这种对象一般是@Service
或Controller
等注解标识的业务逻辑组件,这种对象一般全局有一个实例就行,是要一直用的,所以应该让他进。
第二种:就是大对象,但是这种再上述案例中一般没有,可以忽略。
第三种:就是Minor GC后存活的对象超过了Survivor
区的50%,就直接进入了老年代
2、大促销场景的Full GC多久出发一次?
对于此案例触发Full GC的几个情况:
情况一:没有打开 -XX:HandlePromotionFailure
选项。**我们知道如果老年代剩余内存大于新生代对象总大小就直接Minor GC的,但是老年代剩余内存总大小小于新生代对象总大小时,就要看这个参数了,如果没有打开这个参数,老年代空间小于新生代所有对象大小就直接Full GC,如果打开了,就看平均。**这个参数就是看老年代剩余内存总大小是否大于之前每一次Minor GC进入老年代的对象的平均大小,按照之前项目案例,要很多次Minor GC之后才可能有一两次碰巧会有200MB对象升入老年代,所以这个“历次Minor GC后升入老年代的平均对象大小”,基本是很小的。(JDK 1.6之后就不看了)
情况二:某次升入老年代的对象很大,但是老年代空间不够了。
情况三:和-XX:CMSInitiatingOccupancyFaction
参数有关,默认值是92%,超过这个值就会GC。
针对大促销场景,由于我们之前在新生代优化了参数,所以对象进入老年代较慢,经验上来说,很可能是在系统运行半小时~1小时之后,才会有接近1GB的对象进入老年代。在大促期间,订单系统运行1小时之后,大促下单高峰期几乎都快过了,此时才可能会触发一次Full GC。这个高峰期过后,基本订单系统访问压力就很小了,那么GC的问题几乎就更不算什么了。
当然老年代也会触发Concurrent Mode Failure
问题。假设系统,运行1小时之后,老年代大概有900MB的对象了,剩余可用空间仅仅只有100MB了,然后CMS进行垃圾回收,垃圾回收期间是和系统程序并发的,如果系统此时还在创建对象,比如说很不巧有200MB对象要进来了,而老年代又放不下,那么此时就会进入Stop the World
,然后切换CMS为Serial Old,直接禁止程序运行,然后单线程进行老年代垃圾回收,回收掉900MB对象过后,再让系统继续运行。当然这个概率非常的小,我们没必要特意去优化它。
此时参数为:
-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=9
3、CMS后内存碎片整理频率
没必要特意去修改这个频率,对于上述大促销场景,在大促高峰期,Full GC可能也就1小时执行一次,然后大促高峰期过去之后,就没那么多的订单了,此时可能几个小时才会有一次Full GC。所以就保持默认的设置,每次Full GC之后都执行一次内存碎片整理就可以。要针对特定的业务场景来设定。仅仅针对这个参数来说:
- 如果Full GC相对频繁,就设置多次Full GC后进行碎片整理
- 如果不是很频繁,可以设置每次Full GC后进行碎片整理
五、面试题
1、一个面试题:parnew+cms的gc,如何保证只做ygc,jvm参数如何配置?
首先和垃圾收集器没什么关系,不同的垃圾收集器,只是它们的性能、吞吐量不同,并不影响垃圾回收的时机。只要在新生代根据对象的存活特征,合理的去分配Eden区和s1、s2区域的大小,尽量让垃圾在新生代被回收就好了,注意这边开启内存担保(jdk 1.6),如果eden区超过了老年代大小,不开担保的话每次MGC前都要FGC的。
2、为什么老年代回收比新生代慢?
新生代存活对象小,并且采用复制算法,速度很快,复制过去直接就删除,而老年代对象量较大,遍历标记、遍历清除,然后还要整理好腾出空间来,很耗时,耗时的就是步骤二和步骤四。初始标记是从GC Roots查找直接引用的对象,并发标记也是从GC Roots出发,通过每个对象的引用地址来看哪些对象活着的,活着的又很多,就很耗时。