跳过导航

一次 Full GC 排查实战:从告警、GC 日志到堆转储引用链

约 7 分钟...次浏览
专栏JVM 与 Java 核心第 8 篇

线上出现 Full GC 时,最危险的动作是立即调大堆或直接重启。重启会抹掉现场,扩堆可能只是把事故推迟。可靠排查要回答三个问题:为什么触发、回收了多少、回收后基线是否持续上升。只有建立时间线和对象引用证据,才能区分内存泄漏、堆容量不足、元空间问题和瞬时分配风暴。

1. 案例现象

某 Java API 服务在流量无明显增长时出现:

  • p99 从 200 ms 升到 8 s;
  • 10 分钟内发生多次长暂停;
  • 老年代占用呈阶梯式上涨;
  • Full GC 后占用从 85% 仅降到 78%;
  • 重启后暂时恢复,数小时后复现。

“GC 后基线持续升高”比单次 Full GC 更值得警惕,它说明越来越多对象仍被强引用。若每次 GC 后都降回稳定低位,则更可能是突发分配或堆容量/GC 参数不匹配。

2. 先保现场,再做变更

在机器仍可响应且操作风险允许时,记录:

jcmd <pid> VM.command_line
jcmd <pid> VM.flags
jcmd <pid> GC.heap_info
jcmd <pid> VM.native_memory summary
jcmd <pid> Thread.print -l
jcmd <pid> GC.class_histogram

线程转储建议间隔数秒采集多份,避免单点误判。VM.native_memory 需要进程启动时启用 Native Memory Tracking 才有完整数据。

堆转储可使用:

jcmd <pid> GC.heap_dump /safe-path/heap.hprof

但 dump 需要安全点,可能造成长暂停,并产生与堆相近的大文件和额外磁盘压力;是否先做完整 GC、是否包含不可达对象取决于目标 JDK 的命令选项。执行前先查看 jcmd <pid> help GC.heap_dump,不要凭其他版本的命令经验推断。生产执行还要确认空间、权限和服务容灾;若实例已濒临失效,优先摘流或在副本上操作。不要把 hprof 上传到不可信平台,其中可能含用户和密钥数据。

3. 开启可分析的 GC 日志

JDK 17/21 常见统一日志配置:

-Xlog:gc*,safepoint:file=/logs/gc-%t.log:time,uptime,level,tags:filecount=10,filesize=100M

不同收集器关注字段不同,但分析框架一致:

  • 触发原因:Allocation Failure、Metadata GC Threshold、Humongous Allocation 等;
  • GC 前后各区域占用;
  • 暂停时间与并发阶段耗时;
  • 回收效率;
  • 是否发生 evacuation failure、promotion failure 或 concurrent mode failure 一类异常信号;
  • Safepoint 总耗时与“到达安全点”耗时。

不要只搜索日志中的 Full GC 字符串。现代收集器的周期与退化行为表述随版本不同,应结合该 JDK 与收集器文档解读。

4. 建立时间线

把以下指标按同一时区对齐:

请求量/错误率/延迟

对象分配速率、堆各区域、晋升速率

GC 事件与暂停

CPU、磁盘、容器内存、线程数

发布、配置变更、定时任务和缓存刷新

如果 Full GC 前恰好运行一个全量导出任务,分配率激增但 GC 后恢复低位,重点应查批处理峰值。若流量平稳、GC 后基线逐步上涨,则重点查长期引用。

5. 先用 histogram 缩小范围

两份间隔采集的类直方图比单份更有价值,但 GC.class_histogram 属于有影响的诊断命令,某些选项或 JDK 版本会为了只统计存活对象触发完整收集或较长安全点。先查看 jcmd <pid> help GC.class_histogram 并评估影响;高负载生产实例可先用 JFR 分配事件或低风险采样缩小范围:

jcmd <pid> GC.class_histogram > histo-1.txt
# 在评估影响后,间隔一段时间再次采集
jcmd <pid> GC.class_histogram > histo-2.txt

关注实例数和字节数持续增长的类型,例如:

  • byte[]:缓存、网络缓冲、压缩内容、序列化数据;
  • char[] / String:日志内容、SQL、重复 key;
  • HashMap$Node:无界 Map;
  • 业务 DTO:队列积压或会话未释放;
  • 类加载相关对象:动态生成类或类加载器泄漏。

直方图只能说明“谁多”,不能说明“谁在引用”。最终仍要沿 GC Root 分析。

6. 用 MAT 找到保留者

在 Eclipse MAT 中可按以下顺序:

  1. 查看 Leak Suspects Report;
  2. 按 retained heap 排序 Dominator Tree;
  3. 对异常大对象执行 Path to GC Roots;
  4. 排除弱引用、软引用等不关键路径后观察强引用链;
  5. 对比同类实例的字段,识别共同 owner。

案例中最终看到:

GC Root: application thread
  -> ReportScheduler
  -> completedTasks (ConcurrentHashMap)
  -> taskId
  -> ReportResult
  -> byte[]

业务为了支持结果下载,把每次报表生成结果放入 completedTasks,却没有 TTL、容量上限或成功下载后的删除逻辑。Map 本身只占少量内存,但它支配的大量 ReportResult -> byte[] 占据数 GB;这正是 retained size 比 shallow size 更重要的原因。

7. 修复与验证

修复不能只是“定时 clear”:

  • 结果写入对象存储,内存只保留短期元数据;
  • 缓存设置最大容量、TTL 和淘汰指标;
  • 下载完成后按业务语义删除;
  • 为单个结果设置大小限制;
  • 对任务创建速率和未消费结果数告警;
  • 压测覆盖“持续生成但不下载”的最坏场景。

上线后验证:老年代 GC 后基线不再上升,缓存条目数受控,allocation rate 和晋升速率符合预期,p99 暂停回归目标范围。不要只验证“没有 OOM”,还要验证容量边界与降级行为。

8. Full GC 不一定是 Java 堆泄漏

常见分支还包括:

  • Metaspace:动态代理、脚本、热部署造成类加载器无法卸载;
  • DirectByteBuffer:堆外缓冲使用增长;
  • 本地内存:线程栈、JNI、本地库分配;
  • 显式 GC:代码或库调用 System.gc()
  • 堆太小:存活集合理,但可用余量不足;
  • 巨型对象:大数组或大报文影响特定收集器分区;
  • 分配风暴:短时间产生大量对象,GC 跟不上;
  • 容器限制:JVM 配置与 cgroup 内存边界不匹配。

如果进程 RSS 持续增长而 Java 堆稳定,继续盯着 hprof 很可能找不到答案,应转向 NMT、线程数量、direct buffer 与本地库。

9. 生产排障清单

  • 记录准确时间线、JDK、GC、堆参数和容器限制。
  • 比较 GC 前后占用及多次 GC 后基线。
  • 保存 GC 日志、JFR、线程转储和类直方图。
  • dump 前评估暂停、磁盘、隐私与副本容灾风险。
  • 在 MAT 中看 retained size 和 Path to GC Roots。
  • 检查无界缓存、队列、监听器、ThreadLocal、类加载器。
  • 区分 Java 堆、Metaspace、Direct Memory 和进程 RSS。
  • 修复后用最坏场景压测,并为业务容器大小设置监控。

Full GC 是症状,不是根因。最有价值的排查产物不是一张“内存最多的类”截图,而是一条可以复现和验证的证据链:触发时间、回收效果、增长对象、GC Root、业务 owner、修复边界和上线后的反证。

分享:
文章作者:狼码纪
版权声明:本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。文章可能参考了其他优秀文章,如有侵权请联系删除。