更多干货,欢迎关注公众号:【Fox爱分享】
写在开头
大家好,我是 Fox。
春招冲刺,昨天一位粉丝“大山”发来语音复盘美团二面。
面试官问了一个极其惨烈的生产事故题: “大促期间,你的 Java 服务运行一段时间后,频繁 Full GC,最后直接抛出 OutOfMemoryError 宕机了。运维重启后,过几个小时又炸了。你作为负责人,怎么在不改代码的前提下快速止血?长期怎么定位是哪里的内存泄漏? ”
大山一听,心想这题我会啊!于是急忙回答: “既然内存不够,那就加内存呗!把 -Xmx 从 4G 调到 8G。如果还不行,就重启服务。”
面试官听完,叹了口气: “加内存?如果是内存泄漏(Memory Leak) ,你加到 100G 也会被吃光,只是死得晚一点而已!重启?现场都没保存你就重启? 你知道 jmap 会导致服务卡死吗?你知道 ThreadLocal 是怎么导致泄漏的吗?”
大山瞬间语塞,支支吾吾说不出话,最后遗憾离场。
兄弟们,这道题考的不是“JVM 参数”,考的是 “生产环境急救能力” 和 “内存快照分析能力” ! 如果你只知道“盲目扩容”,在面试官眼里你就是个只会浪费公司资源的“败家子”。
今天 Fox 就带大家拆解这道 P7 级别的 OOM 急救题,教你如何用 MAT (Memory Analyzer Tool) 抓出内存里的鬼。
一、 第一步:复习“小白误区”(OOM 也有很多种)
在排查前,必须搞清楚你面对的是哪种敌人。OOM 不仅仅是堆溢出,不同的错误日志对应不同的死法。
-
Heap Space OOM(最常见)
- 原因: 老年代满了,Full GC 回收不掉。
- 场景: 缓存了大量对象没过期、ThreadLocal 没清理、Excel 导出一次性查了几十万条数据。
-
Metaspace OOM(元空间溢出)
- 原因: 加载的类(Class)太多了。
- 场景: 动态代理(CGLib/Spring AOP)在运行期间生成了无数的代理类,把元空间撑爆了。
-
Direct Buffer Memory(堆外内存溢出 —— 隐形杀手)
- 原因: NIO 程序使用了过多的直接内存,但这部分内存不归 JVM 堆管。
- 场景: Netty 使用不当,或者大量使用
ByteBuffer.allocateDirect()却没释放。
结论: 不同的 OOM,排查方向完全不同。看日志里的错误类型是第一步!
二、 第二步:青铜解法——保留现场(Heap Dump)
面试官问的“快速止血”,绝不是重启。重启 = 毁灭证据! 一旦重启,堆内存里的案发现场(泄漏的对象)就全没了。
正确的止血姿势:
-
隔离节点(流量摘除): 先去 Nginx 或 Dubbo 注册中心,把这台故障机器摘下来。切断新流量,防止事故扩大。
-
自动 Dump(防御性): 你的 JVM 启动参数里必须配置了:
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/log/dump.hprof这意味着:死之前,JVM 会自动把内存快照拍下来保存。 这是最后的救命稻草。 -
手动 Dump(高能预警): 如果服务还没死透,你需要手动拍快照。但这里有个大坑!
- ❌错误做法: 直接在生产主节点执行
jmap -dump:format=b,file=heap.hprof PID。 - ⚠️Fox 高能预警:
jmap在 Dump 大内存(比如 8G+)时,会导致 JVM 进入 STW (Stop The World) 状态!整个服务会卡死几十秒甚至更久。必须先摘流量,再 Dump!
- ❌错误做法: 直接在生产主节点执行
注意: 命令中不要加 :live 选项!虽然加了能减小文件体积,但它会强制触发 Full GC。在内存已经告急的情况下,这无异于给骆驼压上最后一根稻草。
三、 第三步:王者解法——MAT 分析大法
拿到了几个 G 的 heap.hprof 文件,怎么看?用文本编辑器打开?那是乱码。 你要祭出Eclipse MAT (Memory Analyzer Tool)。
1. 概览图(Leak Suspects)
打开 MAT,它会自动生成一份报告。第一眼看"Leak Suspects"(泄漏嫌疑人)。
它会直白地告诉你:“有个名为 ArrayList 的对象占据了 80% 的堆内存。”
破案进度: 50%。
2. 支配树(Dominator Tree)
点击 Dominator Tree 视图。这就好比公司的组织架构图。
找到最大的那个对象。这里有两个概念必考:
- Shallow Heap: 对象本身占多大(比如一个 String 对象也就几十字节)。
- Retained Heap: 对象 + 它引用的所有对象总共占多大(如果回收它,能释放多少内存)。 我们要抓的,就是 Retained Heap 最大的那个“大 Boss”。
破案进度: 80%。
3. 引用链(Path to GC Roots)
这是最关键的一步。找到了大对象还不行,还得知道 “是谁在引用它” ,导致它无法被回收。
操作: 右键选中大对象 -> Path to GC Roots -> exclude all phantom/weak/soft references(排除虚/弱/软引用,只看强引用)。
MAT 会画出一条线,告诉你: “这个大对象被 ThreadLocalMap 引用着,而这个 Map 属于一个名为 http-nio-8080-exec-1 的线程。”
真相大白: 这里的代码往 ThreadLocal 里塞了东西,但是请求结束时忘记 remove() 了!线程池复用线程时,这个对象就一直赖在内存里,越积越多,最终 OOM。
特别提醒: 如果你的 Dump 文件里堆内存很空(占用很少),但进程依然 OOM,那极有可能是 堆外内存(Direct Memory) 爆了!这时候 MAT 能看到的信息有限(只能去搜 DirectByteBuffer 对象),更建议配合 Netty 的自带检测工具或 JVM 的 NMT (Native Memory Tracking) 来排查。
四、 第四步:进阶难题——ThreadLocal 为什么会泄漏?
如果面试官追问:“ThreadLocal 的 Key 是弱引用(WeakReference),GC 的时候不是会自动回收吗?为什么还会泄漏?” 这是 P7 必杀题。
P7 回答话术: “面试官,这涉及引用链的细节。
- Key 是弱引用:
ThreadLocalMap的 Key(ThreadLocal 对象本身)确实是弱引用。下一次 GC 时,Key 会被回收,变成null。 - Value 是强引用(关键点): 但是!Value(我们存的数据)是强引用。
- 泄漏链条:
CurrentThread->ThreadLocalMap->Entry(Key=null, Value=Object)。
- 死循环: 只要线程不结束(线程池复用线程),这个 Value 就永远有一条强引用链连着它。虽然 Key 没了(变成 null),你也访问不到它了,但垃圾回收器(GC)不敢回收它,因为它还被线程引用着!
- 结论: 必须在
finally块中显式调用ThreadLocal.remove(),这是切断这条引用链的唯一方法。”
五、 总结:拿来即用的面试模板
以后再被问到 线上 OOM 排查,别再只说加内存了,把下面这段话背下来:
“关于线上 OOM 问题,我通常遵循 ‘止血-保留-分析’ 的 SOP:
-
防御性配置(Tier 1): 在上线前,务必配置
-XX:+HeapDumpOnOutOfMemoryError。这是最后的救命稻草,保证事故发生时自动保留现场。 -
停机保命(Tier 2): 如果需要手动 Dump,绝不直接在流量节点执行
jmap,防止 STW 导致雪崩。必须先摘除流量,再进行 Dump。 -
根因分析(Tier 3):
- 拿到文件后,使用 MAT 查看 Dominator Tree,按
Retained Heap倒序,锁定占用内存最大的对象。 - 通过
Path to GC Roots定位引用源头。 - 常见元凶: 此时通常会发现是 ThreadLocal 漏写 remove,或者 静态集合(Static List)只加不减,亦或是 Excel 导出流 一次性加载了过多数据。
- 拿到文件后,使用 MAT 查看 Dominator Tree,按
这套流程不仅能解决问题,还能保护现场,防止二次故障。”
这套逻辑讲完,既懂 JVM 参数,又懂 MAT 实战,还懂 ThreadLocal 底层,面试官绝对会认为你是个能解决复杂 bug 的高手!
写在最后
技术没有侥幸。OOM 是悬在 Java 程序员头上的达摩克利斯之剑。 你们公司的代码里有没有手动 remove ThreadLocal?有没有遇到过导出 Excel 导致服务崩溃?评论区聊聊~
我是Fox,关注公众号【Fox爱分享】,回复“大厂”,领取《大厂高频面试题》。