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

咱们团队负责的广告零碎承接了比拟大的C端流量,平峰期间的申请量根本达到了上千QPS,过来也遇到了很屡次GC相干的线上问题。

这篇文章,我分享一个辣手的Young GC耗时过长的线上案例,同时会整顿下YGC相干的知识点,心愿让你有所播种。内容分成以下2个局部:

1、从一次YGC耗时过长的案例说起

2、YGC的相干知识点总结


01 从一次YGC耗时过长的案例说起

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

1. 查看监控

收到告警后,咱们第一工夫查看了监控零碎,立马发现了YoungGC耗时过长的异样。 咱们的 程序大略在 21 点50 左右上线,通过下图能够看出: 在上线之前,YGC根本几十毫秒内实现,而上线后YGC耗时显著变长,最长甚至达到了3秒多。

因为 YGC期间程序会 Stop The World ,而咱们上游零碎设置的服务超时工夫都在几百毫秒,因而推断:是因为YGC耗时过长引发了服务大面积超时。

依照GC问题的惯例排查流程,咱们立即摘掉了一个节点,而后通过以下命令dump了堆内存文件用来保留现场。

jmap -dump:format=b,file=heap pid

最初对线上服务做了回滚解决,回滚后服务立马复原了失常,接下来就是长达1天的问题排查和修复过程。

2. 确认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相干的任何参数,同时咱们服务的申请量根本和平常持平。因而猜想:此问题大概率和上线的代码相干。

3. 查看代码

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

1、从GC Root扫描对象,对存活对象进行标注

2、将存活对象复制到S1区或者降职到Old区

依据上面的监控图能够看出:失常状况下,Survivor区的使用率始终维持在很低的程度(大略30M左右),然而上线后,Survivor区的使用率开始稳定,最多的时候快占满0.2G了。而且,YGC耗时和Survivor区的使用率根本成正相干。因而,咱们揣测:应该是长生命周期的对象越来越多,导致标注和复制过程的耗时减少。

再回到服务的整体体现:上游流量并没有呈现显著变动,失常状况下,外围接口的响应工夫也根本在200ms以内,YGC的频率大略每8秒进行1次。

很显然,对于局部变量来说,在每次YGC后就可能马上被回收了。那为什么还会有如此多的对象在YGC后存活下来呢?

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

4. 对dump的堆内存文件进行剖析

代码排查没有停顿后,咱们开始从堆内存文件中寻找线索,应用MAT工具导入了第1步dump进去的堆文件后,而后通过Dominator Tree视图查看到了以后堆中的所有大对象。

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

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

本来认为,十分靠近问题的假相了,然而深刻排查发现:这个类的所有动态变量全副在类加载时就初始化完数据了,尽管会占到100多M的内存,然而之后根本不会再新增数据。并且,这个类早在3月份就上线应用了,client包的版本也始终没变过。

通过下面种种剖析,这个类的动态HashMap会始终存活,通过多轮YGC后,最终降职到老年代中,它不应该是YGC继续耗时过长的起因。因而,咱们临时排除了这个可疑点。

5. 剖析YGC解决Reference的耗时

团队对于YGC问题的排查教训很少,不晓得再往下该如何剖析了。根本扫光了网上可查到的所有案例,发现起因集中在这两类上:

1、对存活对象标注工夫过长:比方重载了Object类的Finalize办法,导致标注Final Reference耗时过长;或者String.intern办法使用不当,导致YGC扫描StringTable工夫过长。

2、长周期对象积攒过多:比方本地缓存使用不当,积攒了太多存活对象;或者锁竞争重大导致线程阻塞,局部变量的生命周期变长。

针对第1类问题,能够通过以下参数显示GC解决Reference的耗时-XX:+PrintReferenceGC。 增加此参数后,能够看到不同类型的 reference 解决耗时都很短,因而又排除了此项因素。

6. 再回到长周期对象进行剖析

再往后,咱们增加了各种GC参数试图寻找线索都没有后果,仿佛要江郎才尽,没有思路了。综合监控和种种剖析来看:应该只有长周期对象才会引发咱们这个问题。

折腾了好几个小时,最终峰回路转,一个小伙伴从新从MAT堆内存中找到了第二个狐疑点。

