YGC问题排查,又让我涨姿式了!

在高并发下,Java程序的GC问题属于很典型的一类问题,带来的影响每每会被进一步放大。无论是「GC频率过快」仍是「GC耗时太长」,因为GC期间都存在Stop The World问题,所以很容易致使服务超时,引起性能问题。java

咱们团队负责的广告系统承接了比较大的C端流量,平峰期间的请求量基本达到了上千QPS,过去也遇到了不少次GC相关的线上问题。算法

这篇文章,我再分享一个更棘手的Young GC耗时过长的线上案例,同时会整理下YGC相关的知识点,但愿让你有所收获。内容分红如下2个部分:缓存

  • 从一次YGC耗时过长的案例提及
  • YGC的相关知识点总结

从一次YGC耗时过长的案例提及

今年4月份,咱们的广告服务在新版本上线后,收到了大量的服务超时告警,经过下面的监控图能够看到:超时量忽然大面积增长,1分钟内甚至达到了上千次接口超时。下面详细介绍下该问题的排查过程。服务器

 

image.png

 

检查监控

收到告警后,咱们第一时间查看了监控系统,立马发现了YoungGC耗时过长的异常。咱们的程序大概在21点50左右上线,经过下图能够看出:在上线以前,YGC基本几十毫秒内完成,而上线后YGC耗时明显变长,最长甚至达到了3秒多。数据结构

 

image.png

 

因为YGC期间程序会Stop The World,而咱们上游系统设置的服务超时时间都在几百毫秒,所以推断:是由于YGC耗时过长引起了服务大面积超时。
按照GC问题的常规排查流程,咱们马上摘掉了一个节点,而后经过如下命令dump了堆内存文件用来保留现场。
jmap -dump:format=b,file=heap pid
最后对线上服务作了回滚处理,回滚后服务立马恢复了正常,接下来就是长达1天的问题排查和修复过程。多线程

确认JVM配置

用下面的命令,咱们再次检查了JVM的参数架构

ps aux | grep "applicationName=adsearch"
-Xms4g -Xmx4g -Xmn2g -Xss1024K 
-XX:ParallelGCThreads=5 
-XX:+UseConcMarkSweepGC 
-XX:+UseParNewGC 
-XX:+UseCMSCompactAtFullCollection 
-XX:CMSInitiatingOccupancyFraction=80
 

能够看到堆内存为4G,新生代和老年代均为2G,新生代采用ParNew收集器。
再经过命令 jmap -heap pid 查到:新生代的Eden区为1.6G,S0和S1区均为0.2G。
本次上线并未修改JVM相关的任何参数,同时咱们服务的请求量基本和往常持平。所以猜想:此问题大几率和上线的代码相关。并发

检查代码

再回到YGC的原理来思考这个问题,一次YGC的过程主要包括如下两个步骤:app

一、从GC Root扫描对象,对存活对象进行标注
二、将存活对象复制到S1区或者晋升到Old区高并发

根据下面的监控图能够看出:正常状况下,Survivor区的使用率一直维持在很低的水平(大概30M左右),可是上线后,Survivor区的使用率开始波动,最多的时候快占满0.2G了。并且,YGC耗时和Survivor区的使用率基本成正相关。所以,咱们推测:应该是长生命周期的对象愈来愈多,致使标注和复制过程的耗时增长。

 

image.png

 

再回到服务的总体表现:上游流量并无出现明显变化,正常状况下,核心接口的响应时间也基本在200ms之内,YGC的频率大概每8秒进行1次。

很显然,对于局部变量来讲,在每次YGC后就可以立刻被回收了。那为何还会有如此多的对象在YGC后存活下来呢?

咱们进一步将怀疑对象锁定在:程序的全局变量或者类静态变量上。可是diff了本次上线的代码,咱们并未发现代码中有引入此类变量。

对dump的堆内存文件进行分析

代码排查没有进展后,咱们开始从堆内存文件中寻找线索,使用MAT工具导入了第1步dump出来的堆文件后,而后经过Dominator Tree视图查看到了当前堆中的全部大对象。

 

image.png

 

立马发现NewOldMappingService这个类所占的空间很大,经过代码定位到:这个类位于第三方的client包中,由咱们公司的商品团队提供,用于实现新旧类目转换(最近商品团队在对类目体系进行改造,为了兼容旧业务,须要进行新旧类目映射)。

进一步查看代码,发现这个类中存在大量的静态HashMap,用于缓存新旧类目转换时须要用到的各类数据,以减小RPC调用,提升转换性能。

 

