排查 OOM 的固定套路:先分类,再拍片,支配树上找大户——Java 工兵连第 10 篇(第一季收官)
「Java 工兵连」第 10 篇,第一季收官。选 OOM 排查压轴有两个原因:一是它综合——这一季讲过的线程池无界队列、ThreadLocal、集合选型,全是 OOM 的嫌疑人,这篇给它们总收口;二是它实用——凌晨三点的 OOM 告警面前,「玄学重启」和「固定套路」的差距就是下班和通宵的差距。
先把认知立住:OOM 排查不是玄学,是流程。全文就三步:先分类(漏在哪个房间),再拍片(拿现场快照),最后看支配树(顺着水渍找漏点)。
一、第一步:看异常类型,对号入座
OOM 报错信息的第一行就是分类答案,四类常见嫌疑各不相同:
① Java heap space(最常见)——堆水池满了。嫌疑人名单按出现频率排:
- 无界集合与队列:
newFixedThreadPool的无界队列(第 1 篇)、LinkedBlockingQueue默认无界(第 8 篇)、静态HashMap当缓存只进不出; - 大对象一次性加载:
SELECT *不分页拉百万行、Files.readAllBytes读大文件、Excel 全量导入; - ThreadLocal 不 remove:线程池的线程长生不死 × ThreadLocal 里的的大对象 = 永久占有(第 9 篇讲虚拟线程时也点了它);
- 连接/监听器没关:累积泄漏的慢性病。
② Metaspace——工具间(类元数据)堆满。嫌疑人:动态类生成失控(CGLIB 代理被反复创建、Groovy 脚本、反射 defineClass)、热部署框架的 ClassLoader 泄漏。
③ unable to create new native thread——水龙头太多,不是内存问题!看线程数:裸奔的每请求一线程、定时任务泄漏的线程、ulimit 或容器线程配额打满。排查用 jstack <pid> | grep '"' | wc -l 数线程、看是谁在造。
④ Direct buffer memory——水泼到了地上(堆外)。NIO 的 DirectByteBuffer、Netty 池化配置,受 -XX:MaxDirectMemorySize 约束。
分类完先自查一遍嫌疑名单,很多时候答案已经在名单上了——套路的价值就是让直觉有据可依。
二、第二步:拍片——现场比什么都值钱
最重要的一件事在 OOM 发生之前:让 JVM 出事时自动留快照。
# 上线必配,一次都不该少
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/dump/
进程还活着时可以补拍(注意 live 会先触发一次 Full GC,生产高峰期慎用;不带 live 更完整但文件更大):
jmap -dump:live,format=b,file=heap.hprof <pid> # 拍全量堆快照
jmap -histo:live <pid> | head -20 # 轻量版:按类看对象数量与大小排行
进程死了又没配 dump = 现场全毁,只剩重启等下次——这就是为什么三件套必须上线就配(见文末)。
三、第三步:看片——MAT 的支配树
拿到 .hprof 后用 Eclipse MAT 打开,三个动作按顺序:
- Leak Suspects 报告:MAT 自动生成的嫌疑猜测,先扫一眼方向(比如「一个 ThreadLocal 占了 62% 堆」);
- Dominator Tree(支配树):核心武器——按「谁释放了,多少内存跟着释放」排序,最大的大户就是泄漏链的头。点开看它的对象链:典型的
Thread → ThreadLocal → 大 Map或静态字段 → ConcurrentHashMap → 百万 Entry,顺着链走到底就是凶手; - Histogram + incoming references:按类聚合数量,对可疑类看「谁在引用我」,顺藤摸瓜。
GC 日志做佐证判断「泄漏还是容量」(-Xlog:gc*,JDK 8 用 -XX:+PrintGCDetails):
- Full GC 后老年代占用降不下来 → 对象死活放不掉 → 泄漏,去支配树找大户;
- 降得动,但分配速度太快、Full GC 越来越勤 → 不是泄漏,是容量或流量问题——加堆、调缓存上界、限流,三选一。
一句话判据:降不动是泄漏,降得动是容量。这一步判断错,后面全白干。
四、上线三件套与代码三条规约
三件套(今天就能给所有服务配上):
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/dump/ # ① 自动拍片
-Xlog:gc*:file=gc.log:time,uptime:filecount=5,filesize=50m # ② GC 日志滚动
# ③ 告警:老年代占用率 + Full GC 次数,两条曲线异常先于人感知
三条代码规约(把 OOM 扼杀在 Code Review 里):
- 缓存必须有上界——手写
Map当缓存的,换 Caffeine/Guava 设最大条数 + 过期,或者最朴素LinkedHashMap重写removeEldestEntry; - ThreadLocal 用完必回收——
try { ... } finally { threadLocal.remove(); },没有例外; - 流和连接 try-with-resources——JDK 7 就有的语法,别再手写 close。
容器环境再补一条:JDK 8u191+ / 10+ 才正确感知容器内存限制(JDK 史那篇的第 10 篇记忆点),否则 JVM 把宿主机内存当自己的,容器 OOMKilled。
老兵点评
- OOM 不可怕,可怕的是「重启大法」把现场毁了。每次只重启不取证,你只是在等下一次 OOM,问题永远不会自己消失。三件套配齐的那天起,每次 OOM 都是送上门的改进清单。
- 八成以上的堆 OOM,凶手是「无界」二字。回看这一季:无界队列(第 1 篇)、无界缓存(本篇)、无界 ThreadLocal 累积(第 9 篇)——
不限界的资源,迟早吞掉你的堆这条暗线,从线程池一路贯到收官,它就是 Java 服务端内存问题的第一性原理。 - 面试讲 OOM 的段位差:初级背「堆溢出调大内存」;中级讲 jmap/MAT 流程;高级讲分类排查思路 + 「降不动是泄漏,降得动是容量」的判据——工具谁都会点,判断力才是分水岭。
带走三句话
- 先分类再动手:堆看集合与缓存,元看类与加载器,线程看数不看池,堆外看 NIO。
- 现场无价:
HeapDumpOnOutOfMemoryError必配,三板斧 = 拍片(dump)→ 看片(MAT 支配树)→ 佐证(GC 日志)。 - 判据一句话:Full GC 后降不动是泄漏,降得动是容量;代码侧记死「无界必爆,缓存设限,ThreadLocal 必回收」。
🎉 「Java 工兵连」第一季(十篇)至此完结。 → 上一篇:第 9 篇 · 虚拟线程:等待不再占人 | 第二季已开更:第 11 篇 · 事务隔离与 MVCC | 浏览整个专栏 →。
❦ 终 ❦