生产环境 JVM 频繁 Full GC 导致接口雪崩,我用这套诊断流程 15 分钟找回凶手
凌晨两点半,手机炸了。
监控群连刷了十几条告警:P99 响应时间飙到 8 秒,错误率突破 15%,服务健康检查大面积失败。我爬起来连上 VPN,心里已经猜了个七七八八——这熟悉的节奏,大概率是 GC 出了问题。
果然,打开 Grafana 一看,Full GC 频率从平时的每小时 1-2 次,飙到了每分钟 8-10 次。每次 STW 接近 3 秒,接口不超时才怪。
第一步:先确认是不是 GC 的锅
很多人一上来就翻代码,其实应该先拿数据说话。我直接连上目标机器,用jstat看一眼 GC 概况:
jstat-gcutil<pid>100010输出是这样的:
S0 S1 E O M CCS YGC YGCT FGC FGCT GCT 0.00 0.00 34.25 98.71 92.34 84.56 1254 12.345 892 2345.678 2358.023老年代(O 列)使用率 98.71%,Full GC 次数 892 次,累计 STW 时间 2345 秒——将近 40 分钟。这已经不是调优能解决的问题了,老年代基本被塞满了,GC 根本回收不动。
结论:不是 GC 策略问题,是内存里有东西在疯狂占用老年代,而且无法被回收。
第二步:抓内存快照,看谁在吃内存
既然老年代快爆了,直接 dump 内存分析。先确认机器磁盘够,然后:
jmap-dump:format=b,file=/tmp/heap_dump.hprof<pid>文件下来之后,我用 MAT(Eclipse Memory Analyzer)打开,直接跑Dominator Tree分析。结果一目了然:
Class Name | Shallow Heap | Retained Heap ------------------------------------------------|--------------|-------------- java.util.HashMap$Node[] | 1,024,000 | 1,847,293,456 com.example.order.service.OrderCache$CacheEntry | 320,000 | 1,845,678,234一个HashMap占用了将近 1.8GB 内存,里面全是OrderCache的缓存条目。再点进去看引用链,发现是个本地缓存——用HashMap手写的,没有设置过期策略,也没有大小上限。
元凶找到了。
第三步:定位代码,确认根因
顺着 Dominator Tree 的引用链,定位到这段代码:
@ComponentpublicclassOrderCache{// 致命问题:没有上限、没有过期、没有清理privatestaticfinalMap<String,CacheEntry>CACHE=newHashMap<>();publicOrderDTOget(StringorderId){CacheEntryentry=CACHE.get(orderId);if(entry!=null){returnentry.getData();}OrderDTOdto=orderService.queryFromDB(orderId);CACHE.put(orderId,newCacheEntry(dto,System.currentTimeMillis()));returndto;}}问题很明显:
- 没有容量上限,来一个订单就塞一条
- 没有过期时间,数据永远留在内存里
- 没有淘汰策略,老数据一直占着坑
- 更坑的是,这个服务跑了两周,缓存了将近 200 万个订单对象
业务上这个缓存是想减少数据库查询,但实现方式太粗暴了。平时量小的时候没事,一旦赶上促销或者批量补单,内存直接被打爆。
第四步:修复 + 临时止血
先止血,不能让服务继续崩。我有两个选择:
- 重启服务——快,但会丢缓存,且问题还会再犯
- 动态清理缓存——更安全
我选了方案 2,用 Arthas 现场清掉缓存:
# 连上 Arthasjava-jararthas-boot.jar<pid># 清空缓存ognl'@com.example.order.service.OrderCache@CACHE.clear()'缓存清掉之后,老年代使用率立刻从 98% 降到了 35%,Full GC 停止,接口响应恢复正常。整个过程不到 5 分钟。
然后修复代码,换成 Caffeine,加上容量和过期限制:
@ComponentpublicclassOrderCache{privatefinalCache<String,OrderDTO>cache=Caffeine.newBuilder().maximumSize(10000)// 最多 1 万条.expireAfterWrite(10,TimeUnit.MINUTES)// 10 分钟过期.recordStats()// 方便监控.build();publicOrderDTOget(StringorderId){returncache.get(orderId,id->orderService.queryFromDB(id));}}第五步:JVM 参数优化(锦上添花)
代码修复后,顺便调整了 GC 参数。原来的参数是默认的 Parallel GC,STW 时间比较长。考虑到这个服务对延迟敏感,我换成了 G1,并加了几个关键参数:
-XX:+UseG1GC-XX:MaxGCPauseMillis=200-XX:InitiatingHeapOccupancyPercent=45-XX:+HeapDumpOnOutOfMemoryError-XX:HeapDumpPath=/var/log/heap-dumps/-XX:+PrintGCDetails-XX:+PrintGCDateStamps-Xloggc:/var/log/gc.logG1 的预测停顿时间配合 IHOP 调整,能在内存压力上来时提前触发并发标记,避免老年代突然被打满。
踩坑记录
1. 不要自己造缓存轮子
这次事故的根本原因是手写了无界HashMap当缓存。Guava Cache 或者 Caffeine 都有完善的容量控制、过期策略和统计监控,比自己写靠谱 100 倍。
2. 监控要前置,别等告警了才看
我们后来给这个服务补了两块监控:
# JVM 内存使用率jvm_memory_used_bytes / jvm_memory_max_bytes# GC 停顿时间(通过 Micrometer + Prometheus)jvm_gc_pause_seconds_max阈值设成:老年代使用率 > 80% 或者 Full GC 间隔 < 5 分钟就告警。这样下次有问题,能在雪崩之前发现。
3. MAT 的 Dominator Tree 比 Histogram 更直观
一开始我用 Histogram 看,只看到byte[]和char[]占了很多内存,但不知道是谁在引用。Dominator Tree 直接展示了引用链,省了不少时间。
4. Arthas 是线上排查神器
这次如果不是 Arthas 能动态清缓存,只能重启服务,会丢失很多数据。建议每个 Java 服务都部署 Arthas,关键时刻能救命。
写在最后
从告警到定位,总共 15 分钟。其中 10 分钟在等 heap dump 下载,真正分析只用了 5 分钟。
这件事给我的教训是:内存泄漏不会立刻爆炸,它会在某个业务量突增的凌晨给你致命一击。平时多关注老年代增长趋势,比事后排查重要得多。
如果你也维护 Java 服务,建议现在就检查一遍——有没有手写 Map 当缓存的?有没有大对象没及时释放的?有没有 GC 日志但没看过的?
预防永远比救火便宜。