Lesson 37 · JVM 原理与调优
OOM 排查实战:六种 OutOfMemoryError 全解
凌晨 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 exceeded | 堆 | GC 忙死了但回收不了多少 |
| Map/Array too large | 堆 | 单个数组/集合超过 2GB 上限 |
面试官问 OOM,不是在考你能不能背出 6 种类型。他想知道:你有没有真的排查过?给你一个日志片段,你能不能 30 秒内定位方向?这篇文章把每种 OOM 的"案发现场→诊断手段→修复方案"全链路走一遍。
Java heap space:最常见的 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)
根因只有两种:要么堆给小了(配置问题),要么对象太多了(内存泄漏)。
诊断四步法:生产环境一定要先加这两个 JVM 参数,OOM 时自动留"遗照":
# 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)打开:
- 点击 Leak Suspects Report——MAT 自动分析最大嫌疑对象
- 看 Dominator Tree——按 retained size 排序,找到占内存最大的对象
- 点 Path to GC Roots——看谁持有了这些对象,阻止了回收
// 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 只是给你争取排查时间。
Metaspace:类太多,元空间爆了
Java 8 之后,PermGen 被 Metaspace 取代。Metaspace 存的是类的元数据(Class 对象、方法字节码、常量池),默认使用本地内存(Native Memory),不设上限——这意味着如果不设限制,它可以吃光你整台机器的内存。
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 ← 问题在这!
三种典型场景:
- 动态代码生成:CGLIB、Javassist、Groovy 脚本引擎不断生成新类,旧类不被卸载
- ClassLoader 泄漏:热部署场景下,旧 ClassLoader 没有被 GC 回收,它加载的类一直留在 Metaspace
- 超大项目 + 大量依赖: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 需要显式开启)
Direct buffer memory:堆外内存的隐形杀手
使用 NIO 的 ByteBuffer.allocateDirect() 分配的内存在堆外(Native Memory),不受 -Xmx 控制,而是由 -XX:MaxDirectMemorySize 限制(默认等于 -Xmx 的值)。
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 使用量 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、线程栈——很可能不够。
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)释放内存空间
GC overhead limit exceeded:JVM 在喊"我快累死了"
这个 OOM 比较特殊——它不是因为内存真的用光了,而是因为 JVM 花了超过 98% 的时间在做 GC,但每次只回收到不到 2% 的堆空间。换句话说,JVM 觉得自己已经"生不如死"了,选择自杀式退出。
// GC 日志(OOM 前 10 分钟) [12.5s] GC pause (G1 Evacuation Pause) 45MB→42MB 12ms [12.8s] GC pause (G1 Evacuation Pause) 44MB→40MB 28ms [13.0s] GC pause (G1 Evacuation Pause) 43MB→39MB 85ms ← GC 时间飙升 [13.1s] GC pause (G1 Evacuation Pause) 42MB→38MB 320ms ← 回收越来越少 [13.2s] GC pause (G1 Evacuation Pause) 41MB→39MB 1200ms ← 一次 GC 1.2 秒! [13.3s] GC pause (Full GC) 40MB→39MB 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 才发现。
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] 溢出
Integer.MAX_VALUE - 8 = 2,147,483,639对于
byte[]:最大约 2GB。对于 Object[](64 位引用):最大约 16GB(但堆通常没这么大)。
核心思路:分块处理,不要试图把大数据装进单个数组。
① 文件处理:用流式读取(BufferedReader / FileChannel),不要 readAllBytes
② 集合处理:分批查询、分页处理,每批控制在合理大小
③ 大数据场景:使用内存映射文件(MappedByteBuffer)或外部排序
总结: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 日志、监控告警三板斧
能举出自己排查过的真实案例,加分。
-XX:+HeapDumpOnOutOfMemoryError,这是你在事故发生后唯一能依赖的"黑匣子"。