所属模块:模块二:JVM性能调优深水区
一次线上Full GC频繁的完整排查记录:从监控告警到根因定位
真实场景与时间线
00:15 收到监控告警:某核心服务Full GC频率异常,过去10分钟内触发了12次Full GC,平均停顿时间800ms,服务响应时间P99从平时的50ms飙升到2秒以上。
00:20 值班同学登录服务器,先用jstat -gcutil <pid> 1000观察实时GC情况,确认老年代(O区)使用率持续在95%以上高位徘徊,每次Full GC后回落幅度很小,说明有大量对象持续存活在老年代,无法被正常回收------这是典型的内存泄漏或者内存持续增长的信号,而不是简单的"堆太小了"。
00:35 决定导出堆转储做进一步分析:jmap -dump:live,format=b,file=/tmp/heap.hprof <pid>(注意导出过程本身会触发一次Full GC并短暂暂停服务,生产环境操作前需要评估影响,必要时先切走流量)。
01:10 用MAT打开堆转储文件,查看Dominator Tree(支配树,能看出哪些对象占用内存最多、是被谁"拖住"没有释放),发现一个Map<String, Object>类型的静态缓存对象占用了整个堆的60%以上。
01:30 定位到代码里一个本地缓存组件,用于缓存热点商品信息,但忘记给缓存项设置过期时间,导致缓存只增不减,越积越多,最终把老年代填满,持续触发Full GC。
02:00 紧急发布修复(给缓存加上过期策略和最大容量限制),服务恢复正常。次日补充监控告警(缓存对象数量超过阈值自动告警)防止同类问题再次发生。
原理拆解
Full GC与Minor GC的触发条件不同:Minor GC 在新生代空间不足时触发,通常速度快、影响小;Full GC 通常在老年代空间不足、或者元空间不足、或者显式调用System.gc()时触发,需要扫描整个堆(包括老年代),停顿时间明显更长。大对象直接分配到老年代 是另一个需要了解的规则------如果单次分配的对象超过-XX:PretenureSizeThreshold阈值(部分垃圾回收器支持此参数),会跳过新生代直接进入老年代,如果代码里频繁产生大对象(比如大批量数据的序列化字符串),也会加速老年代空间耗尽。
Full GC的触发场景其实不止"空间不够"这一种,完整梳理常见的几类触发原因,能帮助排查时更快缩小方向:
| 触发场景 | 具体原因 | 排查方向 |
|---|---|---|
| 老年代空间不足(本章案例) | 对象持续晋升/存活,老年代被填满 | 堆转储分析,找持续增长的对象类型 |
| 元空间不足 | 类元数据持续增长(动态生成类过多) | jcmd <pid> GC.class_histogram查看类实例统计 |
| 空间分配担保失败 | Minor GC前判断老年代剩余空间不足以容纳新生代所有对象晋升的最坏情况,触发Full GC兜底 | 检查新生代对象晋升速率是否异常 |
显式调用System.gc() |
代码里(或者某些框架/第三方库内部)主动调用了System.gc() |
排查代码/依赖库里是否存在该调用 |
| CMS并发模式失败 | CMS收集器在并发清理过程中,老年代空间被耗尽,退化为单线程Full GC(仅老版本CMS收集器场景) | 老年代空间预留是否充足 |
这次案例属于第一种最典型的场景,但值班同学在00:20那一步能够快速判断"是内存泄漏而不是堆太小",关键就在于观察到了老年代使用率持续高位、且GC后回落幅度很小这个特征------这和"堆确实太小导致频繁GC但每次都能正常回收大部分空间"的表现有本质区别。
排查工具/关键命令
bash
# 开启详细GC日志(JDK9+统一日志格式)
-Xlog:gc*:file=/logs/gc.log:time,uptime:filecount=10,filesize=100m
# 实时观察GC情况,每秒刷新一次
jstat -gcutil <pid> 1000
# 导出堆转储(live参数表示只导出存活对象,减小dump文件体积)
jmap -dump:live,format=b,file=heap.hprof <pid>
jstat -gcutil的实际输出片段 ,对应案例里00:20这一步观察到的现象:
yaml
S0 S1 E O M CCS YGC YGCT FGC FGCT GCT
0.00 0.00 45.23 96.87 92.15 88.30 1893 38.201 12 9.784 47.985
0.00 0.00 52.61 97.02 92.15 88.30 1894 38.223 13 10.596 48.819
0.00 0.00 38.90 96.55 92.15 88.30 1895 38.245 13 10.596 48.841
重点看O这一列(老年代使用率百分比)------三行数据几乎稳定在96-97%,即使中间发生了一次Full GC(FGC从12变成13),O列的值也没有明显下降(还是96.55%),这正是"内存泄漏而非堆太小"最直观的数据证据:如果只是堆偏小导致的正常Full GC,回收后老年代使用率应该会明显回落(比如从95%降到40%这种量级),而不是像这样纹丝不动。
一份典型的GC日志片段(脱敏示例)长这样,重点关注Pause Full出现的频率和每次回收后老年代的实际下降幅度:
yaml
[2026-08-01T00:16:23.123+0800] GC(1024) Pause Full (Ergonomics)
Eden regions: 0->0, Old regions: 512->498, Humongous regions: 2->2
Metaspace: 45123K->45123K
Pause: 812.4ms
如果发现老年代区域回收前后几乎没有变化(比如512->498,只降了不到3%),说明大部分对象是真正存活、无法回收的,这就是内存泄漏而非简单的"堆太小"的明确信号。
01:10这一步用MAT定位到具体缓存类的详细过程 ,补充完整能更好理解排查思路:打开堆转储后,MAT首先会自动弹出一份"Leak Suspects"报告,这次案例里报告直接提示"一个类型为java.util.concurrent.ConcurrentHashMap的对象占用了约1.8GB内存,怀疑存在内存泄漏";切到Dominator Tree视图,按占用内存排序,能看到这个ConcurrentHashMap实例的具体持有路径:ProductCacheManager.CACHE (static field) -> ConcurrentHashMap -> 约230万个Entry;进一步展开任意几个Entry抽样查看,发现value是完整的商品详情对象,而且抽样的几个商品数据显示"最后更新时间"跨度长达数周------这个"数据新旧跨度极大"的细节,是判断"这是一个从未清理过的缓存"而不是"正常的热点数据"的关键佐证,因为正常的热点缓存,数据的时间跨度通常不会超过缓存本身设计的过期时间。
常见误区
看到Full GC频繁,第一反应就是"加大堆内存"------如果根因是内存泄漏(比如本例中的无过期缓存),加大堆内存只是延缓 问题发生的时间(泄漏对象需要更久才能填满更大的堆),而不是解决问题,反而会让下一次Full GC的停顿时间因为堆更大而变得更长。必须先通过堆转储分析定位到具体是什么对象在持续增长,才能对症下药。
另一个容易被忽视的隐藏根因:代码里(或者引入的第三方依赖库内部)显式调用了System.gc() 。这类调用如果没有配合-XX:+DisableExplicitGC或者-XX:+ExplicitGCInvokesConcurrent这类参数处理,默认情况下每次调用都会触发一次真正的Full GC。一些较老的框架、监控探针、甚至是RMI相关的默认实现里,历史上都存在过定期调用System.gc()的代码。排查Full GC频繁问题时,如果GC日志里Pause Full的触发原因标注的是(System.gc())而不是(Ergonomics)(本章示例日志里的标注),就应该直接把排查方向转向"代码/依赖库里哪里显式调用了GC",而不是继续在堆转储分析这条路上花时间------GC日志里的触发原因标注,本身就是判断该往哪个方向排查的第一手线索,值得养成每次都先看一眼的习惯。