Java 面试笔记III · 14 / 14

Lesson 37 · JVM 原理与调优

OOM 排查实战:六种 OutOfMemoryError 全解

高级·#JVM·#实战·#排查

第 1 站

凌晨 2 点的 PagerDuty:服务挂了,日志里全是 OOM

"凌晨 2:14,PagerDuty 响了,订单服务 4 个 Pod 全部 OOMKilled。值班群里老板已经在了:'多久能恢复?'你打开终端,手心全是汗——这种时候该怎么排查?"

这不是假设题,是每个 Java 后端工程师迟早会遇到的真实场景。我经历过的最严重的一次:大促前夜,一个缓存预热任务把全量商品数据一次性塞进了内存,3000 万条记录,每个对象约 500 字节——算一下,15GB。而我们的堆只给了 8GB。服务启动 20 分钟后,OOM,Kubernetes 重启,再 OOM,再重启——死循环。

java.lang.OutOfMemoryError 是 JVM 发出的最后一道求救信号。但 OOM 不止一种,不同后缀对应完全不同的根因和修复策略:

OOM 类型内存区域一句话根因
Java heap space对象太多或太大,堆不够
Metaspace元空间加载的类太多,元空间不够
Direct buffer memory堆外内存NIO DirectByteBuffer 超上限
Unable to create new native thread操作系统线程数超过 OS 限制
GC overhead limit exceededGC 忙死了但回收不了多少
Map/Array too large单个数组/集合超过 2GB 上限
面试考什么?

面试官问 OOM,不是在考你能不能背出 6 种类型。他想知道:你有没有真的排查过?给你一个日志片段,你能不能 30 秒内定位方向?这篇文章把每种 OOM 的"案发现场→诊断手段→修复方案"全链路走一遍。

第 2 站

Java heap space:最常见的 OOM,也最容易踩坑

这是出现频率最高的 OOM,没有之一。日志里一行:

OOM 现场
java.lang.OutOfMemoryError: Java heap space
  at java.util.Arrays.copyOf(Arrays.java:3236)
  at java.util.ArrayList.grow(ArrayList.java:265)
  at com.example.OrderService.exportAllOrders(OrderService.java:142)

根因只有两种:要么堆给小了(配置问题),要么对象太多了(内存泄漏)。

正常堆内存使用 已用 60% 空闲 GC 正常回收,堆空间充裕 OOM: Java heap space 已用 99%+ 无法分配新对象 GC 频繁但回收极少 → 崩溃
图 1 正常堆 vs OOM 堆对比:堆空间被占满,新对象无处安放

诊断四步法:生产环境一定要先加这两个 JVM 参数,OOM 时自动留"遗照":

JVM 启动参数(生产必备)
# OOM 时自动 dump 堆 + 打印 GC 日志
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/data/logs/heapdump.hprof
-Xlog:gc*=file=/data/logs/gc.log:time,uptime,level,tags

拿到 .hprof 文件后,用 Eclipse MAT(Memory Analyzer Tool)打开:

  1. 点击 Leak Suspects Report——MAT 自动分析最大嫌疑对象
  2. Dominator Tree——按 retained size 排序,找到占内存最大的对象
  3. Path to GC Roots——看谁持有了这些对象,阻止了回收
一次真实 MAT 分析结果
// MAT Leak Suspects Report
Thread "http-nio-8080-exec-12" 占用 3.2GB(总堆 4GB)
└── com.example.service.ExportTask
    └── ArrayList<OrderDTO>  8,420,000 个对象
        └── 每个 OrderDTO ~380 bytes
        └── retained size = 3.2GB

// 根因:导出接口一次性加载全量订单到内存
// 修复:改为分页导出 + 流式写入 CSV
修复策略:短期 → -Xmx 调大(治标);长期 → 找到泄漏点或优化数据加载方式(治本)。
避坑指南

千万别只调大 -Xmx 就以为解决了。如果是内存泄漏,堆调到 32GB 也只是把崩溃时间推迟了几小时。正确的做法是先 dump、再分析、再修复,-Xmx 只是给你争取排查时间。

第 3 站

Metaspace:类太多,元空间爆了

Java 8 之后,PermGen 被 Metaspace 取代。Metaspace 存的是类的元数据(Class 对象、方法字节码、常量池),默认使用本地内存(Native Memory),不设上限——这意味着如果不设限制,它可以吃光你整台机器的内存。

