YGC 问题排查,又让我涨姿势了!
代码排查没有进展后,我们开始从堆内存文件中寻找线索,使用 MAT 工具导入了第 1 步 dump 出来的堆文件后,然后通过 Dominator Tree 视图查看到了当前堆中的所有大对象。
立马发现 NewOldMappingService 这个类所占的空间很大,通过代码定位到:这个类位于第三方的 client 包中,由我们公司的商品团队提供,用于实现新旧类目转换(最近商品团队在对类目体系进行改造,为了兼容旧业务,需要进行新旧类目映射)。
进一步查看代码,发现这个类中存在大量的静态 HashMap,用于缓存新旧类目转换时需要用到的各种数据,以减少 RPC 调用,提高转换性能。
原本以为,非常接近问题的真相了,但是深入排查发现:这个类的所有静态变量全部在类加载时就初始化完数据了,虽然会占到 100 多 M 的内存,但是之后基本不会再新增数据。并且,这个类早在 3 月份就上线使用了,client 包的版本也一直没变过。
经过上面种种分析,这个类的静态 HashMap 会一直存活,经过多轮 YGC 后,最终晋升到老年代中,它不应该是 YGC 持续耗时过长的原因。因此,我们暂时排除了这个可疑点。
分析 YGC 处理 Reference 的耗时
团队对于 YGC 问题的排查经验很少,不知道再往下该如何分析了。基本扫光了网上可查到的所有案例,发现原因集中在这两类上:
1、对存活对象标注时间过长:比如重载了 Object 类的 Finalize 方法,导致标注 Final Reference 耗时过长;或者 String.in
tern 方法使用不当,导致 YGC 扫描 StringTable 时间过长。
2、长周期对象积累过多:比如本地缓存使用不当,积累了太多存活对象;或者锁竞争严重导致线程阻塞,局部变量的生命周期变长。
针对第 1 类问题,可以通过以下参数显示 GC 处理 Reference 的耗时-XX:+PrintReferenceGC。添加此参数后,可以看到不同类型的 reference 处理耗时都很短,因此又排除了此项因素。
再回到长周期对象进行分析
再往后,我们添加了各种 GC 参数试图寻找线索都没有结果,似乎要黔驴技穷,没有思路了。综合监控和种种分析来看:应该只有长周期对象才会引发我们这个问题。折腾了好几个小时,最终峰回路转,一个小伙伴重新从 MAT 堆内存中找到了第二个怀疑点。
从上面的截图可以看到:大对象中排在第 3 位的 ConfigService 类进入了我们的视野,该类的一个 ArrayList 变量中竟然包含了 270W 个对象,而且大部分都是相同的元素。ConfigService 这个类在第三方 Apollo 的包中,不过源代码被公司架构部进行了二次改造,通过代码可以看出:问题出在了第 11 行,每次调用 getConfig 方法时都会往 List 中添加元素,并且未做去重处理。
我们的广告服务在 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 个问题重新认识新生代
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 种回收器
SerialGC(串行回收器),最古老的一种,单线程执行,适合单 CPU 场景。
ParNew(并行回收器),将串行回收器多线程化,适合多 CPU 场景,需要搭配老年代 CMS 回收器一起使用。
ParallelGC(并行回收器),和 ParNew 不同点在于它关注吞吐量,可设置期望的停顿时间,它在工作时会自动调整堆大小和其他参数。
G1(Garage-First 回收器),JDK 9 及以后版本的默认回收器,兼顾新生代和老年代,将堆拆成一系列 Region,不要求内存块连续,新生代仍然是并行收集。
上述回收器均采用复制算法,都是独占式的,执行期间都会 Stop The World.
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,这里不作展开。
YGC 的执行过程
YGC 采用的复制算法,主要分成以下两个步骤:
1、查找 GC Roots,将其引用的对象拷贝到 S1 区 2、递归遍历第 1 步的对象,拷贝其引用的对象到 S1 区或者晋升到 Old 区
上述整个过程都是需要暂停业务线程的(STW),不过 ParNew 等新生代回收器可以多线程并行执行,提高处理效率。YGC 通过可达性分析算法,从 GC Root(可达对象的起点)开始向下搜索,标记出当前存活的对象,那么剩下未被标记的对象就是需要回收的对象。
可作为 YGC 时 GC Root 的对象包括以下几种:
1、虚拟机栈中引用的对象 2、方法区中静态属性、常量引用的对象 3、本地方法栈中引用的对象 4、被 Synchronized 锁持有的对象 5、记录当前被加载类的 SystemDictionary6、记录字符串常量引用的 StringTable7、存在跨代引用的对象 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 之间的对象都是已拷贝但未扫描的对象。
贝到 S1 区,_top 也会往前移动,直到_saved_mark_word 追上_top,说明 S1 区所有对象都已经遍历完成。
有一个细节点需要注意的是:拷贝对象的目标空间不一定是 S1 区,也可能是老年代。如果一个对象的年龄(经历的 YGC 次数)满足动态年龄判定条件便直接晋升到老年代中。对象的年龄保存在 Java 对象头的 mark word 数据结构中(如果大家对 Java 并发锁熟悉,肯定了解这个数据结构,不熟悉的建议查阅资料了解下,这里不做展开)。
最后的话
这篇文章通过线上案例分析并结合原理讲解,详细介绍了 YGC 的相关知识。从 YGC 实战角度出发,再简单总结一下:1、首先要清楚 YGC 的执行原理,比如年轻代的堆内存结构、Eden 区的内存分配机制、GC Roots 扫描、对象拷贝过程等。2、YGC 的核心步骤是标注和复制,绝部分 YGC 问题都集中在这两步,因此可以结合 YGC 日志和堆内存变化情况逐一排查,同时 dump 的堆内存文件需要仔细分析。
评论