欢迎光临
我们一直在努力

线上 OOM 惊魂夜:一场关于内存泄漏的“法医鉴定“

凌晨两点,手机突然疯狂震动。

"杨工!线上服务挂了!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 清单":

  • 上线前压测:观察堆内存曲线,看是否持续上涨不回落。
  • 缓存一律限界:用 Caffeine 或 Guava Cache,设最大容量和过期时间。
  • 慢查询日志兜底:数据库查询必须加 limit,大结果集分页处理。
  • OOM 自动 dump 进启动模板:-XX:+HeapDumpOnOutOfMemoryError 写进所有服务的启动脚本,别再忘。
  • dump 文件太大打不开?:调大 MAT 的启动内存(修改 MemoryAnalyzer.ini),或先用 jmap -histo 缩小范围;也可以用 Arthas 的 heapdump 分区导出。

  • 尾声

    天快亮了,服务恢复正常。

    小刘:"杨工,下次面试要是问 OOM 排查,我知道怎么答了。"

    我:"记住,排查 OOM 就像破案:分类型→留现场→取 dump→MAT 分析→顺引用链→定位代码。套路熟了,半夜就不慌了。"

    他点点头,补了一句:"还有,以后 JVM 启动参数我一定检查三遍。"

    我笑了:"这才是今晚最大的收获。"

    赞(0)
    未经允许不得转载:171主机测评 » 线上 OOM 惊魂夜:一场关于内存泄漏的“法医鉴定“
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址