Metaspace OOM 现场
java.lang.OutOfMemoryError: Metaspace
  at java.lang.ClassLoader.defineClass(ClassLoader.java:763)
  at org.springframework.cglib.core.ReflectUtils.defineClass(ReflectUtils.java:571)

// jcmd 查看已加载类数量
$ jcmd 12345 VM.classloader.stats
ClassLoader            Parent               Classes  Classes
                                          loaded  unloaded
bootstrap              -                     2,847        0
platform               bootstrap               12        0
app                    platform               456        0
GroovyClassLoader      app                48,320       12  ← 问题在这!

三种典型场景

  1. 动态代码生成:CGLIB、Javassist、Groovy 脚本引擎不断生成新类,旧类不被卸载
  2. ClassLoader 泄漏:热部署场景下,旧 ClassLoader 没有被 GC 回收,它加载的类一直留在 Metaspace
  3. 超大项目 + 大量依赖:Spring Boot 大型项目启动时加载数千个类

真实案例:我们的规则引擎使用 Groovy 脚本,每次规则变更都会编译出新的 Class。运行一周后,Metaspace 从 200MB 涨到 2GB,最终 OOM。修复方式:给 GroovyClassLoader 加 LRU 缓存,限制最大类数量,并显式调用 groovyShell.getClassLoader().clearCache()

修复方案

① 设置上限防止吃光内存:-XX:MaxMetaspaceSize=512m
② 排查类加载泄漏:jcmd <pid> VM.classloader.stats 看哪个 ClassLoader 加载了异常多的类
③ 开启类卸载:-XX:+ClassUnloading -XX:+UseG1GC(G1 默认支持类卸载,CMS 需要显式开启)

第 4 站

Direct buffer memory:堆外内存的隐形杀手

使用 NIO 的 ByteBuffer.allocateDirect() 分配的内存在堆外(Native Memory),不受 -Xmx 控制,而是由 -XX:MaxDirectMemorySize 限制(默认等于 -Xmx 的值)。

Netty 应用典型 OOM
java.lang.OutOfMemoryError: Direct buffer memory
  at java.nio.Bits.reserveMemory(Bits.java:694)
  at java.nio.DirectByteBuffer.<init>(DirectByteBuffer.java:123)
  at io.netty.buffer.PoolArena$DirectArena.allocateDirect(PoolArena.java:764)

// 查看当前 DirectMemory 使用量
$ jcmd 12345 VM.native_memory summary
Internal              48MB
Thread                120MB
GC                    32MB
Symbol                16MB
Native Memory Tracking 2MB
                         ────
Total                  218MB   ← 但 DirectByteBuffer 不在这里显示!

最容易踩坑的地方:DirectMemory 使用量在标准监控工具里几乎不可见。jmap 看的是堆,jstat 看的是 GC,top 看的是整进程——你需要用 -XX:NativeMemoryTracking=summary 配合 jcmd VM.native_memory 才能看到堆外内存的真实用量。

用 JMX 监控 DirectMemory
// 通过 JMX 获取 DirectMemory 使用量
MBeanServer server = ManagementFactory.getPlatformMBeanServer();
ObjectName name = new ObjectName("java.nio:type=BufferPool,name=direct");
long used = (long) server.getAttribute(name, "MemoryUsed");
long capacity = (long) server.getAttribute(name, "TotalCapacity");
System.out.printf("Direct Memory: %dMB / %dMB%n",
    used / 1024 / 1024, capacity / 1024 / 1024);
修复策略:① -XX:MaxDirectMemorySize=1g 显式设限;② Netty 应用检查 ByteBuf 是否泄漏(-Dio.netty.leakDetection.level=PARANOID);③ 非高性能场景改用 allocate()(堆内存)替代 allocateDirect()
避坑指南

容器环境下尤其要注意:Docker 的 --memory 限制包含堆 + 堆外 + 线程栈 + JVM 内部。你给了 4GB 内存限制,-Xmx 设了 3GB,剩下 1GB 要分给 Metaspace、DirectMemory、线程栈——很可能不够。

第 5 站

Unable to create new native thread:线程数爆了

