YGC问题排查,又让我涨姿势了!
在高并发下,Java程序的GC问题属于很典型的一类问题,带来的影响往往会被进一步放大。不管是「GC频率过快」还是「GC耗时太长」,由于GC期间都存在Stop The World问题,因此很容易导致服务超时,引发性能问题。
我们团队负责的广告系统承接了比较大的C端流量,平峰期间的请求量基本达到了上千QPS,过去也遇到了很多次GC相关的线上问题。
5月份的这篇文章我介绍了一个Full GC过于频繁的案例,并且针对JVM的堆内存结构和GC原理进行了系统性的总结。
这篇文章,我再分享一个更棘手的Young GC耗时过长的线上案例,同时会整理下YGC相关的知识点,希望让你有所收获。内容分成以下2个部分:
从一次YGC耗时过长的案例说起 YGC的相关知识点总结
今年4月份,我们的广告服务在新版本上线后,收到了大量的服务超时告警,通过下面的监控图可以看到:超时量突然大面积增加,1分钟内甚至达到了上千次接口超时。下面详细介绍下该问题的排查过程。
-Xms4g -Xmx4g -Xmn2g -Xss1024K
-XX:ParallelGCThreads=5
-XX:+UseConcMarkSweepGC
-XX:+UseParNewGC
-XX:+UseCMSCompactAtFullCollection
-XX:CMSInitiatingOccupancyFraction=80
1、从GC Root扫描对象,对存活对象进行标注
2、将存活对象复制到S1区或者晋升到Old区
再回到服务的整体表现:上游流量并没有出现明显变化,正常情况下,核心接口的响应时间也基本在200ms以内,YGC的频率大概每8秒进行1次。
很显然,对于局部变量来说,在每次YGC后就能够马上被回收了。那为什么还会有如此多的对象在YGC后存活下来呢?
我们进一步将怀疑对象锁定在:程序的全局变量或者类静态变量上。但是diff了本次上线的代码,我们并未发现代码中有引入此类变量。
立马发现NewOldMappingService这个类所占的空间很大,通过代码定位到:这个类位于第三方的client包中,由我们公司的商品团队提供,用于实现新旧类目转换(最近商品团队在对类目体系进行改造,为了兼容旧业务,需要进行新旧类目映射)。
原本以为,非常接近问题的真相了,但是深入排查发现:这个类的所有静态变量全部在类加载时就初始化完数据了,虽然会占到100多M的内存,但是之后基本不会再新增数据。并且,这个类早在3月份就上线使用了,client包的版本也一直没变过。
团队对于YGC问题的排查经验很少,不知道再往下该如何分析了。基本扫光了网上可查到的所有案例,发现原因集中在这两类上:
1、对存活对象标注时间过长:比如重载了Object类的Finalize方法,导致标注Final Reference耗时过长;或者String.intern方法使用不当,导致YGC扫描StringTable时间过长。
2、长周期对象积累过多:比如本地缓存使用不当,积累了太多存活对象;或者锁竞争严重导致线程阻塞,局部变量的生命周期变长。
折腾了好几个小时,最终峰回路转,一个小伙伴重新从MAT堆内存中找到了第二个怀疑点。
ConfigService这个类在第三方Apollo的包中,不过源代码被公司架构部进行了二次改造,通过代码可以看出:问题出在了第11行,每次调用getConfig方法时都会往List中添加元素,并且未做去重处理。
我们的广告服务在apollo中存储了大量的广告策略配置,而且大部分请求都会调用ConfigService的getConfig方法来获取配置,因此会不断地往静态变量namespaces中添加新对象,从而引发此问题。
至此,整个问题终于水落石出了。这个BUG是因为架构部在对apollo client包进行定制化开发时不小心引入的,很显然没有经过仔细测试,并且刚好在我们上线前一天发布到了中央仓库中,而公司基础组件库的版本是通过super-pom方式统一维护的,业务无感知。
通过上面这个案例,可以看到YGC问题其实比较难排查。相比FGC或者OOM,YGC的日志很简单,只知道新生代内存的变化和耗时,同时dump出来的堆内存必须要仔细排查才行。
另外,如果不清楚YGC的流程,排查起来会更加困难。这里,我对YGC相关的知识点再做下梳理,方便大家更全面的理解YGC。
1. 5个问题重新认识新生代
YGC 在新生代中进行,首先要清楚新生代的堆结构划分。新生代分为Eden区和两个Survivor区,其中Eden:from:to = 8:1:1 (比例可以通过参数 –XX:SurvivorRatio 来设定 ),这是最基本的认识。
为什么会有新生代?
如果不分代,所有对象全部在一个区域,每次GC都需要对全堆进行扫描,存在效率问题。分代后,可分别控制回收频率,并采用不同的回收算法,确保GC性能全局最优。
为什么新生代会采用复制算法?
新生代的对象朝生夕死,大约90%的新建对象可以被很快回收,复制算法成本低,同时还能保证空间没有碎片。虽然标记整理算法也可以保证没有碎片,但是由于新生代要清理的对象数量很大,将存活的对象整理到待清理对象之前,需要大量的移动操作,时间复杂度比复制算法高。
为什么新生代需要两个Survivor区?
为了节省空间考虑,如果采用传统的复制算法,只有一个Survivor区,则Survivor区大小需要等于Eden区大小,此时空间消耗是8 * 2,而两块Survivor可以保持新对象始终在Eden区创建,存活对象在Survivor之间转移即可,空间消耗是8+1+1,明显后者的空间利用率更高。
新生代的实际可用空间是多少?
YGC后,总有一块Survivor区是空闲的,因此新生代的可用内存空间是90%。在YGC的log中或者通过 jmap -heap pid 命令查看新生代的空间时,如果发现capacity只有90%,不要觉得奇怪。
Eden区是如何加速内存分配的?
HotSpot虚拟机使用了两种技术来加快内存分配。分别是bump-the-pointer和TLAB(Thread Local Allocation Buffers)。
由于Eden区是连续的,因此bump-the-pointer在对象创建时,只需要检查最后一个对象后面是否有足够的内存即可,从而加快内存分配速度。
2. 新生代的4种回收器
SerialGC(串行回收器),最古老的一种,单线程执行,适合单CPU场景。
ParNew(并行回收器),将串行回收器多线程化,适合多CPU场景,需要搭配老年代CMS回收器一起使用。
ParallelGC(并行回收器),和ParNew不同点在于它关注吞吐量,可设置期望的停顿时间,它在工作时会自动调整堆大小和其他参数。
G1(Garage-First回收器),JDK 9及以后版本的默认回收器,兼顾新生代和老年代,将堆拆成一系列Region,不要求内存块连续,新生代仍然是并行收集。
上述回收器均采用复制算法,都是独占式的,执行期间都会Stop The World.
当Eden区空间不足时,就会触发YGC。结合新生代对象的内存分配看下详细过程:
1、新对象会先尝试在栈上分配,如果不行则尝试在TLAB分配,否则再看是否满足大对象条件要在老年代分配,最后才考虑在Eden区申请空间。
2、如果Eden区没有合适的空间,则触发YGC。
3、YGC时,对Eden区和From Survivor区的存活对象进行处理,如果满足动态年龄判断的条件或者To Survivor区空间不够则直接进入老年代,如果老年代空间也不够了,则会发生promotion failed,触发老年代的回收。否则将存活对象复制到To Survivor区。
4、此时Eden区和From Survivor区的剩余对象均为垃圾对象,可直接抹掉回收。
此外,老年代如果采用的是CMS回收器,为了减少CMS Remark阶段的耗时,也有可能会触发一次YGC,这里不作展开。
YGC采用的复制算法,主要分成以下两个步骤:
1、查找GC Roots,将其引用的对象拷贝到S1区
2、递归遍历第1步的对象,拷贝其引用的对象到S1区或者晋升到Old区
可作为YGC时GC Root的对象包括以下几种:
1、虚拟机栈中引用的对象
2、方法区中静态属性、常量引用的对象
3、本地方法栈中引用的对象
4、被Synchronized锁持有的对象
5、记录当前被加载类的SystemDictionary
6、记录字符串常量引用的StringTable
7、存在跨代引用的对象
8、和GC Root处于同一CardTable的对象
其中1-3是大家容易想到的,而4-8很容易被忽视,却极有可能是分析YGC问题时的线索入口。
另外需要注意的是,针对下图中跨代引用的情况,老年代的对象A也必须作为GC Root的一部分,但是如果每次YGC时都去扫描老年代,肯定存在效率问题。在HotSpot JVM,引入卡表(Card Table)来对跨代引用的标记进行加速。
Card Table,简单理解是一种空间换时间的思路,因为存在跨代引用的对象大概占比不到1%,因此可将堆空间划分成大小为512字节的卡页,如果卡页中有一个对象存在跨代引用,则可以用1个字节来标识该卡页是dirty状态,卡页状态进一步通过写屏障技术进行维护。
遍历完GC Roots后,便能够找出第一批存活的对象,然后将其拷贝到S1区。接下来,就是一个递归查找和拷贝存活对象的过程。
如上图所示,每次扫描完一个对象,_saved_mark_word会往前移动,期间如果有新对象也会拷贝到S1区,_top也会往前移动,直到_saved_mark_word追上_top,说明S1区所有对象都已经遍历完成。
有一个细节点需要注意的是:拷贝对象的目标空间不一定是S1区,也可能是老年代。如果一个对象的年龄(经历的YGC次数)满足动态年龄判定条件便直接晋升到老年代中。对象的年龄保存在Java对象头的mark word数据结构中(如果大家对Java并发锁熟悉,肯定了解这个数据结构,不熟悉的建议查阅资料了解下,这里不做展开)。