image.png

 

本来觉得,很是接近问题的真相了,可是深刻排查发现:这个类的全部静态变量所有在类加载时就初始化完数据了,虽然会占到100多M的内存,可是以后基本不会再新增数据。而且,这个类早在3月份就上线使用了,client包的版本也一直没变过。

通过上面种种分析,这个类的静态HashMap会一直存活,通过多轮YGC后,最终晋升到老年代中,它不该该是YGC持续耗时过长的缘由。所以,咱们暂时排除了这个可疑点。

分析YGC处理Reference的耗时

团队对于YGC问题的排查经验不多,不知道再往下该如何分析了。基本扫光了网上可查到的全部案例,发现缘由集中在这两类上:

一、对存活对象标注时间过长:好比重载了Object类的Finalize方法,致使标注Final Reference耗时过长;或者String.intern方法使用不当,致使YGC扫描StringTable时间过长。
二、长周期对象积累过多:好比本地缓存使用不当,积累了太多存活对象;或者锁竞争严重致使线程阻塞,局部变量的生命周期变长。

针对第1类问题,能够经过如下参数显示GC处理Reference的耗时-XX:+PrintReferenceGC。添加此参数后,能够看到不一样类型的 reference 处理耗时都很短,所以又排除了此项因素。

 

image.png

 

再回到长周期对象进行分析

再日后,咱们添加了各类GC参数试图寻找线索都没有结果,彷佛要黔驴技穷,没有思路了。综合监控和种种分析来看:应该只有长周期对象才会引起咱们这个问题。
折腾了好几个小时,最终峰回路转,一个小伙伴从新从MAT堆内存中找到了第二个怀疑点。

 

image.png

 

从上面的截图能够看到:大对象中排在第3位的ConfigService类进入了咱们的视野,该类的一个ArrayList变量中居然包含了270W个对象,并且大部分都是相同的元素。
ConfigService这个类在第三方Apollo的包中,不过源代码被公司架构部进行了二次改造,经过代码能够看出:问题出在了第11行,每次调用getConfig方法时都会往List中添加元素,而且未作去重处理

 

image.png

 

咱们的广告服务在apollo中存储了大量的广告策略配置,并且大部分请求都会调用ConfigService的getConfig方法来获取配置,所以会不断地往静态变量namespaces中添加新对象,从而引起此问题。

至此,整个问题终于水落石出了。这个BUG是由于架构部在对apollo client包进行定制化开发时不当心引入的,很显然没有通过仔细测试,而且恰好在咱们上线前一天发布到了中央仓库中,而公司基础组件库的版本是经过super-pom方式统一维护的,业务无感知。

解决方案

为了快速验证YGC耗时过长是由于此问题致使的,咱们在一台服务器上直接用旧版本的apollo client 包进行了替换,而后重启了服务,观察了将近20分钟,YGC恢复正常。
最后,咱们通知架构部修复BUG,从新发布了super-pom,完全解决了这个问题。
02 YGC的相关知识点总结
经过上面这个案例,能够看到YGC问题其实比较难排查。相比FGC或者OOM,YGC的日志很简单,只知道新生代内存的变化和耗时,同时dump出来的堆内存必需要仔细排查才行。

另外,若是不清楚YGC的流程,排查起来会更加困难。这里,我对YGC相关的知识点再作下梳理,方便你们更全面的理解YGC。

YGC的相关知识点总结

5个问题从新认识新生代

 

image.png

 

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在对象建立时,只须要检查最后一个对象后面是否有足够的内存便可,从而加快内存分配速度。

TLAB技术是对于多线程而言的,在Eden中为每一个线程分配一块区域,减小内存分配时的锁冲突,加快内存分配速度,提高吞吐量。

新生代的4种回收器

 

image.png

 

SerialGC(串行回收器),最古老的一种,单线程执行,适合单CPU场景。

ParNew(并行回收器),将串行回收器多线程化,适合多CPU场景,须要搭配老年代CMS回收器一块儿使用。

ParallelGC(并行回收器),和ParNew不一样点在于它关注吞吐量,可设置指望的停顿时间,它在工做时会自动调整堆大小和其余参数。

G1(Garage-First回收器),JDK 9及之后版本的默认回收器,兼顾新生代和老年代,将堆拆成一系列Region,不要求内存块连续,新生代仍然是并行收集。

上述回收器均采用复制算法,都是独占式的,执行期间都会Stop The World.