从下面的截图能够看到:大对象中排在第3位的ConfigService类进入了咱们的视线,该类的一个ArrayList变量中居然蕴含了270W个对象,而且大部分都是雷同的元素。

ConfigService这个类在第三方Apollo的包中,不过源代码被公司架构部进行了二次革新,通过代码能够看出:问题出在了第11行,每次调用getConfig办法时都会往List中增加元素,并且未做去重解决。

咱们的广告服务在apollo中存储了大量的广告策略配置,而且大部分申请都会调用ConfigService的getConfig办法来获取配置,因而会一直地往动态变量namespaces中增加新对象,从而引发此问题。

至此,整个问题终于上不着天;下不着地了。这个BUG是因为架构部在对apollo client包进行定制化开发时不小心引入的,很显然没有通过认真测试,并且刚好在咱们上线前一天公布到了地方仓库中,而公司根底组件库的版本是通过super-pom形式对立保护的,业务无感知。

7. 解决方案

为了疾速验证YGC耗时过长是因为此问题导致的,咱们在一台服务器上间接用旧版本的apollo client 包进行了替换,而后重启了服务,察看了将近20分钟,YGC恢复正常。

最初,咱们 告诉架构部修复BUG,从新公布了super-pom ,彻底解决了这个问题。

02 YGC的相干知识点总结

通过下面这个案例,能够看到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在对象创立时,只须要查看最初一个对象前面是否有足够的内存即可,从而放慢内存调配速度。

TLAB技术是对于多线程而言的,在Eden中为每个线程调配一块区域,缩小内存调配时的锁抵触,放慢内存调配速度,晋升吞吐量。

2. 新生代的4种回收器

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

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

ParallelGC(并行回收器),和ParNew不同点在于它关注吞吐量,可设置冀望的进展工夫,它在工作时会主动调整堆大小和其余参数。

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

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

3. YGC的触发机会

当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,这里不作开展。

4. YGC的执行过程

YGC采纳的复制算法,次要分成以下两个步骤:

1、查找GC Roots,将其援用的对象拷贝到S1区

2、递归遍历第1步的对象,拷贝其援用的对象到S1区或者降职到Old区

上述整个过程都是须要暂停业务线程的(STW),不过ParNew等新生代回收器能够多线程并行执行,进步解决效率。

YGC通过可达性剖析算法,从GC Root(可达对象的终点)开始向下搜寻,标记出以后存活的对象,那么剩下未被标记的对象就是须要回收的对象。

可作为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区。接下来,就是一个递归查找和拷贝存活对象的过程。

S1区为了不便保护内存区域,引入了两个指针变量: \_saved\_mark\_word和\_top,其中\_saved\_mark\_word示意以后遍历对象的地位,\_top示意以后可分配内存的地位,很显然,\_saved\_mark\_word到\_top之间的对象都是已拷贝但未扫描的对象。

如上图所示,每次扫描完一个对象,\_saved\_mark\_word会往前挪动,期间如果有新对象也会拷贝到S1区,\_top也会往前挪动,直到\_saved\_mark\_word追上\_top,阐明S1区所有对象都曾经遍历实现。

有一个细节点须要留神的是:拷贝对象的指标空间不肯定是S1区,也可能是老年代。如果一个对象的年龄(经验的YGC次数)满足动静年龄断定条件便间接降职到老年代中。对象的年龄保留在Java对象头的mark word数据结构中(如果大家对Java并发锁相熟,必定理解这个数据结构,不相熟的倡议查阅材料理解下,这里不做开展)。

写在最初

这篇文章通过线上案例剖析并联合原理解说,具体介绍了YGC的相干常识。从YGC实战角度登程,再 简略总结一下:

1、首先要分明YGC的执行原理,比方年老代的堆内存构造、Eden区的内存分配机制、GC Roots扫描、对象拷贝过程等。

2、YGC的外围步骤是标注和复制,绝局部YGC问题都集中在这两步,因而能够联合YGC日志和堆内存变动状况逐个排查,同时d ump的堆内存文件 须要仔细分析 。

如果大家对JVM性能调优和GC案例感兴趣,倡议关注前阿里大牛「你假笨」创立的PerfMa社区,外面有很多高质量的JVM文章。

作者简介:985硕士,前亚马逊工程师,现58转转技术总监

欢送关注我的集体公众号:IT人的职场进阶