凌晨两点,手机突然疯狂震动。
"杨工!线上服务挂了!OutOfMemoryError: Java heap space!" 实习生小刘的声音带着哭腔。
我叹了口气,披上外套打开电脑。这已经是我这个月第三次半夜被叫醒了。
第一幕:案发现场——先分清"死因"
我:"别慌,先把错误日志截图发我。"
小刘:"发了!就是 Java heap space。"
我:"好,先记住一件事:OOM 不是只有一种。就像医生看病,得先分清楚是哪种病。"
我给他列了一张"急诊分诊表":
| Java heap space | 堆内存 | 内存泄漏、一次性加载大对象(查表没加 limit)、集合只增不减 |
| GC overhead limit exceeded | 堆内存 | JVM 花了 98% 的时间做 GC,却只回收了不到 2% 的内存——这是堆快满了的前兆 |
| Metaspace | 元空间 | 动态代理/CGLIB/反射生成类过多、类加载器泄漏、热部署频繁 |
| Direct buffer memory | 堆外内存 | NIO / Netty 申请直接内存,堆外空间耗尽 |
| unable to create new native thread | 本地内存 | 线程数超过系统限制(ulimit),或代码里无节制地创建线程 |
| StackOverflowError | 虚拟机栈 | 递归太深、方法循环调用(常和 OOM 一起被面试官问) |
我:"我们是 Java heap space,说明问题出在堆上。接下来,按步骤来。"
第二幕:保留现场——别让证据消失
小刘:"那我重启服务?"
我:"重启可以止血,但别忘了'保留现场'。你确认 JVM 启动参数里有这两行吗?"
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/
小刘:"呃……好像没有。"
我:"这就是教训。加了这两个参数,OOM 发生时 JVM 会自动 dump 一份内存快照,相当于案发现场的监控录像。这次没配,只能手动取了。"
我让他执行:
jmap -dump:format=b,file=heap.hprof 12345
小刘:"文件好大……有 8GB!"
我:"正常。dump 会暂停整个 JVM(STW),生产高峰期慎用。如果只是想快速判断方向,可以用更轻量的命令先看一眼:
jmap -histo 12345 | head -20
这会列出对象数量和大小排行,不用等几十 GB 的文件生成。"
第三幕:法医鉴定——MAT 分析
我们把 dump 文件拖到 Eclipse Memory Analyzer(MAT)里打开。
我:"MAT 打开后,第一眼先看什么?"
小刘:"看 Leak Suspects 报告?"
我:"对。MAT 会自动帮你标出可疑的大对象,相当于法医初步判断'这里有问题'。但别全信它,还得自己验证。"
我点开 Dominator Tree(支配树),按 Retained Heap 从大到小排序。
小刘:"等等,Shallow Heap 和 Retained Heap 有什么区别?面试官爱问这个。"
我:"好问题。打个比方:
- Shallow Heap:一个 HashMap 对象自身占的内存,可能就几十字节。
- Retained Heap:这个 HashMap 被回收后,能连带释放的所有内存——包括它引用的几百万个 Entry、每个 Entry 里的 key 和 value。加起来可能几个 GB。
排查内存泄漏,永远看 Retained Heap,不看 Shallow Heap。"
小刘:"找到了!一个 static ConcurrentHashMap,Retained Heap 占了 6GB!"
我:" bingo。这就是经典泄漏模式之一。"
第四幕:锁定真凶——常见泄漏模式
我让小刘顺着 GC Roots 引用链往下追,果然找到了业务代码:
// 罪魁祸首
private static final ConcurrentHashMap<String, byte[]> CACHE = new ConcurrentHashMap<>();
public void process(String key, byte[] data) {
CACHE.put(key, data); // 只 put,从不 remove
}
我:"看到了吗?static 修饰的 Map,当缓存用,但没设上限、没设过期时间。数据只进不出,迟早把堆吃光。"
我把常见的泄漏模式给他总结了一遍,让他背下来:
- static 集合只增不减:用 ConcurrentHashMap 当缓存,却不设上限和过期策略。
- ThreadLocal 用完不 remove():线程池复用线程,value 越积越多。
- 无界缓存:应该换成 Caffeine,设 maximumSize + expireAfterWrite。
- 数据库查询没加 limit:一条 SQL 把整张表捞进 ArrayList。
- 连接/流没关:连接池泄漏,同时表现为获取连接超时。
小刘:"那怎么判断是泄漏还是堆配置太小?"
我:"看 dump 里的对象分布:
- 如果是大量'该死没死'的活对象(业务对象被引用着,GC 收不掉)→ 内存泄漏,改代码。
- 如果对象分布正常,只是堆确实太小 → 配置不足,加堆只是临时手段,长期还是要优化。"
第五幕:亡羊补牢——怎么防?
修复完代码后,我给小刘列了一份"防 OOM 清单":
尾声
天快亮了,服务恢复正常。
小刘:"杨工,下次面试要是问 OOM 排查,我知道怎么答了。"
我:"记住,排查 OOM 就像破案:分类型→留现场→取 dump→MAT 分析→顺引用链→定位代码。套路熟了,半夜就不慌了。"
他点点头,补了一句:"还有,以后 JVM 启动参数我一定检查三遍。"
我笑了:"这才是今晚最大的收获。"