YGC的触发时机

当Eden区空间不足时,就会触发YGC。结合新生代对象的内存分配看下详细过程:

一、新对象会先尝试在栈上分配,若是不行则尝试在TLAB分配,不然再看是否知足大对象条件要在老年代分配,最后才考虑在Eden区申请空间。

二、若是Eden区没有合适的空间,则触发YGC。

三、YGC时,对Eden区和From Survivor区的存活对象进行处理,若是知足动态年龄判断的条件或者To Survivor区空间不够则直接进入老年代,若是老年代空间也不够了,则会发生promotion failed,触发老年代的回收。不然将存活对象复制到To Survivor区。

四、此时Eden区和From Survivor区的剩余对象均为垃圾对象,可直接抹掉回收。

此外,老年代若是采用的是CMS回收器,为了减小CMS Remark阶段的耗时,也有可能会触发一次YGC,这里不做展开。

YGC的执行过程

YGC采用的复制算法,主要分红如下两个步骤:

一、查找GC Roots,将其引用的对象拷贝到S1区
二、递归遍历第1步的对象,拷贝其引用的对象到S1区或者晋升到Old区

上述整个过程都是须要暂停业务线程的(STW),不过ParNew等新生代回收器能够多线程并行执行,提升处理效率。
YGC经过可达性分析算法,从GC Root(可达对象的起点)开始向下搜索,标记出当前存活的对象,那么剩下未被标记的对象就是须要回收的对象。

 

image.png

 

可做为YGC时GC Root的对象包括如下几种:

一、虚拟机栈中引用的对象
二、方法区中静态属性、常量引用的对象
三、本地方法栈中引用的对象
四、被Synchronized锁持有的对象
五、记录当前被加载类的SystemDictionary
六、记录字符串常量引用的StringTable
七、存在跨代引用的对象
八、和GC Root处于同一CardTable的对象

其中1-3是你们容易想到的,而4-8很容易被忽视,却极有多是分析YGC问题时的线索入口。

另外须要注意的是,针对下图中跨代引用的状况,老年代的对象A也必须做为GC Root的一部分,可是若是每次YGC时都去扫描老年代,确定存在效率问题。在HotSpot JVM,引入卡表(Card Table)来对跨代引用的标记进行加速。

 

image.png

 

Card Table,简单理解是一种空间换时间的思路,由于存在跨代引用的对象大概占比不到1%,所以可将堆空间划分红大小为512字节的卡页,若是卡页中有一个对象存在跨代引用,则能够用1个字节来标识该卡页是dirty状态,卡页状态进一步经过写屏障技术进行维护。

遍历完GC Roots后,便可以找出第一批存活的对象,而后将其拷贝到S1区。接下来,就是一个递归查找和拷贝存活对象的过程。

S1区为了方便维护内存区域,引入了两个指针变量:_saved_mark_word和_top,其中_saved_mark_word表示当前遍历对象的位置,_top表示当前可分配内存的位置,很显然,_saved_mark_word到_top之间的对象都是已拷贝但未扫描的对象。

 

image.png

 

贝到S1区,_top也会往前移动,直到_saved_mark_word追上_top,说明S1区全部对象都已经遍历完成。

有一个细节点须要注意的是:拷贝对象的目标空间不必定是S1区,也多是老年代。若是一个对象的年龄(经历的YGC次数)知足动态年龄断定条件便直接晋升到老年代中。对象的年龄保存在Java对象头的mark word数据结构中(若是你们对Java并发锁熟悉,确定了解这个数据结构,不熟悉的建议查阅资料了解下,这里不作展开)。

最后的话

这篇文章经过线上案例分析并结合原理讲解,详细介绍了YGC的相关知识。从YGC实战角度出发,再简单总结一下:
一、首先要清楚YGC的执行原理,好比年轻代的堆内存结构、Eden区的内存分配机制、GC Roots扫描、对象拷贝过程等。
二、YGC的核心步骤是标注和复制,绝部分YGC问题都集中在这两步,所以能够结合YGC日志和堆内存变化状况逐一排查,同时dump的堆内存文件须要仔细分析。

看完三件事❤️

若是你以为这篇内容对你还蛮有帮助,我想邀请你帮我三个小忙:

  1. 点赞,转发,有大家的 『点赞和评论』,才是我创造的动力。

  2. 关注公众号 『 java烂猪皮 』,不按期分享原创知识。

  3. 同时能够期待后续文章ing🚀

 

相关文章
相关标签/搜索