这个 OOM 和内存没有直接关系——它是操作系统级别的线程数限制。每创建一个 Java 线程,JVM 就要向 OS 申请一个原生线程,而 OS 对每个进程的线程数有上限。

真实案例:消息消费者线程泄漏
java.lang.OutOfMemoryError: unable to create new native thread
  at java.lang.Thread.start0(Native Method)
  at java.lang.Thread.start(Thread.java:717)
  at com.example.mq.ConsumerManager.subscribe(ConsumerManager.java:88)

// 查看线程数
$ jcmd 12345 Thread.print | grep -c "Thread"
32,768  ← 已经到 ulimit 上限了!

$ ulimit -u
32768   ← 这就是天花板

// 线程分布
$ jstack 12345 | grep "Thread.State" | sort | uniq -c
28,400  WAITING (parking)   ← 28000 个线程在等锁!
 2,100  TIMED_WAITING
 1,800  RUNNABLE
   468  BLOCKED

上面这个案例是真实遇到的:一个消息消费组件,每次收到消息就 new Thread() 去处理,没有用线程池。跑了 3 天,线程数悄悄涨到 3 万多。

线程数的天花板不只是 ulimit -u。还要考虑内存:每个线程默认栈大小 1MB(-Xss),3 万个线程 = 30GB 栈内存!这就是为什么有时候堆还没满,进程就被 OOM Killer 杀了。

最大线程数估算公式
可创建线程数 ≈ (进程可用内存 − 堆 − Metaspace) / 线程栈大小
例如:容器 8GB,堆 4GB,Metaspace 512MB,-Xss=512k,则最多 (8−4−0.5)×1024/0.5 ≈ 7168 个线程。
修复三板斧

止血ulimit -u 65535(临时提高上限)
排查jstack 导出线程快照,按线程名分组统计,找到泄漏源
根治:所有线程创建都走线程池,配置合理的 maximumPoolSize;减小 -Xss(如 512k)释放内存空间

第 6 站

GC overhead limit exceeded:JVM 在喊"我快累死了"

这个 OOM 比较特殊——它不是因为内存真的用光了,而是因为 JVM 花了超过 98% 的时间在做 GC,但每次只回收到不到 2% 的堆空间。换句话说,JVM 觉得自己已经"生不如死"了,选择自杀式退出。

GC 日志里的蛛丝马迹
// GC 日志(OOM 前 10 分钟)
[12.5s] GC pause (G1 Evacuation Pause) 45MB42MB  12ms
[12.8s] GC pause (G1 Evacuation Pause) 44MB40MB  28ms
[13.0s] GC pause (G1 Evacuation Pause) 43MB39MB  85ms    ← GC 时间飙升
[13.1s] GC pause (G1 Evacuation Pause) 42MB38MB  320ms   ← 回收越来越少
[13.2s] GC pause (G1 Evacuation Pause) 41MB39MB  1200ms  ← 一次 GC 1.2 秒!
[13.3s] GC pause (Full GC) 40MB39MB  4500ms            ← Full GC,几乎没回收
java.lang.OutOfMemoryError: GC overhead limit exceeded

GC overhead limit exceeded 几乎总是意味着内存泄漏。它和 "Java heap space" 的区别在于:heap space 是"内存真的不够了",而 GC overhead 是"内存虽然还有一点,但 JVM 已经没力气继续了"。

真实踩坑:一个定时任务每 5 分钟跑一次,每次都在一个 HashMap 里累加统计数据,但从来不删除过期数据。运行两周后,这个 Map 里有 2000 万条记录,GC 每次扫描它都要花好几秒,最终触发 GC overhead limit。

修复策略

不要禁用这个检查-XX:-UseGCOverheadLimit 只是让 JVM 不再报错继续跑,但此时服务已经几乎无法响应请求了(98% 时间都在 GC)——这只是把"快速死亡"变成了"慢性死亡"。
② 正确做法:按照第 2 站的方法,dump 堆 → MAT 分析 → 找到内存泄漏 → 修复代码。
③ 监控前置:配置 GC 时间占比告警(如 Full GC 间隔 < 30 秒就告警),不要等到 OOM 才发现。

第 7 站

Requested array size exceeds VM limit:Java 的 2GB 天花板

