案例描述
我们在做项目迁移后 (jdk1.6 ->1.7, 框架升级到spring,业务代码不做修改),发现迁移后的一些instance的gcoverhead在某个特定的时间段(我们用「T」来表示)会突然飙高,最终出现了jvm heap memory被耗尽的情况,导致应用无法响应。
案例分析
我们首先分析这类问题是什么原因造成的。我们具体找了其中一台机器查看metrics,发现jvm heap memory在「T」时间段骤降,最终无法恢复。
我们查看了对应时间段「T」的log,我们发现在这段期间有很多请求(我们用A代替)超时的异常,这类request会去一个service(我们用B代替)上拿资源。日志显示「T」时间段A类请求的耗时特别长,50000ms的请求比比皆是。我们开始以为A类请求是在请求大文件,所以超时时间设置比较大,因为这个项目本身就是要进行各类文件的操作。但从日志发现,这类文件也不过6M。这对于一个内网之间的请求调用而言,不是什么压力。所以我们猜测,是网络波动或者B服务本身不稳定导致的问题,。我们随后查看了A请求超时时间设置,为900000ms.
然后我们把目光转移到了迁移前老的项目,我们发现在该特定时段「T」也会出现请求超时这类错误,A请求的超时时间也为900000ms。迁移前老的项目也会因为过高的gcoverhead导致个别instance内存被耗尽,但影响的范围很小,对于个别instance出现异常,dev/ops的job会负责重启项目。而且老的项目对于这种超时异常导致的问题的承受能力是25k per hour,而迁移后的项目,仅仅是3.5k。
是什么差异导致这种不同的行为呢?我们先看一下迁移前后和迁移后的一些参数对比
参数的差异最终锁定到young generation的占比。我们拿到了项目的heap dump。对于ibm jdk1.6和open jdk1.7,两者使用的都是分代垃圾回收器,open jdk1.7里面就是我们熟悉的CMS。我们通过图片可以看出最后gcoverhead增高是由于old generation的频繁full gc导致的。对于CMS垃圾回收器的原理,这里简单描述一下,一个实例一般最开始创建于young generation(默认Survivor0 : Survivor1 : Eden = 1 : 1 : 8)的Eden区,在这个区域会进行young gc,触发条件Eden区满了。对象经过若干次(java对象头里面用4个bit来存储gc标记,最大次数是15,但针对CMS,默认是4次。JVM 参数的设定为-XX:MaxTenuringThreshold 。这个参数和 -XX:TargetSurvivorRatio配合使用:期望s区存活大小的参数。默认值为50,即50%。当一个S区中所有的age对象的大小如果大于等于Desired survivor size,则重新计算threshold,以age和MaxTenuringThreshold两者的最小值为准。来决定是否要晋升到老年代young gc后如果还存活,会进入老年代,当老年代满了,会触发full gc,full gc会STW,会消耗大量资源。
所以看出,升级到老年代,不一定完全按照age ,如果object 增长过大, 甚至只经过一次就直接进入到老年代了。
从图中我们可以看到Young generation中minior gc的频率特别高,原因是我们迁移后的项目的young generation的占比过低(800m/6000m,一般推荐1:3)这样就会导致一个问题,对象过早晋升到年老代,从而触发年老代频繁的full gc。对应到刚才的案例就是这些很占资源的长链接实例,经过多次young gc一直没有被回收 最终晋升到老年代 导致触发频繁的full gc 最终消耗尽jvm资源
解决办法
在迁移后的项目里面,框架默认young generation为800m,最终我们将迁移后的项目设定为2000m,当出现这种大面积资源获取缓慢时,项目不再出现抗不过的情况。至于为什么在timeout 900000ms这块没有进行调优,因为目前项目是在迁移阶段,我们是保持和老的项目相同的配置。而且问题我们有向用户那边反应,至于后续的更改,由用户来决定。
参考文档
https://www.oracle.com/java/technologies/javase/vmoptions-jsp.html