VOL.001 · 创刊号Token 天团 API

Token 天团

—— 一份关于代码与生活的个人刊物 ——

技术Java教程

排查 OOM 的固定套路:先分类,再拍片,支配树上找大户——Java 工兵连第 10 篇(第一季收官)

「Java 工兵连」第 10 篇,第一季收官。选 OOM 排查压轴有两个原因:一是它综合——这一季讲过的线程池无界队列、ThreadLocal、集合选型,全是 OOM 的嫌疑人,这篇给它们总收口;二是它实用——凌晨三点的 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 打开,三个动作按顺序:

  1. Leak Suspects 报告:MAT 自动生成的嫌疑猜测,先扫一眼方向(比如「一个 ThreadLocal 占了 62% 堆」);
  2. Dominator Tree(支配树):核心武器——按「谁释放了,多少内存跟着释放」排序,最大的大户就是泄漏链的头。点开看它的对象链:典型的 Thread → ThreadLocal → 大 Map 或 静态字段 → ConcurrentHashMap → 百万 Entry,顺着链走到底就是凶手;
  3. 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 里):

  1. 缓存必须有上界——手写 Map 当缓存的,换 Caffeine/Guava 设最大条数 + 过期,或者最朴素 LinkedHashMap 重写 removeEldestEntry;
  2. ThreadLocal 用完必回收——try { ... } finally { threadLocal.remove(); },没有例外;
  3. 流和连接 try-with-resources——JDK 7 就有的语法,别再手写 close。

容器环境再补一条:JDK 8u191+ / 10+ 才正确感知容器内存限制(JDK 史那篇的第 10 篇记忆点),否则 JVM 把宿主机内存当自己的,容器 OOMKilled。

老兵点评

  1. OOM 不可怕,可怕的是「重启大法」把现场毁了。每次只重启不取证,你只是在等下一次 OOM,问题永远不会自己消失。三件套配齐的那天起,每次 OOM 都是送上门的改进清单。
  2. 八成以上的堆 OOM,凶手是「无界」二字。回看这一季:无界队列(第 1 篇)、无界缓存(本篇)、无界 ThreadLocal 累积(第 9 篇)——不限界的资源,迟早吞掉你的堆 这条暗线,从线程池一路贯到收官,它就是 Java 服务端内存问题的第一性原理。
  3. 面试讲 OOM 的段位差:初级背「堆溢出调大内存」;中级讲 jmap/MAT 流程;高级讲分类排查思路 + 「降不动是泄漏,降得动是容量」的判据——工具谁都会点,判断力才是分水岭。

带走三句话

  1. 先分类再动手:堆看集合与缓存,元看类与加载器,线程看数不看池,堆外看 NIO。
  2. 现场无价:HeapDumpOnOutOfMemoryError 必配,三板斧 = 拍片(dump)→ 看片(MAT 支配树)→ 佐证(GC 日志)。
  3. 判据一句话:Full GC 后降不动是泄漏,降得动是容量;代码侧记死「无界必爆,缓存设限,ThreadLocal 必回收」。

🎉 「Java 工兵连」第一季(十篇)至此完结。 → 上一篇:第 9 篇 · 虚拟线程:等待不再占人 | 第二季已开更:第 11 篇 · 事务隔离与 MVCC | 浏览整个专栏 →。

❦ 终 ❦

← 回到首页全部归档 →