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

  内容分成以下2个部分:

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

  • 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的参数

psaux | 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时都去扫描老年代,肯定存在效率问题。在HotSpotJVM,引入卡表(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日志和堆内存变化情况逐一排查,同时dump的堆内存文件需要仔细分析。

转自https://www.sohu.com/a/411190854_355142

YGC问题排查的更多相关文章

  1. 一次ygc越来越慢的问题排查过程

    问题发现场景 某天突然收到线上应用的gc时间过长的告警,刚开始只有一台机器偶尔报一下,后续其他机器也纷纷告警,具体告警的阈值是应用10分钟内ygc的总时长达到了6.6s. 初步排除过程 按照gc问题常 ...

  2. java问题排查总结

    前些天发现:http://hellojava.info/这个站点,关于java问题排查分析总结线上故障总结其实是最有价值的,好的总结就是一个系统演进历史,是团队难得的积累沉淀. 花了不少时间看了下,顺 ...

  3. Hadoop中的问题排查思路

    一.概述: 在实际使用hadoop的过程中,由于涉及到多台服务器.每台机器上可能还有多个服务等.所以当集群环境出现问题时,快速定位到错误出现的地方尤为重要. 在排查错误的过程中,基本上就是通过既有的工 ...

  4. JVM 线上故障排查基本操作

    # 前言 对于后端程序员,特别是 Java 程序员来讲,排查线上问题是不可避免的.各种 CPU 飚高,内存溢出,频繁 GC 等等,这些都是令人头疼的问题.楼主同样也遇到过这些问题,那么,遇到这些问题该 ...

  5. 系统运行缓慢,CPU 100%,以及Full GC次数过多问题的排查思路

    前言 处理过线上问题的同学基本上都会遇到系统突然运行缓慢,CPU 100%,以及Full GC次数过多的问题.当然,这些问题的最终导致的直观现象就是系统运行缓慢,并且有大量的报警. 本文主要针对系统运 ...

  6. 一次ES故障排查过程

    作者:莫那鲁道 原文:http://thinkinjava.cn/#blog 某天晚上,某环境 ES 出现阻塞, 运行缓慢.于是开始排查问题的过程. 开始 思路:现象是阻塞,通常是 CPU 彪高,导致 ...

  7. jvm排查问题常用命令及注释

    本文将介绍JDK自带的JVM排查工具.其提供的排查工具有: (1)jps:JVM Process Status Tool,显示系统内所有的JVM进程: (2)jstat:JVM Statistics ...

  8. 线上问题排查(2)——JDK内置工具

    https://www.cnblogs.com/keanuyaoo/p/3253663.html 常用命令目录: jps命令(Java Virtual Machine Process Status T ...

  9. Java线上问题排查思路及Linux常用问题分析命令学习

    前言 之前线上有过一两次OOM的问题,但是每次定位问题都有点手足无措的感觉,刚好利用星期天,以测试环境为模版来学习一下Linux常用的几个排查问题的命令. 也可以帮助自己在以后的工作中快速的排查线上问 ...

随机推荐

  1. 详解apollo的设计与使用

    简介 apollo 是一款由携程团队开发的配置中心,可以实现配置的集中管理.分环境管理.即时生效等等.在这篇博客中,我们可以了解到: 为什么使用配置中心 如何设计一个配置中心 apollo 是如何设计 ...

  2. Linux 安装及管理程序

    Linux 安装及管理程序 目录 一.Linux应用程序基础 1.1.应用程序与系统命令的关系 1.2.典型应用程序的目录结构 1.2.常见的软件包封装类型 二.RPM包管理工具 2.1.RPM软件包 ...

  3. 基于 CentOS 8 搭建 openLDAP 服务器

    转载请注明原文地址:基于 CentOS 8 搭建 openLDAP 服务器 环境 OS: CentOS 8.4.2105 PHP: 7.4.21 注意 CentOS 7 中可能默认提供了 openLD ...

  4. codeforces 830E dp

    大致题意: n场比赛,k个钱币.赢一场获得一个钱币,输一场失去一个钱币,一旦钱币数量为2k个或者0个,就马上离开比赛.给出n个长度字符串,由W,D,L,?四个字符组成,W表示赢,L表示输,D表示平局, ...

  5. 重新整理 .net core 实践篇————跨域问题四十一]

    前言 简单整理一下.net core 的跨域问题,这个以前也整理过比较详细的,故而在此简单整理一下. 正文 对跨域相对的就是同源,什么是同源呢? 协议相同(http/https) 主机(域名)相同 端 ...

  6. 深入理解 Go Map

    文章参考:Go语言设计与实现3.3 哈希表 哈希表的意义不言而喻,它能提供 O(1) 复杂度的读写性能,所以主流编程语言中都内置有哈希表. 哈希表的关键在于哈希函数, 好的哈希函数能减少哈希碰撞,提供 ...

  7. MyBatis:MyBatis-Plus条件构造器EntityWrapper

    EntityWrapper 简介 1. MybatisPlus 通过 EntityWrapper(简称 EW,MybatisPlus 封装的一个查询条件构造器)或者 Condition(与 EW 类似 ...

  8. Spring:在web.xml正确加载spring配置文件的方式

    web.xml加载spring配置文件的方式主要依据该配置文件的名称和存放的位置不同来区别,目前主要有两种方式. 1. 如果spring配置文件的名称为applicationContext.xml,并 ...

  9. git配置多个ssh key

    随着工作的深入,我们通常都会有多个git账户.比如公司gitlab一个账户,github或gitee有一个账户.而平时我们又回通过 配置ssh公私钥认证的方式省去繁琐的登录远程仓库的操作,不同的git ...

  10. python 字典添加数据

    dzkuaa={}#类似{"张三":2,"李四":3} for i in range(23): ming=bj[i] if dzkuaa.get(ming)== ...