生产环境 JVM 频繁 Full GC 导致接口雪崩,我用这套诊断流程 15 分钟找回凶手 生产环境 JVM 频繁 Full GC 导致接口雪崩我用这套诊断流程 15 分钟找回凶手凌晨两点半手机炸了。监控群连刷了十几条告警P99 响应时间飙到 8 秒错误率突破 15%服务健康检查大面积失败。我爬起来连上 VPN心里已经猜了个七七八八——这熟悉的节奏大概率是 GC 出了问题。果然打开 Grafana 一看Full GC 频率从平时的每小时 1-2 次飙到了每分钟 8-10 次。每次 STW 接近 3 秒接口不超时才怪。第一步先确认是不是 GC 的锅很多人一上来就翻代码其实应该先拿数据说话。我直接连上目标机器用jstat看一眼 GC 概况jstat-gcutilpid100010输出是这样的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:formatb,file/tmp/heap_dump.hprofpid文件下来之后我用 MATEclipse 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{// 致命问题没有上限、没有过期、没有清理privatestaticfinalMapString,CacheEntryCACHEnewHashMap();publicOrderDTOget(StringorderId){CacheEntryentryCACHE.get(orderId);if(entry!null){returnentry.getData();}OrderDTOdtoorderService.queryFromDB(orderId);CACHE.put(orderId,newCacheEntry(dto,System.currentTimeMillis()));returndto;}}问题很明显没有容量上限来一个订单就塞一条没有过期时间数据永远留在内存里没有淘汰策略老数据一直占着坑更坑的是这个服务跑了两周缓存了将近 200 万个订单对象业务上这个缓存是想减少数据库查询但实现方式太粗暴了。平时量小的时候没事一旦赶上促销或者批量补单内存直接被打爆。第四步修复 临时止血先止血不能让服务继续崩。我有两个选择重启服务——快但会丢缓存且问题还会再犯动态清理缓存——更安全我选了方案 2用 Arthas 现场清掉缓存# 连上 Arthasjava-jararthas-boot.jarpid# 清空缓存ognlcom.example.order.service.OrderCacheCACHE.clear()缓存清掉之后老年代使用率立刻从 98% 降到了 35%Full GC 停止接口响应恢复正常。整个过程不到 5 分钟。然后修复代码换成 Caffeine加上容量和过期限制ComponentpublicclassOrderCache{privatefinalCacheString,OrderDTOcacheCaffeine.newBuilder().maximumSize(10000)// 最多 1 万条.expireAfterWrite(10,TimeUnit.MINUTES)// 10 分钟过期.recordStats()// 方便监控.build();publicOrderDTOget(StringorderId){returncache.get(orderId,id-orderService.queryFromDB(id));}}第五步JVM 参数优化锦上添花代码修复后顺便调整了 GC 参数。原来的参数是默认的 Parallel GCSTW 时间比较长。考虑到这个服务对延迟敏感我换成了 G1并加了几个关键参数-XX:UseG1GC-XX:MaxGCPauseMillis200-XX:InitiatingHeapOccupancyPercent45-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 Prometheusjvm_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 日志但没看过的预防永远比救火便宜。