凌晨两点,手机疯狂震动。监控平台发来告警:核心服务响应超时,CPU飙到95%,日志里赫然出现java.lang.OutOfMemoryError: Java heap space。我翻身下床,打开电脑,开始了这次OOM排查。
第一步:保留现场,别急着重启
很多运维的第一反应是重启,但重启会丢失现场。我立刻登录服务器,先确认进程还在,然后执行:
jmap -dump:live,format=b,file=/tmp/heap.hprof 12345
同时检查JVM启动参数,确认已经配置了-XX:+HeapDumpOnOutOfMemoryError,这样即使进程崩溃也能自动保存堆快照。接着用jstat -gcutil 12345 1000观察GC情况:老年代使用率99%,Full GC每隔几秒触发一次,但回收效果几乎为零。典型的堆内存泄漏。
第二步:分析堆快照,找到大对象
把hprof文件下载到本地,用MAT(Memory Analyzer Tool)打开。首页的“Leak Suspects”报告直接指向一个ConcurrentHashMap,它占了整个堆的78%,里面有超过200万个Entry。展开看,Key是用户ID,Value是一个订单列表对象。很明显,这是一个本地缓存,但没有任何清理机制。
继续用MAT的“Path to GC Roots”功能,发现这个Map被一个静态变量持有,生命周期和JVM一样长。代码里这样写的:
private static final Map<Long, List<Order>> ORDER_CACHE = new ConcurrentHashMap<>();
每次查询订单,如果缓存里没有,就查数据库然后放进去。但从来没有移除逻辑,也没有设置上限。系统运行了半个月,缓存越来越大,最终撑爆了堆。
第三步:定位代码,确认泄漏路径
回到代码仓库,搜索ORDER_CACHE,发现调用点在一个订单查询接口里:
public List<Order> getOrders(Long userId) { List<Order> orders = ORDER_CACHE.get(userId); if (orders == null) { orders = orderDao.queryByUserId(userId); ORDER_CACHE.put(userId, orders); } return orders; }问题很清晰:用户量上百万,每个用户都缓存,而且订单数据还会变化,缓存永远不会失效。这是典型的“把缓存当数据库用”,而且没有淘汰策略。
第四步:紧急修复与长期方案
紧急处理:先重启服务,临时把堆从2G调到4G,争取时间。然后修改代码,把本地缓存换成Redis,并设置TTL。如果非要用本地缓存,至少用Caffeine,指定maximumSize(10000)和expireAfterWrite(10, TimeUnit.MINUTES)。
长期方案:
所有本地缓存必须设置容量上限和过期时间。
增加监控:对缓存大小、GC频率、老年代使用率设置告警。
JVM参数优化:-Xms和-Xmx设为相同值,避免动态扩容;-XX:+UseG1GC,并设置-XX:MaxGCPauseMillis=200。
定期做堆dump分析,防患于未然。
第五步:复盘与总结
这次OOM的根本原因不是JVM参数没调好,而是代码里埋了一个无界缓存。JVM调优能解决的是“资源怎么分配”,但解决不了“对象该不该活”。排查OOM,工具只是辅助,核心是理解对象的生命周期。
记住三点:
线上必须开启HeapDumpOnOutOfMemoryError,现场比什么都重要。
MAT是分析堆的利器,重点看“Leak Suspects”和“Dominator Tree”。
本地缓存一定要有界,最好用成熟框架,别自己手写Map。
那次之后,我们组定了一条规矩:任何静态集合,必须写明清理策略,否则代码评审不通过。毕竟,JVM再能扛,也扛不住你无限往里塞对象。