建议学习:不服不行! Github即将爆发的《高同时秒杀顶级教程》先睹为快
在高并发性下,Java程序的GC问题是一个典型的问题,影响往往会进一步扩大。 无论是“GC频率太快”还是“GC花费太长时间”,GC期间都存在Stop The World问题,因此容易导致服务超时,从而导致性能问题。 我们团队负责的广告系统承担了比较大的C端流量,平峰期间的请求量几乎达到了几千QPS,过去也多次遇到过GC相关的在线问题。 本文还分享了另一个更加棘手的Young GC耗时过长的在线案例,并整理了关于YGC的知识点。 我希望你能得到。 内容分为以下两部分。
从一次在YGC上花费过多时间的案例到YGC相关知识点的总结今年4月,我们的广告服务在新版本上线后收到了大量的服务超时警告。 从下面的监视图中可以看到,超时量突然大面积增加,一分钟内达到了上千次接口超时。 详细介绍这个问题的故障诊断过程。
监控摄像头接到警告后,我们第一时间查看了监控系统,很快发现YoungGC时间过长的异常。 我们的程序大概在21点50分左右上线。 从下图可以看出,上线前YGC基本上在几十毫秒内完成,但上线后YGC明显花了很长时间,最长时间达到了3秒以上。
在YGC期间,程序将停止世界,但上游系统上设置的服务超时时间为几百毫秒,因此估计YGC花费了太多时间,导致了服务大规模超时。 按照常规故障诊断GC问题的步骤,我们立即卸下节点,并使用以下命令dump堆内存文件以保留现场。 jmap -dump:format=b,file=heap pid最后回滚了在线服务。 回滚后,服务立即恢复正常,接下来是长达一天的问题故障排除和修复流程。
要检查JVM的配置,请使用以下命令再次检查了JVM的参数
ps aux | grep ‘ application name=ad search ‘-xms4g-xmx4g-xmn2g-XSS 1024k-xx:parallelgcthreads=5- xx:useconcmarksweeeeeepgc 通过命令jmap -heap pid发现,新生代的Eden区为1.6G,S0和S1区均为0.2G。 此次上线未更改与JVM相关的参数。 此外,服务的请求数量与平时大致相同。 因此,推测此问题与在线代码相关的概率较高。
检查代码并回到YGC的原理来考虑这个问题,YGC的过程主要包括以下两个步骤。
1、从GC途径扫描对象,标记生存对象
2、将生存对象复制到S1区或升级到Old区
根据以下监测图,Survivor区使用率通常一直维持在较低水平(约30M左右),但上线后,Survivor区使用率开始波动,最多的时候快占0.2G。 另外,YGC的时间和Survivor区使用率基本呈正相关关系。 因此,我们推测越来越多的对象应该具有较长的生命周期,标记和复制过程也越来越费时间。
返回服务的总体表示,上游业务没有明显变化,核心接口的响应时间通常也几乎在200ms以内,YGC频率约每8秒发生一次。 很明显,关于局部变量,每次YGC时都可以立即回收。 那么为什么这么多对手在YGC之后幸存下来呢? 此外,锁定怀疑程序全局变量或类静态变量的对象。 但是,虽然对这次上线的代码进行了diff,但是没有发现在代码中引入了这样的变量。
在未对dump堆内存文件的解析代码进行故障排除后,开始从堆内存文件中查找线索,使用MAT工具导入从步骤1dump中导出的堆文件后,在Dominator Tree视图中
很快就发现NewOldMappingService这个类所占的空间很大。 此类位于第三方客户端包中,由我们的产品团队提供,用于新旧类别转换。 最近,商品团队正在改造类别体系,为了与旧业务兼容,需要进行新旧类别映射。 通过检查代码,我们发现此类中存在大量用于缓存新旧类转换所需的各种数据的静态HashMap,从而降低了RPC调用、提高转换的性能。
本以为非常接近问题的真相,但经过深入调查,这个类的所有静态变量在类加载时都初始化了数据。 虽然占用100个以上的内存,但是之后几乎不添加数据。 而且,这个类早在3月就在线使用了,客户端包的版本也一直没有改变。 经过以上分析,这种类型的静态HashMap一直生存下去,经过YGC多次后,最终晋升到老年代,应该不是YGC持续时间的理由。 因此,我们暂时排除了这一可疑之处。
YGC处理Reference的耗时团队对YGC问题的排查经验很少,不知道再怎么分析才好。 对所有可在网上查到的案例进行了基本扫描,发现原因集中在这两类。
1、存活对象标记时间过长:例如,由于重载了Object类的Finalize方法,导致标记Final Reference花费了太长时间; 或者,由于String.intern方法的使用错误,YGC扫描StringTable的时间太长。
2、长周期对象积累过多:如本地缓存使用不当,生存对象积累过多,或者锁定冲突严重,线程阻塞,局部变量生命周期延长。
针对第1类问题,可以通过以下参数显示GC处理Reference的时间-XX: PrintReferenceGC : 添加此参数后,您会发现各种类型的reference处理都很费时间,因此排除了此因素。
回到长周期对象分析后,我们尝试添加各种GC参数寻找线索,但没有结果,黔驴技穷,似乎没有主意。 综观监测和各种分析,只有长周期的对象应该引起了我们的问题。 虽然辛苦了好几个小时,但最终峰回路转,同伴再次从MAT堆内存中找到了第二个疑点。
从上面的屏幕快照中可以看到,大对象中排名第三的ConfigService类正在进入视野。 这个类的一个ArrayList变量竟然包含270W的对象,大多数都是同一个元素。 名为ConfigService的类位于第三方Apollo包中,但源代码经过了公司架构部分的辅助改造。 从代码中可以看到,问题出在第11行,在每次调用getConfig方法时,都没有在List中添加元素并重新处理。
我们的广告服务在apollo中存储了大量的广告策略配置,大多数请求都是通过调用ConfigService的getConfig方法来获取配置的,因此不断向namespaces静态变量添加新对象会导致这一问题至此,整个问题终于弄清楚了。 这个错误是架构部在定制开发apollo client包时不小心引入的,显然没有经过仔细测试,在我们上线的前一天发布在了中央仓库。 另一方面,公司基础构件库的版本采用super-pom方式统一维护,业务不可识别。
解决方案为了快速验证YGC验证时间过长是因为此问题,我们在一台服务器上用旧版本的apollo client软件包替换后,重新启动服务,观察约20分钟,YGC恢复正常。 最后,我们通知架构部修复bug,重新发布了super-pom,彻底解决了这个问题。 02 YGC相关知识点的总结在上述案例中,可以看出YGC问题的故障排除其实很难。 与FGC和OOM相比,YGC的日志简单,只需要新生代内存的变化和时间,同时从dump出来的堆内存需要仔细检查。 此外,如果不知道YGC的流程,故障排除将变得更加困难。 现在,我们将整理关于YGC的知识点,以便更全面地理解YGC。
关于YGC的知识点总结了五个问题来重新认识新生代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),显然后者的空间利用率更高新生代的实际可用空间是多少? YGC后,由于Survivor空间总是空闲的,所以新生代的可用内存空间为90%。 如果在YGC的log中或通过jmap -heap pid命令检查新生代空间时,发现capacity只有90%,请不要感到奇怪。 Eden区是如何加快内存分配的? HotSpot虚拟机使用了两种技术来加速内存分配。 分别是bump-the-pointer和tlab (随机定位缓冲器)。 由于Eden区域是连续的,因此在创建对象时,bump-the-pointer只需检查最后一个对象后面是否有足够的内存,即可加快内存分配。 对于多线程,TLAB技术通过Eden为每个线程分配空间,减少内存分配时的锁定争用,加快内存分配速度,提高吞吐量。
新生代4种回收器串行回收器( SerialGC ),是最古老的1种,单线程运行,适用于单CPU场景。 ParNew (并行回收)将串行回收多线程化,适用于多CPU场景。 必须与旧时代的CMS回收一起使用。 ParallelGC (并行回收装置)与ParNew的不同之处在于,可以关注吞吐量,设定期望的停止时间,运行时自动调整堆大小和其他参数。 G1 (首次回收)、JDK 9以后的默认回收,兼顾新生代和老一代,将堆分解为一系列Region,不要求内存块连续性,新生代仍然是并行收集。 上述回收器均采用复制算法,均为独占式,执行期均为Stop The World。
YGC的触发时机Eden区域的可用空间不足时,YGC将被触发。 让我们结合新生代对象的内存分配来看看详细的过程。 1、新对象首先尝试在堆栈上分配,否则尝试在TLAB上分配。 否则,需要看是否满足大对象的条件并分配到老年代,最后考虑在Eden区申请空间。 2、Eden区没有合适的空间时,触发YGC。 3、YGC处理Eden区和From Survivor区的生存对象,如果满足动态年龄判断条件,或者To Survivor区空间不够,则直接进入老年代,如果老年代空间不够,则会发生问题否则,将生存对象复制到“To Survivor”区域。 4、此时,Eden区和From Survivor区其余对象均为垃圾对象,可直接清除回收。 另外,在旧年采用CMS回收器时,为了缩短CMS Remark阶段的时间,也有可能触发一次YGC,但在此不展开。
YGC的执行过程YGC采用的复制算法主要分为以下两个步骤:
1、找到GC路线,将其参照地址复制到S1区域
2、递归遍历步骤1中的对象,将其引用位置复制到S1区域或提升到Old区域
上述所有进程都需要暂停业务线程( STW ),但ParNew等新生代回收器可以多线程并行执行)提高处理效率。 YGC通过可达性分析算法,从GC Root (可到达对象的起点)开始向下搜索,标记当前生存的对象后,未被标记的对象成为应回收的对象。
YGC时,GC Root的对象如下。
1、虚拟机堆栈中引用的对象
2、方法区域中的静态属性,常数引用的对象
3、本地方法堆栈中引用的对象
4 .被同步锁保持的对象
5、记录当前加载的类的系统目录
6、记录字符串常量引用的字符串表
7 .存在世代间引用的客体
8、与GC根位于同一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_mard
当托架移动到区域S1时,_top也会向前移动,直到_saved_mark_word赶上_top,表示区域S1中的所有对象都已扫描。 需要注意的一个细节是,要复制的目标空间不一定是S1区域,也可能是年代久远的地方。 如果某个对象的年龄(经历过的YGC次数)满足动态年龄判定条件,就会直接晋升到老年代。 对象的年龄存储在Java对象标头的标记世界数据结构中。 熟悉Java并发锁的人应该知道这个数据结构。 不熟悉的人建议参考资料理解。 不在这里展开。
最后,本文结合在线案例分析和原理进行了说明,并详细介绍了YGC的相关知识。 从YGC实战的观点出发,再简单总结一下吧。 1、首先要阐明YGC的执行原理,如新一代堆内存结构、Eden区内存分配机制、GC Roots扫描、对象复制过程等。 2、YGC的核心步骤是标记和复制,由于大部分YGC问题集中在这两个阶段,可以结合YGC日志和堆内存的变化情况逐一查找,同时需要仔细分析dump的堆内存文件。