Java 中数组的最大长度受限于 int 的上限(Integer.MAX_VALUE - 8 ≈ 21 亿),但实际上受堆大小限制,通常远达不到这个理论值。当代码试图创建一个超大数组时,就会触发这个 OOM。

典型触发场景
// 场景 1:ArrayList 扩容溢出
ArrayList<byte[]> list = new ArrayList<>();
for (int i = 0; i < 100_000_000; i++) {
    list.add(new byte[1024]); // 到后期 ArrayList 内部数组扩容时 OOM
}

// 场景 2:大文件一次性读入
byte[] data = Files.readAllBytes(Paths.get("bigfile.dat"));
// 文件 3GB → byte[3221225472] → 超过 Integer.MAX_VALUE → 直接 OOM

// 场景 3:集合转数组
List<String> hugeList = ...; // 30 亿条数据
String[] arr = hugeList.toArray(new String[0]); // 内部 new String[size] 溢出
Java 数组上限:理论最大长度 = Integer.MAX_VALUE - 8 = 2,147,483,639
对于 byte[]:最大约 2GB。对于 Object[](64 位引用):最大约 16GB(但堆通常没这么大)。
修复方案

核心思路:分块处理,不要试图把大数据装进单个数组
① 文件处理:用流式读取(BufferedReader / FileChannel),不要 readAllBytes
② 集合处理:分批查询、分页处理,每批控制在合理大小
③ 大数据场景:使用内存映射文件(MappedByteBuffer)或外部排序

第 8 站

总结:OOM 诊断全流程图与速查手册

下面这张流程图,建议打印出来贴在工位旁边:

OOM 发生!先看错误消息 错误后缀是什么? Java heap space Metaspace 类加载过多 Direct buffer 堆外内存超限 native thread 线程数超限 GC overhead GC 耗尽 Array too large 数组 > 2GB jmap + MAT 分析堆转储 jcmd classloader 查类加载统计 NMT + JMX 查堆外内存用量 jstack 统计线程 查 ulimit -u GC 日志分析 本质是内存泄漏 改为流式/分块 处理大数据 调大 -Xmx + 修复内存泄漏 MaxMetaspaceSize + 修复类加载泄漏 MaxDirectMemory + 检查 ByteBuf 泄漏 减少线程数 + 用线程池管理 修复内存泄漏 (同 heap space) 分块处理 避免单个大数组 通用预防措施 ① 生产必加 -XX:+HeapDumpOnOutOfMemoryError ② 配置 GC 日志 + 监控告警 ③ 容器环境预留堆外内存空间(容器内存 > 堆 + Metaspace + 线程栈 + 堆外) ④ 定期压测 + 内存基线
图 2 OOM 诊断全流程图:从错误消息到诊断工具到修复方案,一图搞定

最后,把所有 OOM 类型汇总成速查表,面试前过一遍:

OOM 类型常见原因诊断工具修复方案
Java heap space 内存泄漏、一次性加载太多数据 jmap + MAT 调大 -Xmx(临时)+ 修复泄漏
Metaspace 动态类生成、ClassLoader 泄漏 jcmd classloader.stats MaxMetaspaceSize + 类卸载
Direct buffer NIO/Netty 堆外内存超限 NMT + JMX BufferPool MaxDirectMemorySize + 检查泄漏
native thread 线程数过多、线程泄漏 jstack + ulimit -u 线程池 + 减小 -Xss
GC overhead limit 内存泄漏导致 GC 无效循环 GC 日志 + MAT 修复泄漏(禁用检查只是临时)
Array too large 单数组超过 2GB 限制 堆栈信息定位 流式/分块处理
面试满分答案模板

面试官问"你怎么排查 OOM"时,用这个结构回答:
① 先看错误消息后缀,确定 OOM 类型
② 根据类型选择诊断工具(堆 → jmap/MAT,线程 → jstack,类 → jcmd classloader)
③ 找到根因:是配置问题(调参数)还是代码问题(改代码)
④ 预防措施:HeapDump、GC 日志、监控告警三板斧
能举出自己排查过的真实案例,加分。

核心记忆点:OOM 有 6 种,但排查套路只有 1 套——看错误消息 → 选诊断工具 → 找根因 → 修复 + 预防。生产环境永远加 -XX:+HeapDumpOnOutOfMemoryError,这是你在事故发生后唯一能依赖的"黑匣子"。