2023 年我们一个 Java 微服务突然 GC 频繁,Full GC 每分钟一次,业务 p99 延迟从 50ms 飙到 800ms。从 G1 切到 ZGC 后 GC 暂停时间从 200ms 降到 3ms,但花了两周调参才稳定。本文复盘 G1 调优 + ZGC 切换的全过程,讲透 JVM 各种 GC 算法对比、调参方法论、实战监控。
问题现象
服务:订单查询(Spring Boot 2.7 + JDK 11)
配置:Pod 8C 16G,Xmx 12g,G1 GC
突然出现:
- GC 频率:1 min/次 Full GC
- GC 暂停:200-500ms
- 业务 p99:从 50ms → 800ms
- CPU:50% → 90%(GC 线程占用)
排查日志:
2023-10-15T03:15:23.421+0800 Full GC (Allocation Failure)
[PSYoungGen: 4096M->0M(5120M)]
[ParOldGen: 7168M->6900M(7168M)] 12288M->6900M(12288M),
[Metaspace: 245M->245M(1024M)], 0.4823 secs
老年代占用 7168M(已满),触发 Full GC
JVM GC 算法对比
GC 算法 适用场景 暂停时间 吞吐量
==========================================================
Serial GC 单核小堆 长(秒级) 高
ParallelGC (PS+PO) 吞吐量优先(老 default) 中(100ms+) 最高
CMS(已废弃) 响应时间(JDK 8 流行) 短(50-200ms) 中
G1 GC(JDK 9+ default) 通用,大堆 中(<200ms) 高
ZGC(JDK 11+ 试验,15 GA) 超大堆,亚毫秒暂停 极短(<10ms) 中
Shenandoah(OpenJDK) 类似 ZGC,响应时间 极短(<10ms) 中
Epsilon GC 压测专用,不回收 无 最高(短时)
JDK 21 默认 G1,ZGC 已 GA 推荐生产用
G1 调优(第一阶段)
# 默认配置
JAVA_OPTS="-Xms12g -Xmx12g -XX:+UseG1GC"
# 排查 GC 日志开启
JAVA_OPTS="$JAVA_OPTS \
-Xlog:gc*:file=/var/log/jvm/gc.log:time,uptime,level,tags:filecount=10,filesize=100M \
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/var/log/jvm/heapdump.hprof \
-XX:+ExitOnOutOfMemoryError"
# G1 调参 - 控制暂停时间
JAVA_OPTS="$JAVA_OPTS \
-XX:MaxGCPauseMillis=100 \ # 目标最大暂停 100ms
-XX:G1HeapRegionSize=16m \ # region 大小
-XX:G1NewSizePercent=20 \ # 年轻代最小占比
-XX:G1MaxNewSizePercent=40 \ # 年轻代最大占比
-XX:InitiatingHeapOccupancyPercent=45 \ # 老年代 45% 启动 mixed GC
-XX:G1MixedGCCountTarget=8 \ # mixed GC 分 8 次完成
-XX:G1MixedGCLiveThresholdPercent=85 \ # 老年代 region 存活 < 85% 才回收
-XX:G1HeapWastePercent=5 \ # 浪费 5% 不再 mixed GC
-XX:G1OldCSetRegionThresholdPercent=10 # 单次 mixed 回收 10% region"
# 大对象阈值(>region/2)
JAVA_OPTS="$JAVA_OPTS \
-XX:G1HeapRegionSize=32m" # region 32M,大对象 > 16M 进 Humongous
# 并发线程
JAVA_OPTS="$JAVA_OPTS \
-XX:ParallelGCThreads=8 \ # STW 阶段并行线程
-XX:ConcGCThreads=2 \ # 并发标记线程"
看 GC 日志
# gc.log 关键指标
$ tail -f /var/log/jvm/gc.log
[2023-10-15T03:15:23.421+0800][info][gc] GC(1234) Pause Young (Normal) (G1 Evacuation Pause) 8192M->6500M(12288M) 45.123ms
[2023-10-15T03:16:01.234+0800][info][gc] GC(1235) Pause Remark 6500M->6500M(12288M) 12.456ms
[2023-10-15T03:16:30.567+0800][info][gc] GC(1236) Pause Cleanup 6500M->6500M(12288M) 5.789ms
[2023-10-15T03:17:00.123+0800][info][gc] GC(1237) Pause Mixed (G1 Evacuation Pause) 7800M->4500M(12288M) 89.234ms
# 用 gceasy.io 可视化
$ curl -F "file=@/var/log/jvm/gc.log" https://api.gceasy.io/analyzeGC -H "API-KEY: ${KEY}"
# 关键指标:
# - GC 频率(次/分钟)
# - 平均暂停(ms)
# - p99 暂停(ms)
# - 吞吐量(应用时间 / 总时间)
# - 老年代使用率趋势
# - Allocation Rate / Promotion Rate
G1 调优结果
指标 调优前 调优后(G1)
=============================================
Young GC 频率 5s/次 10s/次
Young GC 暂停 80ms 50ms
Mixed GC 频率 30s/次 120s/次
Mixed GC 暂停 300ms 80ms
Full GC 频率 1 min/次 0(无 Full GC)
业务 p99 800ms 120ms
仍不够好:
- 80ms 的 Mixed GC 影响 p99
- 大堆(12G)时 G1 暂停时间还是大
- 目标 p99 < 50ms 还差
决定试 ZGC
切换 ZGC
# JDK 17+ ZGC GA
$ java -version
openjdk version "17.0.9"
# 启用 ZGC
JAVA_OPTS="$JAVA_OPTS \
-XX:+UseZGC \ # 用 ZGC
-XX:+ZGenerational \ # JDK 21+ 分代 ZGC(强烈推荐)
-Xms12g -Xmx12g"
# ZGC 几乎不需要调参!只有这些可以调:
JAVA_OPTS="$JAVA_OPTS \
-XX:ConcGCThreads=4 \ # 并发 GC 线程,默认 1/8 CPU
-XX:ParallelGCThreads=8 \ # STW 并行
-XX:SoftMaxHeapSize=11g \ # 软上限(留 1G 给 buffer)
-XX:ZAllocationSpikeTolerance=2.0 \ # 分配尖峰容忍
-XX:+UseLargePages \ # 大页(2MB Linux hugepage)
-XX:+UseTransparentHugePages"
# Linux 大页配置
$ sysctl -w vm.nr_hugepages=8192
# 16G 堆需要 8192 个 2MB 大页 = 16GB
# 验证 GC 类型
$ jcmd VM.flags | grep -i gc
-XX:+UseZGC
-XX:+ZGenerational
ZGC 日志
# ZGC 日志格式
[2023-10-22T10:23:15.123+0800][info][gc] GC(12) Garbage Collection (Allocation Rate) 7800M(63%)->4500M(36%)
[2023-10-22T10:23:15.234+0800][info][gc,phases] GC(12) Pause Mark Start 0.456ms
[2023-10-22T10:23:15.567+0800][info][gc,phases] GC(12) Concurrent Mark 234.123ms
[2023-10-22T10:23:15.789+0800][info][gc,phases] GC(12) Pause Mark End 0.234ms
[2023-10-22T10:23:15.987+0800][info][gc,phases] GC(12) Pause Relocate Start 1.123ms
# 关键:Pause 阶段都 < 2ms,Concurrent 阶段不阻塞业务
# GC 总耗时:几百毫秒,但暂停只 < 10ms
# ZGC stats
$ jcmd GC.heap_info
garbage-first heap total 12582912K, used 4521376K [...]
ZGC: phase 0, ...
# 持续监控 GC pause
$ jstat -gc 1000
S0 S1 E O M P YGC YGCT FGC FGCT
0.0 0.0 0.0 0.0 ... ... 123 0.45 3 0.012
ZGC 切换的坑
坑 1:内存占用增加
G1 时 Xmx 12g,常驻内存 13G
切 ZGC 后常驻内存 18G
原因:ZGC 用染色指针,需要额外 metadata
ZGC 的 reservation factor 默认 2x(为整理预留空间)
修法:
1. SoftMaxHeapSize 限制软上限
2. Pod limits 调整(15G → 20G)
3. 大页可以减少 page table 开销
坑 2:Native memory leak
# 切到 ZGC 后,容器 RSS 持续涨
# 但 -Xmx 12g 没变,GC 也正常
# 看 NMT(Native Memory Tracking)
$ jcmd VM.native_memory summary
Total: reserved=21342MB, committed=20123MB
- Java Heap (reserved=12288MB, committed=12288MB)
- Class (reserved=1283MB, committed=312MB)
- Thread (reserved=523MB, committed=523MB)
- NMT (reserved=128MB, committed=128MB)
- Code (reserved=251MB, committed=128MB)
- GC (reserved=4523MB, committed=4523MB) ← 大
- Compiler (reserved=23MB, committed=23MB)
- Internal (reserved=523MB, committed=523MB)
- Symbol (reserved=85MB, committed=85MB)
- Native Memory Tracking (reserved=23MB, committed=23MB)
- Arena Chunk (reserved=12MB, committed=12MB)
# GC 区域 4.5GB 是正常的(ZGC 元数据)
# 不是 leak,是 ZGC 的代价
坑 3:JFR 不兼容
原 JFR(Java Flight Recorder)在 G1 上的 GC 事件,切 ZGC 后部分事件没了
解决:升级到 JDK 21+(JFR 事件完整覆盖 ZGC)
配置事件:
-XX:StartFlightRecording=settings=profile,filename=app.jfr,duration=300s
JVM 监控
# Micrometer + Prometheus
management:
endpoints:
web:
exposure:
include: prometheus, health, metrics
metrics:
tags:
application: ${spring.application.name}
distribution:
percentiles-histogram:
http.server.requests: true
percentiles:
http.server.requests: 0.5, 0.95, 0.99
# 关键 metrics
jvm_memory_used_bytes{area="heap"}
jvm_memory_committed_bytes{area="heap"}
jvm_memory_max_bytes{area="heap"}
jvm_gc_pause_seconds_count
jvm_gc_pause_seconds_sum
jvm_gc_pause_seconds_max
jvm_gc_concurrent_phase_time_seconds
jvm_threads_live_threads
jvm_threads_daemon_threads
# 告警规则
groups:
- name: jvm
rules:
- alert: JVMHeapHigh
expr: jvm_memory_used_bytes{area="heap"} / jvm_memory_max_bytes{area="heap"} > 0.85
for: 5m
labels: { severity: warning }
- alert: JVMGCPauseTooLong
expr: jvm_gc_pause_seconds_max > 0.5
for: 1m
labels: { severity: critical }
- alert: JVMFullGCFrequent
expr: rate(jvm_gc_pause_seconds_count{action="end of major GC"}[5m]) > 0.1
for: 5m
labels: { severity: critical }
- alert: JVMThreadsHigh
expr: jvm_threads_live_threads > 1000
for: 5m
labels: { severity: warning }
实战调优方法论
第 1 步:开 GC log + dump
JAVA_OPTS="-Xlog:gc*:file=gc.log -XX:+HeapDumpOnOutOfMemoryError"
第 2 步:看现象
- 频率:Young GC 多少次/秒?Full GC 有没有?
- 暂停:平均、p99
- 堆使用趋势:有没有泄漏(老年代持续涨)
- 吞吐量:应用时间占比
第 3 步:用工具分析
- GCeasy.io:gc.log 可视化
- VisualVM / JConsole:实时监控
- JProfiler / YourKit:深度分析
- MAT:heap dump 分析
第 4 步:针对症状调优
- Young GC 频繁 → 加大年轻代 -Xmn 或 -XX:G1NewSizePercent
- Full GC 多 → 老年代设置 / 大对象 / 内存泄漏
- 暂停长 → 切 ZGC 或 Shenandoah
- 吞吐量低 → 调 ParallelGCThreads / ConcGCThreads
第 5 步:压测验证
不要在生产乱调,先在压测环境验证
压测工具:JMeter / Gatling / wrk2
不同场景的 GC 选择
场景 推荐 GC
================================================
小堆 (< 4G) 通用 G1
大堆 (4-32G) 通用 G1 或 ZGC
超大堆 (> 32G) ZGC
低延迟要求 (p99 < 50ms) ZGC / Shenandoah
吞吐量优先(批处理) Parallel GC
JDK 21+ + 高并发 ZGC (Generational)
内存敏感(K8s 小 Pod) G1(ZGC 多占 20-30% 内存)
JDK 8(legacy) G1(放弃 CMS)
压测 / benchmark Epsilon(不 GC,只压性能上限)
切 ZGC 后效果
指标 G1 调优后 ZGC 变化
==========================================
GC 频率(总) 1/10s 1/30s -67%
GC 暂停 p50 50ms 1ms -98%
GC 暂停 p99 80ms 3ms -96%
Full GC 无 无 -
业务 p99 延迟 120ms 45ms -63%
业务 p999 延迟 500ms 80ms -84%
内存占用 13G 18G +38%
CPU(GC 部分) 8% 12% +50%
权衡:ZGC 用更多内存和 CPU 换更短的暂停
对延迟敏感的业务,这个权衡很划算
避坑清单
- 不要盲目选 GC,先看业务对延迟还是吞吐量的偏好
- GC 日志必须开,出问题时 gc.log 是第一手资料
- HeapDumpOnOutOfMemoryError 一定开,OOM 时有 dump 才能分析
- G1 大堆(> 16G)暂停时间不稳定,可以考虑 ZGC
- ZGC 多占 20-30% 内存,K8s Pod 内存 limits 要相应调大
- ZGC 几乎不用调参,简单几个就够
- 大对象(> region/2)避免,G1 有 Humongous 问题
- JDK 21 + 分代 ZGC 是当前最佳选择(2024)
- 压测验证,不要在生产乱调
- 监控 GC pause / heap usage / Full GC 频率,告警阈值要合理
总结
JVM GC 调优是 Java 服务运维的核心技能。G1 是通用最优解(JDK 9+ 默认),但堆超过 16G 后暂停时间难控制。ZGC 是新一代低延迟 GC,JDK 21 的分代 ZGC 进一步降低了内存开销,p99 暂停可以做到亚毫秒。这次从 G1 切 ZGC 让我们的 p99 延迟降了 63%,代价是 38% 内存增加,业务可以接受。最大的认知改变:GC 不是黑盒,看懂 gc.log + 用对工具,大部分问题都能定位。未来 Project Loom + ZGC 的组合会让 Java 在高并发场景重新有竞争力。
—— 别看了 · 2026