很多工程师拿到 JVM 参数就照着网上的模板抄,遇到 OOM 就看一眼堆栈猜原因。本文不再重复回收器的入门介绍,而是直击 G1 的 RSet/SATB 内部机制、ZGC 的着色指针与读屏障,然后以「GC 日志 + Heap Dump + 在线诊断」三条工具链还原四个生产级案例的完整决策过程,建立可复制的调优方法论。
前置基础可先阅读 JVM 性能调优与 GC 优化 与 JVM 内存模型与类加载机制。
1. G1 GC 内部机制深挖
1.1 RSet:跨区引用的记忆集
G1 把堆划分为大小相等的 Region,回收时以 Region 为单位。但一个 Region 中的对象可能被其他 Region 的对象引用——如果每次回收都全堆扫描,分区回收就失去意义。解决方案是 RSet(Remembered Set):
Region A (被回收) Region B(存活)
┌─────────────┐ ┌─────────────┐
│ obj1 │ │ obj2 ───┐ │
│ obj1 被 obj2 引用 │ │ │
└─────────────┘ └──────────┼──┘
▲ RSet 记录:B 区对象引用了 A 区对象 │
└───────────────────────────────────────────┘
回收 Region A 时,只扫描「RSet 中记录的跨区引用源」,
无需扫描整个堆。
| 概念 | 说明 |
|---|---|
| 分区粒度 | 默认 2048 个 Region,大小 1M~32M,-XX:G1HeapRegionSize 指定 |
| RSet 记录 | 记录「谁引用了本 Region 中的对象」的指针来源 Region |
| 卡表(Card Table) | 位图形式的脏卡标记,写屏障在对象引用写入时置脏 |
| RSet 代价 | 维护 RSet 本身有内存与 CPU 开销,大对象区(Humongous)不建 RSet |
关键调优点:RSet 膨胀会导致 GC 变慢。可监控 -Xlog:gc+remset 观察 RSet 处理耗时;若大量跨区引用来自缓存类对象,考虑调整对象布局或改用 -XX:G1HeapRegionSize 让对象尽量落在同一 Region。
1.2 SATB:快照标记算法
G1 并发标记采用 SATB(Snapshot At The Beginning),解决并发标记期间引用变化的一致性问题:
并发标记开始前,记录一个堆快照(逻辑上的,不是拷贝)
── 标记快照时刻仍可达的对象
── 并发标记期间,若一个「快照时存活对象」的引用被切断(变为不可达),
用写屏障记录到 SATB 队列,保证标记不会漏掉快照期存活对象
── 代价:快照后变「垃圾」的对象可能在本轮标记中幸存,留到下一轮
# SATB 相关日志与参数
-Xlog:gc+satb # SATB 队列处理日志
-XX:ConcGCThreads=4 # 并发标记线程数,默认 (ParallelGCThreads + 2) / 4
调优意义:-XX:InitiatingHeapOccupancyPercent(默认 45)决定老年代占用达到多少时触发并发标记。设置过大→标记启动晚→Mixed GC 回收不足→可能 Full GC;过小→标记频繁→CPU 浪费。需要结合 R 大小时监控调整。
1.3 停顿预测模型与自适应
G1 的核心承诺是「可控停顿」,靠的是停顿预测模型:
// G1 内部:根据历史 GC 停顿数据拟合一个「停顿时间 vs 回收量」模型
// 每次 Young/Mixed GC 前,按 MaxGCPauseMillis 目标估算本次回收哪些 Region
// 参数:
// -XX:MaxGCPauseMillis=200 目标停顿(默认 200ms)
// -XX:G1ConfidencePercent=50 置信度,越高越保守
// -XX:G1HeapWastePercent=5 堆浪费阈值,超过则停止 Mixed GC
常见误区:MaxGCPauseMillis 不是硬上限,只是模型的目标值。当堆占用逼近满、Mixed GC 跟不上对象增长时,停顿会超过目标甚至退化为 Full GC。调优不是把目标设小,而是通过调节 InitiatingHeapOccupancyPercent 与 G1MixedGCCountTarget 让 GC 节奏与分配速率匹配。
1.4 Evacuation Failure 与 Humongous
| 现象 | 根因 | 对策 |
|---|---|---|
| Evacuation Failure | 复制存活对象时目标 Region 空间不足,对象就地留在老年代 | 增大堆 / 调低 G1HeapWastePercent / 减少大对象 |
| Humongous 分配失败 | 对象超过 Region 一半直接分配连续 Humongous Region | -XX:G1HeapRegionSize 调大,减少跨 Region 的大对象 |
| 频繁 Full GC | 并发标记跟不上分配,老年代持续膨胀 | 调低 IHOP、加大 ConcGCThreads、检查内存泄漏 |
# 观察大对象分配
-Xlog:gc+humongous=info
# 大对象阈值
-XX:G1HeapRegionSize=4m # 超过 2m 的对象走 Humongous
2. ZGC 与 Shenandoah 深入
2.1 ZGC 着色指针(Colored Pointers)
ZGC 把 GC 元数据直接编码进 64 位指针本身,实现「并发转移对象而引用无需更新」:
ZGC 指针布局(64 位,4TB 堆以内):
┌──────────┬──────┬──────┬──────┬──────┬───────────────────────┐
│ 47位对象地址 │ Remapped│ Marked0│ Marked1│ Finalizable │ 元数据占用 │
└──────────┴──────┴──────┴──────┴──────┴───────────────────────┘
未使用 │ │ │ │
Remapped │ │ │ └─ 终结器处理标记
Marked0/1 │ │ └── 双重标记位(并发标记用)
└────────── 三态:0/1 切换,避免标记位翻转
2.2 读屏障与并发转移
访问对象时:加载指针 → 读屏障检查指针上的颜色元数据
├─ 状态正常(Remapped/Marked)→ 直接使用
└─ 状态异常(需要修正)→ 读屏障执行修正操作:
├─ Mark:标记为已访问
└─ Remap:把指向旧地址的引用修正到新地址(转发指针)
└─ 修正后的指针写回(Membar 保证原子性)
好处:
├─ 转移对象时无需像 CMS 那样全堆扫描更新引用(无 Stop The World 重映射)
└─ 停顿时间与堆大小无关(亚毫秒级)
2.3 Generational ZGC(JDK 21+)
JDK 21 引入分代 ZGC,把 ZGC 分为年轻代/老年代两套集合,解决「全堆 ZGC 每次回收都要扫描全部存活对象」在长生命周期应用中的 CPU 浪费:
| 维度 | 单代 ZGC | 分代 ZGC(JDK 21) |
|---|---|---|
| 回收范围 | 每次全堆 | 分年轻代 GC / 老年代 GC |
| 平均停顿 | 亚毫秒 | 年轻代更低 |
| CPU 开销 | 全堆扫描 | 小对象优先年轻代,CPU 显著下降 |
| 启用 | 默认(JDK 15+ 单代) | -XX:+ZGenerational(JDK 21,后续版本默认) |
2.4 ZGC 关键参数
# 启用 ZGC
-XX:+UseZGC
# 并发线程数(默认 CPU 核数的 1/4 左右)
-XX:ConcGCThreads=8
# 目标最长停顿(ZGC 通常 <1ms,此参数意义不大但可设)
-XX:MaxGCPauseMillis=5
# JDK 21 分代 ZGC
-XX:+ZGenerational
# 关键监控
-Xlog:gc*:file=zgc.log:time,uptime:filecount=5,filesize=50m
2.5 Shenandoah 对比
| 维度 | ZGC | Shenandoah |
|---|---|---|
| 内存模型 | 着色指针(堆地址映射) | Brooks 转发指针(对象头加字段) |
| 读屏障 | 有 | 有(JDK 13+ 可选) |
| 大堆适配 | 优秀(10MB~16TB) | 优秀 |
| 社区 | Oracle | Red Hat / OpenJDK |
| 停顿 | 亚毫秒 | 亚毫秒(并发压缩) |
| 适用 | 低延迟优先 | 低延迟 + 内存占用均衡 |
3. GC 日志与 Heap Dump 分析
3.1 统一日志:-Xlog(JDK 9+)
JDK 9 后统一日志框架取代了 -XX:+PrintGCDetails 等老参数:
# 常用组合
-Xlog:gc*:file=gc.log:time,uptime,level,tags:filecount=10,filesize=100m
# 拆分成不同文件
-Xlog:gc+heap=debug:file=heap.log:time,uptime
-Xlog:gc+remset=debug:file=remset.log:time,uptime
-Xlog:safepoint=info:file=safepoint.log:time,uptime
# 仅打印到控制台
-Xlog:gc
3.2 GC 日志解析
一段典型 G1 日志:
[0.321s][info][gc,start] GC(0) Pause Young (Normal) (G1 Evacuation Pause)
[0.321s][info][gc] GC(0) Eden regions: 512->0(512)
[0.321s][info][gc] GC(0) Survivor regions: 16->16(32)
[0.321s][info][gc] GC(0) Old regions: 256->260(1024)
[0.326s][info][gc] GC(0) Humongous regions: 0->0
[0.326s][info][gc,stats] GC(0) Metaspace: 34M used, 34M capacity
[0.326s][info][gc] GC(0) Pause Young (Normal) 256M->240M(2048M) 5.1ms
| 字段 | 含义 | 关注点 |
|---|---|---|
GC(0) | 第 0 次 GC | 连续编号 |
Eden regions: 512->0(512) | Eden Region 变化(回收前→回收后(总容量)) | 每次填满 512 个 Region 即触发 |
Survivor regions: 16->16(32) | Survivor 容量变化 | 晋升比例 |
Old regions: 256->260(1024) | 老年代增长 | 老年代持续增长=泄漏信号 |
256M->240M(2048M) | 堆使用量变化 | 回收效果 |
5.1ms | 本次停顿 | 与目标对比 |
解读要点:如果每次 GC 后堆使用量几乎不降(回收不足),或 Eden 越滚越大才触发,说明分配速率高于回收速率,需要增大堆或优化对象生命周期。
3.3 jcmd:在线诊断
# 查看当前 JVM 的所有诊断命令
jcmd <pid> help
# 打印堆配置与使用情况
jcmd <pid> GC.heap_info
# 触发一次 GC(配合 -verbose:gc 观察)
jcmd <pid> GC.run
# 导出 Heap Dump(生产环境建议 jmap 之外优先 jcmd)
jcmd <pid> GC.heap_dump /tmp/heap.hprof
# 查看类直方图(对象数量与占用 Top)
jcmd <pid> GC.class_histogram | head -40
# 查看 JVM 参数生效值
jcmd <pid> VM.flags
3.4 Heap Dump 生成
# 方式一:OOM 时自动导出(强烈推荐线上开启)
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/log/heapdump.hprof
# 方式二:手动导出
jmap -dump:live,format=b,file=/tmp/heap.hprof <pid> # live 会先触发 Full GC,谨慎
jcmd <pid> GC.heap_dump /tmp/heap.hprof # 推荐,不开新进程
# 方式三:Arthas
# arthas 中执行:heapdump /tmp/heap.hprof
生产注意:jmap -dump:live 会触发一次 Full GC,可能在高峰期造成大停顿;优先用 jcmd 或直接在 OOM 参数上配置自动导出。
3.5 MAT 分析流程
Eclipse MAT 是最主流的 Heap Dump 分析工具:
导入 .hprof → 生成 Dominator Tree(支配树)
→ Leak Suspects Report(泄漏嫌疑报告)
→ 查看支配树 Top 对象:
├─ 关注 Retained Heap(保留堆)大的对象
├─ 顺着 GC Roots 路径分析引用链
└─ 检查集合类(Map/List)是否无限增长
→ OQL 查询(类级聚合)
-- OQL:统计某个类的实例数与占用
SELECT toString(c), count(*) FROM INSTANCEOF com.example.dto.Order c
-- OQL:找出超过 10MB 的对象
SELECT * FROM OBJECTS o WHERE o.@retainedHeapSize > 10485760
4. 生产调优案例
4.1 案例一:高并发网关低延迟
背景:核心网关 8C16G,要求 TP99 接口时延 < 50ms,但 GC 停顿平均 120ms。
诊断:
- GC 日志显示 Full GC 频繁,每次 800ms+;
jcmd GC.class_histogram发现byte[]占堆 60%,来自请求体缓冲;- 老年代持续增长至 12G 触发 Full GC,说明存在大对象长期滞留。
决策:
# 改用 ZGC(亚毫秒停顿),堆 12G 对 ZGC 无压力
-Xmx12g -XX:+UseZGC -XX:ConcGCThreads=6
# 同时根治大对象:请求体缓冲改用堆外 DirectBuffer + 对象池
结果:GC 停顿从 120ms → 0.8ms,TP99 从 68ms → 31ms。
经验:低延迟场景先评估回收器切换(G1→ZGC),再定位大对象根因。
4.2 案例二:大堆批处理吞吐
背景:离线批处理 32G 堆,追求吞吐,Full GC 每天 5 次每次 5s,吞吐损失明显。
诊断:对象存活率低、批处理阶段性创建大量临时对象;G1 的 Mixed GC 节奏跟不上。
决策:
# 吞吐优先:Parallel GC(暂停长但总吞吐高)
-Xmx32g -XX:+UseParallelGC
-XX:ParallelGCThreads=16
# 或保留 G1 但扩大 GC 节奏容忍度
-XX:+UseG1GC
-XX:MaxGCPauseMillis=1000 # 批处理容忍长停顿,降低 GC 频率
-XX:InitiatingHeapOccupancyPercent=60 # 晚一点触发并发标记
结果:Parallel GC 下总 GC 时间占比从 6% 降至 2.8%,批处理吞吐提升约 22%。
经验:吞吐场景把停顿目标放宽,减少 GC 次数比降低单次停顿更重要。
4.3 案例三:OOM 定位实战
背景:应用夜间 OOM 崩溃,HeapDumpOnOutOfMemoryError 已开启,产出 6G hprof。
定位:
- MAT Leak Suspects 报告指向
ConcurrentHashMap,占用 4.2G; - 支配树显示 key 为
String(时间戳前缀),value 为缓存对象; - OQL 统计该 Map 元素数量 → 从启动到崩溃持续增长,无清理;
- 定位代码:缓存 Map 只有 put 没有淘汰策略。
修复:改用 Caffeine(基于 W-TinyLFU 的本地缓存,支持容量上限与过期),OOM 消除。详见 Java 缓存策略与 Redis 集成。
方法论:OOM 定位顺序——先看 Heap Dump 找大头 → 判断增长型集合 → 反查代码路径 → 用正确数据结构替代。
4.4 案例四:ZGC 在 20G 堆上的调参
背景:支付系统 20G 堆启用 ZGC 后,GC CPU 占用偏高(约 25%),时延达标但成本高。
诊断:-Xlog:gc+stats 显示并发标记与转移线程繁忙,且大量小对象存活率低。
决策:
-XX:+UseZGC
-XX:ConcGCThreads=12 # 从默认 8 上调,加快并发回收,减少堆积
-XX:SoftMaxHeapSize=18g # 让 ZGC 尽量在 18G 内完成回收,留出余量
# JDK 21 环境切分代
-XX:+ZGenerational # 年轻代快速回收,整体 GC CPU 明显下降
结果:GC CPU 占用从 25% 降至 9%,平均停顿仍 < 1ms。
5. 低延迟与吞吐权衡
5.1 决策框架
业务特征分析:
├─ 停顿敏感(支付、实时推荐、网关)→ ZGC / Shenandoah
├─ 吞吐优先(批处理、ETL、离线分析)→ Parallel / 放宽 G1 停顿目标
├─ 中规中矩(常规 Web/API)→ G1 默认参数起步,按日志迭代
└─ 内存受限(容器 512M~2G)→ 控制堆 + Serial / 小堆 G1
调优路径(永远先监控、后调参):
1. 收集基线:GC 日志 + 堆使用 + 时延/吞吐指标
2. 找出瓶颈:停顿过高 / 频率过高 / Full GC / CPU 高
3. 最小变更:一次只改一个参数,对比验证
4. 回归验证:压测 + 灰度观察,确认无副作用
5.2 通用参数基线(G1 起点)
# 常规 Web 服务起步基线
-Xms4g -Xmx4g # 堆大小固定,避免扩容抖动
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/log/heapdump.hprof
-Xlog:gc*:file=/var/log/gc.log:time,uptime,level,tags:filecount=10,filesize=100m
# 内存不足再考虑:
-XX:MaxMetaspaceSize=512m
-XX:MaxDirectMemorySize=512m
5.3 关键监控指标
| 指标 | 获取方式 | 警戒线 |
|---|---|---|
| GC 停顿 P99 | GC 日志 / Grafana | 超过目标 1.5 倍 |
| Full GC 次数 | GC 日志 | 单小时 > 0 次需关注 |
| 老年代增速 | jstat -gcutil | 稳定上升=泄漏 |
| GC CPU 占比 | jcmd VM.native_memory / top | > 15% 需要优化 |
| Metaspace | GC 日志 | 持续增长=类加载泄漏 |
6. 总结
| 主题 | 核心要点 |
|---|---|
| G1 机制 | RSet 解决跨区引用、SATB 保证并发标记一致、停顿预测模型控制节奏 |
| ZGC | 着色指针 + 读屏障实现并发转移,停顿与堆大小无关 |
| 工具链 | -Xlog 统一日志、jcmd 在线诊断、MAT 主导树分析 |
| 案例 | 低延迟切 ZGC、吞吐放宽停顿、OOM 找增长集合、ZGC 调并发线程 |
| 权衡 | 先监控后调参、一次一参数、回归验证 |
GC 调优不是玄学,而是「机制理解 + 数据驱动 + 最小变更」的工程方法。掌握回收器内部机制,你就能读懂每一条 GC 日志背后的因果;熟练使用诊断工具链,你就能在任何生产事故面前快速锁定根因。
延伸阅读
- JVM 性能调优与 GC 优化 — 回收器选型与参数速查
- JVM 内存模型与类加载机制 — 内存区域与对象布局基础
- 监控诊断与可观测性 — Arthas 在线诊断
- Java 性能测试与压测工具链 — JMH 微基准与压测基线
- Java 容器化最佳实践与 Kubernetes 部署 — 容器内存与 JVM 堆的适配
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。