共计 6138 个字符,预计需要花费 16 分钟才能阅读完成。
在高并发下,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 人的职场进阶