有一次版本上线后,凌晨两点线上报警:服务 OOM 崩了。日志里只有一行
java.lang.OutOfMemoryError: Java heap space,没堆栈、没现场。爬起来连上服务器,翻 GC 日志、抓对象直方图,折腾到天亮,最后定位到一个静态缓存——只进不出,对象全被它拽着,GC 收不掉,内存就这么被慢慢占满。那次之后我才把 OOM 从头梳理了一遍:
Java heap space只是最表层的一个信号,堆满、GC 空转、类太多、线程耗尽、堆外内存爆掉……每种原因不同,排查路径也完全不同。这篇就把这些类型和排查方法整理出来,每个示例我都实际跑过。
一、解决什么问题:OOM 不是"内存不够"那么简单
OutOfMemoryError 是 Error 不是 Exception——它表示 JVM 已经"撑不住了",一般 catch 不住、也不该 catch(catch 了大概率继续 OOM,还不如让它崩了出 dump)。
关键认知:OOM 有好几种,信号完全不同。看到 Java heap space 和看到 unable to create new native thread,背后原因八竿子打不着——前者是堆满了,后者是线程耗尽了。所以排查第一步不是"看代码哪里 new 多了",而是先判断"哪块内存满了"。内存分区本身见《JVM 内存模型与垃圾回收》。
二、OOM 的类型:先分清"哪块内存满了"
| 类型 | 哪块内存 | 典型信号 | 常见原因 |
|---|---|---|---|
① Java heap space | 堆 | 对象太多/太大 | 集合无界增长、缓存不清理、大对象 |
② GC overhead limit exceeded | 堆 | GC 空转 | 堆快满时 GC 拼命回收却收不回来 |
③ Metaspace | 元空间 | 类太多 | 动态代理/反射生成类、热部署不卸载 |
④ unable to create native thread | 系统线程 | 线程耗尽 | 线程池无限、连接池爆、无限起线程 |
⑤ Direct buffer memory | 堆外直接内存 | NIO 堆外分配超限 | ByteBuffer 只分不释放 |
⑥ StackOverflowError | 栈 | 无限递归 | 递归没出口(不是 OOM,但常一起问) |
① Java heap space:堆满了(最常见)
new 的对象都在堆上,堆的容量由 -Xmx 控制。只增不删就会堆满:
import java.util.ArrayList;
import java.util.List;
public class OomHeap {
public static void main(String[] args) {
List<byte[]> list = new ArrayList<>();
int i = 0;
while (true) {
list.add(new byte[1024 * 1024]); // 每次塞 1MB,只增不删
}
}
}实测(java -Xmx64m OomHeap,JVM 实际用不满 64m——GC 到 31MB 就顶不住了):
已分配 31 MB
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at OomHeap.main(OomHeap.java:9)② GC overhead limit exceeded:GC 空转
堆快满时 GC 反复回收,但回收率低于 2% 且耗时超过 98%,JVM 判断"再收也是白收"直接抛错。触发条件比较苛刻——堆里得大部分是回收不掉的活对象。字符串池(intern)里的对象就永生,是经典触发源:
import java.util.ArrayList;
import java.util.List;
public class OomGcOverhead {
public static void main(String[] args) {
List<String> list = new ArrayList<>();
int i = 0;
while (true) {
list.add(("str" + i++).intern()); // 字符串池永生对象,GC 几乎回收不掉
}
}
}实测(java -XX:+UseParallelGC -Xmx32m OomGcOverhead):
Exception in thread "main" java.lang.OutOfMemoryError: GC overhead limit exceeded细节:默认 G1 收集器下同样的代码直接抛
Java heap space,GC overhead limit exceeded是在 Parallel GC 下触发的——不同收集器对"GC 空转"的判定时机不同,排障时别死磕这一条。
③ Metaspace:类太多
JDK 8 起方法区用 元空间(Metaspace),装类信息。它由 -XX:MaxMetaspaceSize 限制(默认不设上限)。类本身太多就会爆——动态代理、反射生成类、热部署反复加载不卸载,都是源头:
import java.lang.reflect.Proxy;
import java.util.ArrayList;
import java.util.List;
public class OomMetaspace {
public interface Service { void run(); }
public static void main(String[] args) {
List<Object> keep = new ArrayList<>(); // 强引用防止代理类被 GC 卸载
int i = 0;
while (true) {
ClassLoader cl = new ClassLoader() {}; // 每次新类加载器 → 每次都生成新代理类
Service proxy = (Service) Proxy.newProxyInstance(cl,
new Class<?>[]{Service.class}, (p, m, a) -> null);
keep.add(proxy);
}
}
}实测(java -XX:MaxMetaspaceSize=64m OomMetaspace):
Exception in thread "main" java.lang.OutOfMemoryError: Metaspace
at java.base/jdk.internal.loader.AbstractClassLoaderValue.computeIfAbsent(Unknown Source)
at java.base/java.lang.reflect.Proxy.newProxyInstance(Unknown Source)
at OomMetaspace.main(OomMetaspace.java:13)生成类的方式不止动态代理——反射详解、动态代理都在运行时产生类,配合热部署框架(每次重启加载新类加载器不卸载)最容易踩这条。
④ unable to create native thread:线程耗尽
这是系统级的:进程能创建的线程数有上限(受系统资源、栈大小、进程限制约束)。无限起线程就会打爆:
public class OomThread {
public static void main(String[] args) {
int i = 0;
while (true) {
new Thread(() -> {
try { Thread.sleep(60000); } catch (InterruptedException e) {}
}).start();
}
}
}实测:Windows 本地跑,创建到 10 万+ 个线程还没触发(64 位进程虚拟地址空间大,线程栈是 reserved 不立即 commit,线程"很便宜");放到 Linux 容器用 ulimit -u 64 限制线程数,0.037 秒、第 8 个线程就爆:
[0.037s][warning][os,thread] Failed to start the native thread for java.lang.Thread "Thread-8"
Exception in thread "main" java.lang.OutOfMemoryError: unable to create native thread: possibly out of memory or process/resource limits reached
at java.base/java.lang.Thread.start0(Native Method)
at java.base/java.lang.Thread.start(Thread.java:1526)生产环境不会是裸奔 new Thread,而是线程池/连接池配置错误(池大小无上限、任务无限积压排队),排查重点看 Java 并发编程里的线程池参数。
⑤ Direct buffer memory:堆外内存
NIO(见《Java NIO 详解》)的 ByteBuffer.allocateDirect 分配在堆外,不受 -Xmx 管,由 -XX:MaxDirectMemorySize 限制(默认等于堆上限)。它不经过 GC 管理(靠 Cleaner 释放),只分不放就爆:
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.List;
public class OomDirect {
public static void main(String[] args) {
List<ByteBuffer> keep = new ArrayList<>(); // 强引用防止 GC 回收直接内存
int i = 0;
while (true) {
keep.add(ByteBuffer.allocateDirect(1024 * 1024)); // 每次 1MB 堆外
}
}
}实测(java -XX:MaxDirectMemorySize=32m OomDirect):
Exception in thread "main" java.lang.OutOfMemoryError: Cannot reserve 1048576 bytes of direct buffer memory (allocated: 33554432, limit: 33554432)细节:
allocated: 33554432= 32MB,正好等于limit——JVM 在错误信息里直接告诉你了"分到上限了"。这类问题堆里查不到,得查 NIO 调用点(Netty 的堆外 Buffer、文件映射)。
⑥ StackOverflowError:栈溢出
严格说不是 OOM,但面试总跟 OOM 一起问。方法调用在栈上,栈帧有深度上限(默认 -Xss1m),无限递归就爆:
public class StackOverflow {
static int depth = 0;
static void recurse() {
depth++;
recurse(); // 无限递归,没有出口
}
public static void main(String[] args) {
try {
recurse();
} catch (StackOverflowError e) {
System.out.println("栈溢出,递归深度约 " + depth);
}
}
}实测(java StackOverflow):
栈溢出,递归深度约 22482三、怎么定位:工具链 + 决策流程
前置动作(最重要,出事前配好):JVM 启动参数加上这两行,OOM 时自动把现场留下来:
java -Xmx512m \
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/logs/dump/ \
-Xlog:gc* \
-jar app.jarHeapDumpOnOutOfMemoryError:OOM 瞬间自动导出堆快照(.hprof)-Xlog:gc*:GC 日志,看"崩之前 GC 是不是已经在频繁 Full GC"
现场工具(对运行中的进程):
| 工具 | 命令 | 看什么 |
|---|---|---|
| jstat | jstat -gcutil <pid> 1s | GC 频率、各代占用曲线 |
| jmap | jmap -histo <pid> | 堆里对象实例数 TOP(快速定位大头) |
| jmap | jmap -dump:format=b,file=x.hprof <pid> | 手动导出堆快照 |
| jstack | jstack <pid> | 线程栈:数线程、找死锁/卡住的点 |
| jps | jps -l | 列出 JVM 进程(拿真实 PID) |
| MAT / VisualVM | 分析 .hprof | 从 GC Roots 找泄漏路径 |
拿到 OOM 类型后的决策流程:
- heap / GC overhead → 分析堆:
jmap -histo看什么对象多 → dump 出来用 MAT 找"谁引用着它不放手"(GC Roots 路径) - Metaspace →
jmap -clstats <pid>看加载的类 → 查动态代理/热部署/反射生成类的地方 - native thread →
jstack数线程、看线程名 → 查线程池/连接池配置 - Direct buffer → 查 NIO 调用点(Netty、文件映射、
allocateDirect)
四、实战:一次"静态缓存泄漏"的 Java heap space
凌晨那次排查,最后定位到的就是这种情况——静态 Map 做缓存,只增不删。它的代码长这样:
import java.util.HashMap;
import java.util.Map;
public class StaticCache {
// 静态 Map 模拟无界缓存:只增不删 → 老年代持续上涨 → 最终 Java heap space
static Map<String, byte[]> CACHE = new HashMap<>();
static int i = 0;
public static void main(String[] args) throws Exception {
while (true) {
CACHE.put("key" + i++, new byte[512 * 1024]); // 每个 512KB,被静态引用永不释放
Thread.sleep(10);
}
}
}现象(java -Xmx512m StaticCache):老年代缓慢上涨 → Full GC 越来越频繁 → 最终:
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space定位:光看报错只知道"堆满了",不知道谁撑的。jmap -histo 看对象直方图(分配进行中抓的):
num #instances #bytes class name
-------------------------------------------------------
1: 3680 276996760 [B ← byte[] 数组:277MB!
2: 1409 45088 java.util.HashMap$Node ← HashMap 条目
3: 715 89408 java.lang.Class[B(byte[])3680 个实例占了 277MB——整个堆就它最大,而 HashMap$Node 才 1409 个。两个一拼:缓存 Map 里塞了一大堆 512KB 的 byte[],就是 StaticCache 里 CACHE.put 的货。再顺着引用链(谁引用着这个 Map)就定位到 StaticCache.CACHE。
修复:缓存必须有界——加容量上限(满了淘汰)、定期清理、或者用软引用(SoftReference,内存紧张时 GC 自动回收):
// 修复方向 1:容量上限 + 淘汰
static Map<String, byte[]> CACHE = new LinkedHashMap<>(128, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<String, byte[]> eldest) {
return size() > 128; // 超过 128 条淘汰最老的
}
};// 修复方向 2:软引用——内存紧张时 GC 自动回收缓存
static Map<String, SoftReference<byte[]>> CACHE = new ConcurrentHashMap<>();
// 取用时:SoftReference.get() 返回 null 说明已被回收,重新构建五、预防:别等崩了再查
- 启动参数:合理设置
-Xmx(不是越大越好,太大反而拖慢 Full GC;按业务压测定)、HeapDumpOnOutOfMemoryError必开、GC 日志必开 - 代码习惯:
- 集合用完清理、流(IO/NIO)用 try-with-resources 关闭
ThreadLocal用完remove()(线程池里线程复用,不清理会串数据还泄漏)- 缓存一律有界(容量上限/软引用)
- 线程池/连接池永远设上限,别用"无界队列"
- 监控告警:堆使用率、Full GC 频率、线程数、Metaspace 使用率——这些指标在崩溃前就有明显趋势,比等 OOM 日志早一步
小结
- 先分类型再动手:heap / GC overhead → 堆;Metaspace → 类;thread → 线程;Direct → 堆外;栈溢出 → 递归
- 排查链:OOM 日志 →
jmap -histo找大头对象 → dump + MAT 找引用链 → 修复 + 验证 - 预防 > 排查:dump 开关必开、缓存有界、池子上限、监控趋势
内存分区与 GC 原理见《JVM 内存模型与垃圾回收》;线程池/ThreadLocal 相关的泄漏点见《Java 并发编程》;集合类容量的正确打开方式见《Java 集合框架详解》。
验证说明:六种类型 + 实战案例全部实跑——①
-Xmx64m31MB 爆 heap space;② Parallel GC 下GC overhead limit exceeded(G1 下同代码是 heap space,已注明);③MaxMetaspaceSize=64m动态代理生成类爆 Metaspace;④ Windows 本地 10 万+ 线程未触发(已注明),Linux 容器ulimit -u 64下 0.037s 第 8 线程爆;⑤MaxDirectMemorySize=32m爆 direct buffer(错误信息 allocated=limit=32MB);⑥ 无限递归深度 22482;案例 StaticCache 复现 heap space +jmap -histo实测 byte[] 277MB 占大头。
