同一个 OutOfMemoryError 可能写 Java heap space,也可能写 Metaspace,处理路径完全不同:前者查谁在持有对象,后者查谁在生成类。很多人看到 OOM 先加 -Xmx,加了没效果,因为满的根本不是堆。这篇先把五个运行时区域各自装什么讲清楚,再走一遍对象从 new 到回收的路径,最后用三个能亲手跑出来的报错把"区域—参数—报错"对上号。
1. 报错文本先定位区域
| 报错文本 | 区域 | 常见诱因 | 先看什么 |
|---|---|---|---|
Java heap space |
堆 | 大对象、集合无上限、缓存不淘汰 | 堆直方图、支配树 |
Metaspace |
方法区/元空间 | 动态生成类、类加载器泄漏 | 已加载类数、加载器占用 |
unable to create new native thread |
本机线程 | 线程池无界、每请求起线程 | ulimit -u、Threads 计数 |
Direct buffer memory |
堆外直接内存 | NIO allocateDirect、Netty |
-XX:MaxDirectMemorySize、NMT |
后两种跟 -Xmx 没关系。把 -Xmx 从 2G 调到 8G 对 unable to create new native thread 毫无帮助,有时更糟:堆越大,留给线程栈的地址空间越紧张。
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at java.base/java.util.Arrays.copyOf(Arrays.java:3537)
栈顶几帧只是最后一次分配失败的位置,不是根因。
2. 五个区域:谁共享、谁私有
| 区域 | 存什么 | 共享/私有 | 上限由谁控制 | 溢出表现 |
|---|---|---|---|---|
| 堆 | 对象实例、数组 | 全线程共享 | -Xmx/-Xms |
Java heap space |
| 虚拟机栈 | 栈帧:局部变量表、操作数栈、返回地址 | 线程私有 | -Xss |
StackOverflowError |
| 本地方法栈 | native 方法的调用帧 | 线程私有 | -Xss(HotSpot 与虚拟机栈合并) |
同上,或 native 崩溃 |
| 方法区 / 元空间 | 类元数据、字段方法描述、Class 对象 |
全线程共享 | -XX:MaxMetaspaceSize(默认无上限) |
Metaspace |
| 程序计数器 | 当前执行的字节码位置 | 线程私有 | 固定 | 不会 OOM |
两点容易记混:方法区是规范,元空间是实现,JDK 8 起永久代移除,元数据搬到本地内存,默认不受 -Xmx 约束;程序计数器是唯一不会 OOM 的区域,每线程一个,大小固定。
3. 对象的一生
路径:new → 栈上引用 → 堆上分配(可能先走 TLAB)→ 逃逸分析判断能否标量替换 → 可达性分析 → 回收。
// 逃逸分析:p 没有逃出方法,JIT 可把它拆成标量,甚至不分配
static int sum(int a, int b) {
Point p = new Point(a, b); // 未返回、未存字段、未对其他线程可见
return p.x + p.y;
}
逃逸分析的收益取决于 JIT 是否真做标量替换,加 -XX:+UnlockDiagnosticVMOptions -XX:+PrintEliminateAllocations 能看到结果,别凭想象断言"这对象在栈上"。
判断存活用可达性分析,不用引用计数(解决不了循环引用)。GC Roots 常见几类:
| GC Root 类别 | 例子 |
|---|---|
| 虚拟机栈中的局部变量 | 正在执行的方法里的引用 |
| 方法区中的静态字段 | static final Map CACHE |
| 方法区中的常量引用 | 常量池里的对象引用 |
| 本地方法栈中的 JNI 引用 | native 代码持有的句柄 |
| 活动线程、系统类加载器 | 线程本身及其加载的类 |
| 同步监视器持有的对象 | synchronized 的锁对象 |
"静态集合持有引用"之所以泄漏,是因为集合本身挂在 GC Root 上,里面元素都被判为存活——哪怕业务早就不用了。
4. 为什么分代
依据是对象寿命分布极不均匀:绝大多数对象创建后很快死亡(弱分代假说),少数活得非常久。
| 代 | 存放 | 回收算法 | 回收频率 |
|---|---|---|---|
| 新生代 Eden | 新对象 | 复制 | 高 |
| Survivor S0/S1 | 熬过一次 GC 的对象 | 复制 | 高 |
| 老年代 | 熬过多次 GC 的对象、大对象 | 标记-整理 / 标记-清除 | 低 |
不分代则每次 GC 扫全堆并搬运全部存活对象;分代后多数 GC 只扫一小块,成本随存活比例下降。
# 观察分代与晋升,JDK 17 统一日志
java -Xms512m -Xmx512m -Xlog:gc*,gc+heap=debug:file=/tmp/gc.log -jar app.jar
5. 栈溢出的两种成因
StackOverflowError 只有一种报错,成因有两种:
| 成因 | 现象 | 处理 |
|---|---|---|
| 递归过深或无终止条件 | 栈帧数量爆炸 | 改迭代、补终止条件、显式栈 |
| 单帧过大 | 局部变量表里塞了超大数组或大量局部变量 | 拆方法、把大对象挪出方法体 |
public class StackOverflow {
static int depth = 0;
static void dig() { depth++; dig(); } // 无终止条件
public static void main(String[] a) {
try { dig(); } catch (StackOverflowError e) { System.out.println("depth=" + depth); }
}
}
java -Xss256k StackOverflow.java # 默认 -Xss 通常 512k~1M,按 JDK/平台核对
depth 绝对值没意义(跟 -Xss、帧大小、内联都有关),有意义的是同一 -Xss 下改代码后的变化方向。
6. 元空间为什么会涨
元空间存类元数据,涨的来源三类:
- 动态生成类:
Proxy、CGLIB、字节码增强、脚本引擎(每次eval可能生成新类); - 反射与动态调用:
MethodHandle、LambdaMetafactory生成的隐藏类; - 类加载器泄漏:自定义 ClassLoader 被引用不放,它加载的所有类都无法卸载。
// 每次新建 ClassLoader 并持有它,元空间只涨不落
while (true) {
ClassLoader cl = new URLClassLoader(new URL[]{path}, null);
Class<?> c = cl.loadClass("com.example.Plugin");
HOLDER.put(c.getName() + System.nanoTime(), cl); // 静态 Map 一存就卸不掉
}
类卸载条件很苛刻:该类所有实例已回收、Class 对象无引用、加载它的 ClassLoader 已回收。三者缺一,元空间就不回落。同参数反复生成代理会命中缓存,真正涨的是不断变化的接口组合或新建的 ClassLoader。
7. 字符串常量池与 intern
String a = "abc"; // 常量池
String b = new String("abc"); // 堆上新对象
System.out.println(a == b); // false
System.out.println(a == b.intern()); // true
| 写法 | 对象在哪 | == 与字面量比较 |
|---|---|---|
"abc" |
字符串常量池 | 同一引用 |
new String("abc") |
堆 | false |
new String("abc").intern() |
返回常量池引用 | true |
"a" + "b" |
编译期折叠为 "ab" |
true |
变量拼接 s1 + s2 |
运行时 StringBuilder 产物在堆 |
false |
JDK 7 起常量池从永久代移到堆,-XX:StringTableSize 可调大以降低冲突。别把 intern() 当缓存:它只增不减,大量唯一字符串 intern 就是一次缓慢的堆泄漏。
8. 直接内存与堆外
ByteBuffer buf = ByteBuffer.allocateDirect(64 * 1024 * 1024); // 堆外 64MB
直接内存在堆外,句柄对象 DirectByteBuffer 在堆里,靠 Cleaner 在回收时释放。堆压力大、GC 不频繁时堆外反而先满。
- 上限:
-XX:MaxDirectMemorySize,不设时约等于-Xmx(按版本核对); - 观察:
jcmd <pid> VM.native_memory summary,需先开 NMT。
9. 最小配置示例
java -Xms2g -Xmx2g \
-XX:MaxMetaspaceSize=256m \
-XX:MaxDirectMemorySize=512m \
-Xss512k \
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/opt/dump \
-Xlog:gc*:file=/opt/logs/gc.log:time,uptime:filecount=5,filesize=20m \
-jar app.jar
| 参数 | 建议起点 | 说明 |
|---|---|---|
-Xms = -Xmx |
相等 | 避免运行期反复扩缩堆 |
-XX:MaxMetaspaceSize |
256m 起 | 不设即无上限,出问题时吃满机器 |
-Xss |
512k | 线程总量 = 线程数 × 栈大小 |
-XX:MaxDirectMemorySize |
按 NIO 用量 | 不设易误判"堆没满怎么还 OOM" |
10. 确认到底哪块满了
jcmd <pid> VM.native_memory summary # 需先开 NMT
jcmd <pid> GC.class_histogram # 类直方图,谁实例最多
jcmd <pid> GC.heap_info # 各代使用量
jcmd <pid> VM.metaspace # 元空间按类加载器拆分
VM.metaspace 按 ClassLoader 列占用,排元空间问题最直接。堆内用 GC.class_histogram 找大类,再靠堆转储看引用链。
11. 最小可跑示例:三个报错
public class HeapOOM {
public static void main(String[] a) {
var list = new java.util.ArrayList<byte[]>();
while (true) list.add(new byte[1024 * 1024]);
}
}
java -Xmx64m HeapOOM.java # Java heap space
java -Xss256k StackOverflow.java # StackOverflowError
java -XX:MaxMetaspaceSize=32m MetaOOM.java # Metaspace
三个报错文本不同,处理方向也不同。跑一遍比读十遍文档管用:第三个不会在 Full GC 后回落。
12. 排错表
| 现象 | 更可能的原因 | 处理 |
|---|---|---|
| 堆 OOM 但直方图无大对象 | 对象多而碎,或堆外被误读成堆 | GC.heap_info 与 NMT 交叉看 |
加了 -Xmx 仍 OOM |
满的是元空间、直接内存或线程数 | 按第 1 节报错文本定位区域 |
| 容器 OOMKilled 却无 Java 报错 | 容器限额小于 -Xmx,内核直接杀 |
堆上限给元空间与栈留余量 |
| 元空间只涨不落 | ClassLoader 被静态引用 | VM.metaspace 找占用方,断引用 |
| 返回后内存不释放 | ThreadLocal 未 remove,值被线程持有 |
在 finally 里 remove() |
StackOverflowError 停在框架深处 |
递归解析或循环依赖导致深度失控 | 抓栈看重复帧,改迭代 |
Direct buffer memory 但堆很空 |
堆外到上限,句柄回收滞后 | 设上限,或改池化 ByteBuffer |
13. 边界表
| 场景 | 该看什么 | 本文不覆盖 |
|---|---|---|
| GC 停顿长 | -Xlog:gc* 停顿分布 |
收集器内部实现 |
| 内存缓慢上涨 | 堆直方图 + 引用链 | 泄漏对象的业务含义 |
| 容器内存限额 | cgroup 指标 + -XX:MaxRAMPercentage |
编排层策略 |
| 线程数暴涨 | 线程栈 + 池配置 | 异步框架的调度模型 |
14. 自测
unable to create new native thread该调哪个参数?为什么调-Xmx反而可能更糟?- 元空间默认有没有上限?不设的话后果是什么?
- 为什么引用计数不能替代可达性分析?
-Xss调小后StackOverflowError来得更早,说明了什么?intern()为什么不适合当缓存?- 容器里进程被杀而 JVM 没打印 OOM,先比较哪两个值?