JVM GC 调优实战——从频繁 Full GC 到丝滑运行的完整案例
JVM GC 调优实战——从频繁 Full GC 到丝滑运行的完整案例
适读人群:有 JVM 基础、遇到过 GC 问题或需要做性能调优的 Java 工程师 | 阅读时长:约20分钟 | 核心价值:掌握 GC 调优的系统方法论,通过真实案例理解 G1/ZGC 参数调整的思路
一、那次让接口 P99 从 800ms 降到 50ms 的 GC 调优
三年前我接手了一个老系统,核心接口的 P99 延迟是 800ms,P50 大约 80ms,SLA 要求 P99 必须在 200ms 以内。
领导给了两周时间优化。我先看了应用代码,逻辑本身不复杂,DB 查询都有索引,不像是代码问题。然后我看了 GC 日志,发现问题所在:
- Young GC 频率高,每隔 8~10 秒触发一次,每次 STW 80ms
- Full GC 每小时触发 3~5 次,每次 STW 1.2~1.8 秒
- 接口的 P99 高峰期正好是 Full GC 的 STW 时间
当时这个服务用的是 CMS 垃圾回收器,堆大小 -Xmx 8G,但老年代几乎常驻 6G 不释放。
用 MAT 分析堆快照,发现老年代里全是缓存数据,业务代码把大量查询结果放在内存 Map 里,而且这个 Map 没有做 LRU 淘汰,越积越多,撑满老年代,导致频繁 Full GC。
解决了缓存问题(改用 Caffeine,加了容量限制),然后换了 G1 GC,P99 从 800ms 降到了 50ms 以内。
这个案例我经常拿来讲,因为它很典型:GC 调优不是单纯改参数,而是"找问题 → 改代码 → 调参数"三步走,顺序不能乱。
二、GC 日志的正确开法
调优的基础是日志。没有 GC 日志,一切分析都是瞎猜。
Java 11+ 推荐的 GC 日志参数:
-Xlog:gc*:file=/logs/gc.log:time,uptime,level,tags:filecount=10,filesize=50mJava 8 的写法:
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-XX:+PrintGCCause
-XX:+PrintAdaptiveSizePolicy
-Xloggc:/logs/gc.log
-XX:+UseGCLogFileRotation
-XX:NumberOfGCLogFiles=10
-XX:GCLogFileSize=50m打开 GC 日志之后,用 GCEasy 或者 GCViewer 可视化分析,重点关注:
- Young GC 频率和 STW 时间
- Full GC 频率和 STW 时间
- 晋升失败(Promotion Failed)次数
- 并发模式失败(Concurrent Mode Failure)次数
三、G1 GC 核心参数详解
Java 9 之后 G1 成为默认 GC。对于大多数服务,我的建议是:先用 G1,不要上来就换 ZGC,G1 的调优空间很大,参数更容易理解。
# G1 GC 完整推荐启动参数(以 8G 堆为例)
-server
-Xms8g -Xmx8g # 初始堆和最大堆设相同,避免动态扩容开销
-Xss512k # 线程栈大小,默认 512k 通常够用
-XX:+UseG1GC # 使用 G1 GC
-XX:MaxGCPauseMillis=200 # 目标最大 STW 时间 200ms(G1 会动态调整 Region 数量)
-XX:G1HeapRegionSize=16m # Region 大小,堆 8G 时推荐 16m(2048 个 Region)
-XX:InitiatingHeapOccupancyPercent=45 # 老年代占用超 45% 时触发并发 GC
-XX:G1NewSizePercent=20 # 新生代最小占比 20%
-XX:G1MaxNewSizePercent=40 # 新生代最大占比 40%
-XX:ParallelGCThreads=8 # GC 并行线程数,一般等于 CPU 核数
-XX:ConcGCThreads=4 # 并发标记线程数,一般是 ParallelGCThreads 的 1/2
-XX:+G1UseAdaptiveIHOP # 自适应调整 IHOP,Java 9+ 推荐开启理解 MaxGCPauseMillis:这是 G1 的目标停顿时间,G1 会通过动态调整回收的 Region 数量来尽量满足这个目标,但不是硬性保证。设置太小会导致 GC 效率低(每次只回收少量 Region),设置太大会导致停顿时间长。一般从 200ms 开始,根据实测调整。
四、一次 Full GC 根因排查的完整过程
我把真实排查过程的关键步骤写出来,帮你建立排查思路。
第一步:确认 Full GC 的触发原因
Full GC 触发原因主要有几类,从 GC 日志里可以看 GCCause:
| 触发原因 | 说明 |
|---|---|
| Metadata GC Threshold | Metaspace 不足,需扩容 |
| Promotion Failure | 晋升失败,老年代没有足够的连续空间 |
| Concurrent Mode Failure | CMS/G1 并发 GC 追不上分配速度,触发 STW Full GC |
| System.gc() | 代码或第三方库显式调用了 System.gc() |
| Ergonomics | JVM 自适应调整触发 |
第二步:分析对象晋升速度
如果 Full GC 原因是 Promotion Failure,说明老年代空间紧张,需要分析是对象晋升太快还是老年代有大量长生命周期对象(内存泄漏)。
使用 jstat -gcnew <pid> 1000 观察 Young GC 前后的 Eden 和 Survivor 变化:
jstat -gcnew 12345 1000 20输出里看 TT(晋升阈值)和 MTT(最大晋升阈值),如果 TT 很小(比如 1 或 2),说明对象熬过很少次 Young GC 就晋升了,可能是新生代太小。
第三步:分析老年代对象存活情况
这一步需要 Heap Dump。触发方式:
# 方式1:jmap
jmap -dump:format=b,file=/tmp/heap.hprof <pid>
# 方式2:JVM 参数,OOM 时自动 dump
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/tmp/heap-oom.hprof
# 方式3:Arthas(不停机)
heapdump /tmp/heap-arthas.hprof用 Eclipse MAT 分析 dump 文件,重点关注 Retained Heap 最大的对象,确认是否是预期的长生命周期对象还是泄漏。
五、ZGC 适用场景与切换时机
我会选 ZGC 的条件:
- 堆内存 >= 16G(ZGC 的并发标记压缩开销在大堆上优势明显)
- 业务对延迟极其敏感,要求 P99 < 10ms
- Java 版本是 15+ (ZGC 在 Java 15 之后才正式 GA,15 之前是实验性的)
ZGC 的核心特点是 STW 时间基本控制在 1ms 以内(不随堆大小增长),但代价是更高的 CPU 和内存开销(并发处理需要更多资源)。
# ZGC 启动参数(Java 15+)
-XX:+UseZGC
-Xms16g -Xmx16g
-XX:ConcGCThreads=4 # 并发 GC 线程数
-XX:ZCollectionInterval=5 # 主动 GC 间隔(秒),防止长时间不 GC 导致内存碎片我的判断是:对于大部分业务服务,QPS < 5000、P99 要求 100ms~200ms,G1 完全足够。上 ZGC 是锦上添花,不是救命药;如果 GC 问题根因是代码里的内存泄漏或者对象分配过快,换 ZGC 也治不了根本。
六、踩坑实录
坑1:-Xms 和 -Xmx 设置不一致导致频繁 Full GC
现象:服务刚启动时 Full GC 频繁,运行一段时间后稳定下来。
原因:-Xms 设置了 1G,-Xmx 设置了 8G,JVM 初始堆只有 1G,业务请求一来,堆迅速填满,GC 后堆扩容,这个过程中频繁触发 Full GC。
解法:生产环境把 -Xms 和 -Xmx 设成一样,避免堆动态扩缩的开销。这是基础配置,但我见过很多项目忘记这一点。
坑2:System.gc() 被第三方库触发
现象:GC 日志里看到 System.gc() 触发的 Full GC,频率和业务量无关,每隔几分钟一次。
原因:某个第三方库(具体是 RMI 的心跳机制)内部调用了 System.gc(),导致周期性 Full GC。
解法:加 JVM 参数 -XX:+DisableExplicitGC 禁止显式 GC 调用。注意:这个参数会让直接内存的回收依赖 GC 日志触发,如果用了大量直接内存要谨慎使用,建议改为 -XX:+ExplicitGCInvokesConcurrent(让显式 GC 走并发模式,不 STW)。
坑3:G1 的 Humongous 对象频繁触发 GC
现象:G1 日志里频繁看到 Pause Full (G1 Humongous Allocation),老年代使用率异常高。
原因:业务代码有大对象分配,超过了 G1 Region 大小的 50%,被 G1 判定为 Humongous 对象,直接分配到老年代的多个连续 Region,不经过新生代,当老年代 Humongous 区域不够时触发 Full GC。
解法:一是排查大对象来源,看是否能拆分(比如分批查询代替一次性全量查询);二是适当增大 -XX:G1HeapRegionSize,让更多对象不被判定为 Humongous。
七、GC 调优总结
我的 GC 调优三步走:
第一步:开 GC 日志,建立基线 不开 GC 日志就调优,等于闭眼开车。至少跑一天日志,统计 Young GC 和 Full GC 的频率、STW 时间、晋升率。
第二步:找根因,优先改代码 80% 的 GC 问题根因在代码:内存泄漏、对象分配过快、大对象不必要的创建。改代码的效果远大于调参数。
第三步:调参数,做精细化优化 代码问题解决后,再通过调整 -XX:MaxGCPauseMillis、新生代比例、并发线程数等参数,把 GC 停顿时间控制在目标范围内。
