JVM GC 调优:GC 问题发现工具,五大 GC 异常模式,四大调优方案与案例实战
摘要:本文系统梳理 JVM 垃圾回收(GC)调优的核心体系,详细介绍 GC 问题的发现工具( jstat、VisualVM、GCEasy 等)、常见 GC 异常模式(如内存泄漏、持续 FullGC),并提供可落地的解决手段(基础 JVM 参数优化、垃圾回收器选择、回收器参数调优),最后通过实战案例演示调优全流程,帮助开发者定位并解决 GC 引发的性能问题。
GC 调优
GC 调优指的是对垃圾回收(Garbage Collection)进行调优。GC 调优的主要目标是避免由垃圾回收引起程序性能下降。
GC 调优的核心分成三部分:
-
通用 Jvm 参数的设置。
-
特定垃圾回收器的 Jvm 参数的设置。
-
解决由频繁的 FULLGC 引起的程序性能问题。
GC 调优没有唯一的标准答案,如何调优与硬件、程序本身、使用情况均有关系,重点学习调优的工具和方法。
1. GC 调优的核心指标
1.1 吞吐量
吞吐量分为业务吞吐量和垃圾回收吞吐量。
业务吞吐量指的在一段时间内,程序需要完成的业务数量。
比如企业中对于吞吐量的要求可能会是这样的:
支持用户每天生成 10000 笔订单
在晚上 8 点到 10 点,支持用户查询 50000 条商品信息
保证高吞吐量的常规手段有两条:
1. 优化业务执行性能,减少单次业务的执行时间
2. 优化垃圾回收吞吐量
1.1.1 垃圾回收吞吐量
垃圾回收吞吐量指的是 CPU 用于执行用户代码的时间与 CPU 总执行时间的比值,即吞吐量 = 执行用户代码时间 /(执行用户代码时间 + GC 时间)。吞吐量数值越高,垃圾回收的效率就越高,允许更多的 CPU 时间去处理用户的业务,相应的业务吞吐量也就越高。
例如:虚拟机总共运行了 100 秒,其中 GC 花掉 1 秒,那么吞吐量就是 99%
1.2 延迟
延迟指的是从用户发起一个请求到收到响应这其中经历的时间。比如企业中对于延迟的要求可能会是这样的:所有的请求必须在 5 秒内返回给用户结果
延迟 = GC 延迟 + 业务执行时间,所以如果 GC 时间过长,会影响到用户的使用。
1.3 内存使用量
内存使用量指的是 Java 应用占用系统内存的最大值,一般通过 Jvm 参数调整,在满足上述两个指标的前提下,这个值越小越好。
2. GC 调优的步骤
GC 调优的步骤总共分为四个步骤:
-
发现问题:通过监控工具尽可能早地发现 GC 时间过长、频率过高的现象
-
诊断问题:通过分析工具,诊断问题的产生原因
-
修复问题:调整 JVM 参数或者修复源代码中的问题
-
测试验证:在测试环境运行之后获得 GC 数据,验证问题是否解决
2.1 发现问题 - 常用工具
Jstat 工具
Jstat 工具是 JDK 自带的一款监控工具,可以提供各种垃圾回收、类加载、编译信息等不同的数据。使用方法为:jstat -gc 进程ID 每次统计的间隔(毫秒) 统计次数
-
C 代表 Capacity 容量,U 代表 Used 使用量
-
S – 幸存者区,E – 伊甸园区,O – 老年代,M – 元空间
-
YGC、YGT:年轻代 GC 次数和 GC 耗时(单位:秒)
-
FGC、FGCT:Full GC 次数和 Full GC 耗时
-
GCT:GC 总耗时
Visualvm 插件
VisualVm 中提供了一款 Visual GC 插件,实时监控 Java 进程的堆内存结构、堆内存变化趋势以及垃圾回收时间的变化趋势。同时还可以监控对象晋升的直方图。
Prometheus + Grafana
Prometheus+Grafana 是企业中运维常用的监控方案,其中 Prometheus 用来采系统或者应用的相关数据,同时具备告警功能。Grafana 可以将 Prometheus 采集到的数据以可视化的方式展示。
Java 程序员要学会如何读懂 Grafana 展示的 Java 虚拟机相关的参数。
2.1.1 GC 日志
通过 GC 日志,可以更好的看到垃圾回收细节上的数据,同时也可以根据每款垃圾回收器的不同特点更好地发现存在的问题。
使用方法(JDK 8 及以下):-XX:+PrintGCDetails -Xloggc:文件名
使用方法(JDK 9+):-Xlog:gc*:file=文件名
操作步骤:
1.添加虚拟机参数
2.打开日志文件就可以看到 GC 日志
2.1.2 GCViewer
GCViewer 是一个将 GC 日志转换成可视化图表的小工具:github 地址https://github.com/chewiebug/GCViewer
使用方法:java -jar gcviewer_1.3.4.jar 日志文件.log
2.1.3 GCEasy
GCeasy 是业界首款使用 AI 机器学习技术在线进行 GC 分析和诊断的工具。定位内存泄漏、GC 延迟高的问题,提供 JVM 参数优化建议,支持在线的可视化工具图表展示。
官方网站:https://gceasy.io/
使用方法:
1. 选择文件,找到 GC 日志并上传
2. 点击 Analyze 分析就可以看到报告,每个账号每个月能免费上传 5 个 GC 日志
2.2 常见的 GC 模式
根据内存的趋势图,我们可以将 GC 的情况分成几种模式
2.2.1 正常情况
特点:呈现锯齿状,对象创建之后内存上升,一旦发生垃圾回收之后下降到底部,并且每次下降之后的内存大小接近,存留的对象较少。
2.2.2 缓存对象过多
特点:呈现锯齿状,对象创建之后内存上升,一旦发生垃圾回收之后下降到底部,并且每次下降之后的内存大小接近,处于比较高的位置。
问题产生原因:程序中保存了大量的缓存对象,导致 GC 之后无法释放,可以使用 MAT 或者 HeapHero 等工具进行分析内存占用的原因。
2.2.3 内存泄漏
特点:呈现锯齿状,每次垃圾回收之后下降到的内存位置越来越高,最后由于垃圾回收无法释放空间导致对象无法分配产生 OutOfMemory 的错误。
问题产生原因:程序中保存了大量的内存泄漏对象,导致 GC 之后无法释放,可以使用 MAT 或者 HeapHero 等工具进行分析是哪些对象产生了内存泄漏。
2.2.4 持续的 FullGC
特点:在某个时间点产生多次 Full GC,CPU 使用率同时飙高,用户请求基本无法处理。一段时间之后恢复正常。
问题产生原因:在该时间范围请求量激增,程序开始生成更多对象,同时垃圾收集无法跟上对象创建速率,导致持续地在进行 FULL GC。
2.2.5 元空间不足导致的 FULLGC
特点:堆内存的大小并不是特别大,但是持续发生 FULLGC。
问题产生原因:元空间大小不足,导致持续 FULLGC 回收元空间的数据。GC 分析报告
元空间并不是满了才触发 FULLGC,而是 JVM 自动会计算一个阈值,如下图中元空间并没有满,但是频繁产生了 FULLGC。
2.3 解决 GC 问题的手段
解决 GC 问题的手段中,前三种是比较推荐的手段,第四种仅在前三种无法解决时选用:
-
优化基础 JVM 参数,基础 JVM 参数的设置不当,会导致频繁 FULLGC 的产生
-
减少对象产生,大多数场景下的 FULLGC 是由于对象产生速度过快导致的,减少对象产生可以有效的缓解 FULLGC 的发生
-
更换垃圾回收器,选择适合当前业务场景的垃圾回收器,减少延迟、提高吞吐量
-
优化垃圾回收器参数,优化垃圾回收器的参数,能在一定程度上提升 GC 效率
2.3.1 优化基础 JVM 参数
参数 1: -Xmx 和 –Xms
-Xmx 参数设置的是最大堆内存,但是由于程序是运行在服务器或者容器上,计算可用内存时,要将元空间、操作系统、其它软件占用的内存排除掉。
案例:服务器内存 4G,操作系统 + 元空间最大值 + 其它软件占用 1.5G,-Xmx 可以设置为 2g。
最合理的设置方式是根据最大并发量估算服务器的配置,再根据服务器配置计算最大堆内存的值
-Xms 用来设置初始堆大小,建议将 - Xms 设置的和 - Xmx 一样大,有以下几点好处:
1. 运行时性能更好,堆的扩容是需要向操作系统申请内存的,这样会导致程序性能短期下降。
2. 可用性问题,如果扩容时其他程序正在使用大量内存,很容易因为操作系统内存不足分配失败。
3. 启动速度更快,Oracle 官方文档的原话:如果初始堆太小,Java 应用程序启动会变得很慢,因为 JVM 被迫频繁执行垃圾收集,直到堆增长到更合理的大小。为了获得最佳启动性能,请将初始堆大小设置为与最大堆大小相同。
参数 2: -XX:MaxMetaspaceSize 和 –XX:MetaspaceSize
-XX:MaxMetaspaceSize=值
:参数指的是最大元空间大小,默认值比较大,如果出现元空间内存泄漏会让操作系统可用内存不可控,建议根据测试情况设置最大值,一般设置为 256m。
-XX:MetaspaceSize=值
:参数指的是到达这个值之后会触发 FULLGC(网上很多文章的初始元空间大小是错误的),后续什么时候再触发 JVM 会自行计算。如果设置为和 MaxMetaspaceSize 一样大,就不会 FULLGC,但是对象也无法回收。
参数 3: -Xss 虚拟机栈大小
如果我们不指定栈的大小,JVM 将创建一个具有默认大小的栈。大小取决于操作系统和计算机的体系结构。
比如 Linux x86 64 位:1MB,如果不需要用到这么大的栈内存,完全可以将此值调小节省内存空间,合理值为 256k – 1m 之间。(推荐:-Xss256k
)
参数 4: 不建议手动设置的参数
由于 JVM 底层设计极为复杂,一个参数的调整也许让某个接口得益,但同样有可能影响其他更多接口。
-Xmn
:年轻代的大小,默认值为整个堆的 1/3,可以根据峰值流量计算最大的年轻代大小,尽量让对象只存放在年轻代,不进入老年代。但是实际的场景中,接口的响应时间、创建对象的大小、程序内部还会有一些定时任务等不确定因素都会导致这个值的大小并不能仅凭计算得出,如果设置该值要进行大量的测试。G1 垃圾回收器尽量不要设置该值,G1 会动态调整年轻代的大小。
-XX:SurvivorRatio
:伊甸园区和幸存者区的大小比例,默认值为 8。
-XX:MaxTenuringThreshold
:最大晋升阈值,年龄大于此值之后,会进入老年代。另外 JVM 有动态年龄判断机制:将年龄从小到大的对象占据的空间加起来,如果大于 survivor 区域的 50%,然后把等于或大于该年龄的对象,放入到老年代。
比如下图中,年龄 1 + 年龄 2 + 年龄 3=55m 已经超过了 S 区的 50%,所以会将年龄 3 及以上的对象全部放入老年代。
其他参数
-XX:+DisableExplicitGC
:禁止在代码中使用 System.gc (),System.gc () 可能会引起 FULLGC,在代码中尽量不要使用。使用 DisableExplicitGC 参数可以禁止使用 System.gc () 方法调用。
-XX:+HeapDumpOnOutOfMemoryError
:发生OOM错误时,自动生成 hprof 内存快照文件。
-XX:HeapDumpPath=<path>
:指定 hprof 文件的输出路径。
打印 GC 日志
JDK8 及之前:-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:文件路径
JDK9 及之后:-Xlog:gc*:file=文件路径
JVM 参数模板
-Xms1g-Xmx1g-Xss256k-XX:MaxMetaspaceSize=512m -XX:+DisableExplicitGC-XX:+HeapDumpOnOutOfMemoryError-XX:HeapDumpPath=/opt/logs/my-service.hprof-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:文件路径
注意:
1. JDK9 及之后 gc 日志输出修改为 -Xlog:gc*:file=文件名
2. 堆内存大小和栈内存大小根据实际情况灵活调整。
2.3.2 垃圾回收器的选择
背景:
小李负责的程序在高峰期遇到了性能瓶颈,团队从业务代码入手优化了多次也取得了不错的效果,这次他希望能采用更合理的垃圾回收器优化性能。
思路:
编写 Jmeter 脚本对程序进行压测,同时添加 RT 响应时间、每秒钟的事务数等指标进行监控。
选择不同的垃圾回收器进行测试,并发量分别设置 50、100、200,观察数据的变化情况。
1. JDK8 下 ParNew + CMS 组合
2. 默认组合 : PS + PO
3. g1 : -XX:+UseG1GC
垃圾回收器 | 参数 | 50并发(最大响应时间) | 100并发(最大响应时间) | 200并发(最大响应时间) |
PS+PO | 默认 | 260ms | 474ms | 930ms |
CMS | -XX:+UseParNewGC -XX:+UseConcMarkSweepGC | 157ms | 未测试 | 833ms |
G1 | JDK11默认 | 未测试 | 未测试 | 248ms |
由此可见使用了JDK11之后使用G1垃圾回收器,性能优化结果还是非常明显的。
2.3.3 优化垃圾回收器的参数
这部分优化效果未必出色,仅当前边的一些手动无效时才考虑
案例:
CMS 的并发模式失败现象。由于 CMS 的垃圾清理线程和用户线程是并行进行的,如果在并发清理的过程中老年代的空间不足以容纳放入老年代的对象,会产生并发模式失败。
老年代已经满了此时有一些对象要晋升到老年代:
解决方案:
1. 减少对象的产生以及对象的晋升。
2. 增加堆内存大小
3. 优化垃圾回收器的参数,比如-XX:CMSInitiatingOccupancyFraction=值
,当老年代大小到达该阈值时,会自动进行 CMS 垃圾回收,通过控制这个参数提前进行老年代的垃圾回收,减少其大小。
JDK8 中默认这个参数值为 - 1,根据其他几个参数计算出阈值:
((100 - MinHeapFreeRatio) + (double)(CMSTriggerRatio * MinHeapFreeRatio) / 100.0)
本机结果是 92,该参数设置完不会生效,必须开启-XX:+UseCMSInitiatingOccupancyOnly
参数。
-XX:CMSInitiatingOccupancyFraction=20 XX:+UseCMSInitiatingOccupancyOnly 调整之后的效果展示
2.2.4 案例实战
背景:
小李负责的程序在高峰期经常会出现接口调用时间特别长的现象,他希望能优化程序的性能。
思路:
1. 生成 GC 报告,通过 Gceasy 工具进行分析,判断是否存在 GC 问题或者内存问题。
2. 存在内存问题,通过 jmap 或者 arthas 将堆内存快照保存下来。
3. 通过 MAT 或者在线的 heaphero 工具分析内存问题的原因。
4. 修复问题,并发布上线进行测试。
JVM 参数:
-Xms1g -Xmx1g -Xss256k -XX:MaxMetaspaceSize=256m -XX:+UseParNewGC -XX:+UseConcMarkSweepGC -XX:+PrintGCDateStamps -XX:+PrintGCDetails -XX:+DisableExplicitGC -Xloggc:D:/test.log
实战问题 1
发生了连续的 FULL GC,堆内存 1g 如果没有请求的情况下,内存大小在 200-300mb 之间。
分析:
没有请求的情况下,内存大小并没有处于很低的情况,满足缓存对象过多的情况,怀疑内存中缓存了很多数据。需要将堆内存快照保存下来进行分析。
解决步骤:
1. 在本地通过 visualvm 将 hprof 文件保存下来
2. 通过 HeapHero 分析文件,上传的是 hprof 文件
但是我们发现,生成的文件非常小,与接近200m大小不符:
Peak峰值接近Allocated,容易发生频繁的垃圾回收
吞吐量较好,但是最大暂停时间较长,不容乐观
由于是CMS回收器,设置了最大回收时间,CMS会将暂停时间控制在一定范围内,为了达到较短的最大暂停时间,就会频繁进行垃圾回收,导致回收次数大大增加
分析GC Duration,发现某段时间内,出现了不正常的较频繁的Full GC
分析堆内存,发现Full GC之后仍然留存了100多MB的空间未释放
3. 怀疑有些对象已经可以回收,所以没有下载下来。使用 jmap 调整下参数,将 live 参数去掉,这样即便是垃圾对象也能保存下来
4. 在 MAT 中分析,选择不可达对象直方图
5. 大量的对象都是字节数组对象
6. 那么这些对象是如何产生的呢?继续往下来,捕捉到有大量的线程对象,如果没有发现这个点,只能去查代码看看哪里创建了大量的字节数组了
实战问题 2
由于这些对象已经不在引用链上,无法通过支配树等手段分析创建的位置。
分析:
在不可达对象列表中,除了发现大量的 byte [] 还发现了大量的线程,可以考虑跟踪线程的栈信息来判断对象在哪里创建。
解决步骤:
1. 在 VisualVM 中使用采样功能,对内存采样
2. 观察到这个线程一直在发生变化,说明有线程频繁创建销毁
3. 选择线程功能,保存线程栈
4. 抓到了一个线程,线程后边的 ID 很大,说明已经创建过很多线程了
5. 通过栈信息找到源代码
问题产生原因:
在定时任务中通过线程创建了大量的对象,导致堆内存一直处于比较高的位置。
解决方案:
暂时先将这段代码注释掉,测试效果,由于这个服务本身的内存压力比较大,将这段定时任务移动到别的服务中。
实战问题 3
修复之后内存基本上处于 100m 左右,但是当请求发生时,依然有频繁 FULL GC 的发生。
分析:
请求产生的内存大小比当前最大堆内存大,尝试选择配置更高的服务器,将 - Xmx 和 - Xms 参数调大一些。
解决方案:
当前的堆内存大小无法支撑请求量,所以要不就将请求量降下来,比如限制 tomcat 线程数、限流,或者提升服务器配置,增大堆内存。
调整为4G之后的效果,FULLGC数量很少:
案例总结
1. 压力较大的服务中,尽量不要存放大量的缓存或者定时任务,会影响到服务的内存使用。
2. 内存分析发现有大量线程创建时,可以使用导出线程栈来查看线程的运行情况。
3. 如果请求确实创建了大量的内存超过了内存上限,只能考虑减少请求时创建的对象,或者使用更大的内存。
4. 推荐使用 g1 垃圾回收器,并且使用较新的 JDK 可以获得更好的性能。
使用G1回收器的效果
高吞吐量
Full GC 非常少
查看建议进行优化对比
大功告成!