排查 OOM 不知道哪个区爆了?JVM 内存模型全景图,从根讲清
文章目录
排查 OOM 不知道哪个区爆了?JVM 内存模型全景图,从根讲清
你写了一段递归,啪一下 StackOverflowError。你又开了一堆线程,啪一下 OOM: unable to create new native thread。然后你懵了:同样是栈相关,凭什么一个叫 StackOverflow,一个叫 OOM?
JVM 内存模型这块,面试必问,排查线上问题更离不开。但如果只看零散的文章,很容易只见树木不见森林——知道堆存对象、栈存引用,但你让我画一张从堆到栈到方法区到直接内存的全景图,我画不出来。
这一篇,从根上捋干净。
一、全景图:你写的代码到底在 JVM 哪个角落
┌──────────────────────────────────────────┐
│ JVM 进程 │
│ │
│ ┌──────────┐ 线程共享 │
│ │ 堆 │ 全部对象实例、数组 │
│ │ (Heap) │ String 常量池(JDK 7+) │
│ └──────────┘ │
│ │
│ ┌──────────┐ 线程共享 │
│ │ 元空间 │ 类元数据、方法字节码 │
│ │(Metaspc) │ 运行时常量池、JIT 缓存 │
│ └──────────┘ │
│ │
│ ┌──────────┐ │
│ │ 直接内存 │ NIO 堆外分配 │
│ └──────────┘ │
│ │
│ ┌────┐┌────┐┌────┐ 线程私有 │
│ │栈1 ││栈2 ││栈3 │ JVM 栈 + 本地方法栈 │
│ └────┘└────┘└────┘ │
│ ┌────┐┌────┐┌────┐ │
│ │PC 1││PC 2││PC 3│ 程序计数器 │
│ └────┘└────┘└────┘ │
└──────────────────────────────────────────┘
先记住一张大表,之后每一块我们展开讲:
| 区域 | 线程共享? | 存什么 | 异常 |
|---|---|---|---|
| 堆 | 共享 | 对象实例、数组 | OOM: Java heap space |
| 元空间 | 共享 | 类元数据、方法字节码、常量池、JIT 代码缓存 | OOM: Metaspace |
| 直接内存 | 共享 | NIO 堆外 buffer | OOM: Direct buffer memory |
| JVM 栈 | 私有(每线程) | 栈帧(局部变量表、操作数栈、返回地址) | StackOverflowError / OOM |
| 本地方法栈 | 私有 | native 方法调用的栈帧 | StackOverflowError / OOM |
| 程序计数器 | 私有 | 当前线程执行的字节码指令地址 | 不会 OOM(唯一一个) |
二、堆:对象的大本营
所有
new出来的对象、数组,都在堆里。private、static只是访问权限,改不了对象放哪。
2.1 堆的分代结构
┌──────────────────────────────────────┐
│ 堆 (Heap) │
│ ┌─────────────────────────────────┐ │
│ │ 新生代 (Young) │ │
│ │ ┌──────┐┌──────┐┌──────┐ │ │
│ │ │ Eden ││ S0 ││ S1 │ │ │
│ │ │ 8 ││ 1 ││ 1 │ │ │
│ │ └──────┘└──────┘└──────┘ │ │
│ └─────────────────────────────────┘ │
│ ┌─────────────────────────────────┐ │
│ │ 老年代 (Old) │ │
│ │ 长期存活对象 / 大对象 │ │
│ └─────────────────────────────────┘ │
└──────────────────────────────────────┘
新生代:Eden + 两个 Survivor(S0、S1),默认比例 8:1:1。绝大多数新对象在 Eden 诞生。
老年代:熬过多次 Minor GC 的对象,或者大对象直接分配到这里。
2.2 Minor GC 到底干了什么
第一次 Minor GC:
Eden(满了) + S0(当前在用) → 活下来的对象拷到 S1 → 清空 Eden 和 S0
第二次 Minor GC:
Eden(又满了) + S1(当前在用) → 活下来的对象拷到 S0 → 清空 Eden 和 S1
...S0 和 S1 永远有一个是空的,轮流当"接收方"
每次在 Survivor 之间拷贝,对象头里的 GC 年龄 +1。年龄默认达到 15 就晋升老年代。
但实际很多对象到不了 15。如果 Survivor 中同龄对象总大小超过 Survivor 的 50%,这批对象连带年龄更大的全部提前晋升——这叫动态年龄判断。
2.3 大对象直接进老年代
一个大数组:
byte[] big = new byte[10 * 1024 * 1024]; // 10MB
新生代空间不够它来回拷贝,所以直接分配进老年代。不同 GC 判断标准不同:
| GC | 大对象阈值 |
|---|---|
| Serial / Parallel | -XX:PretenureSizeThreshold(默认 0,不启用) |
| G1 | 超过 Region 大小的一半 |
| ZGC | 不分大小,全用 Region |
2.4 新生代 GC vs 老年代 GC
| Minor GC(新生代) | Major/Full GC(老年代) | |
|---|---|---|
| 触发 | Eden 满了 | 老年代空间不足 / System.gc() |
| 频率 | 高 | 低 |
| 速度 | 快(几十毫秒) | 慢(几百毫秒到秒级) |
| 算法 | 复制算法(存活少) | 标记-整理 / 标记-清除(存活多) |
算法不同是因为存活率不同。新生代 98% 的对象朝生夕死,拷走少数活的清空全场就行。老年代大多数对象还活着,不能拷——只能标出垃圾再整理碎片。
三、栈:方法的流水线
3.1 一个线程一个栈,一个方法一个栈帧
public void foo() {
MyObject obj = new MyObject(); // obj(引用)在栈,new出来的本体在堆
int x = 3; // x 在栈,值直接存
bar(); // 调用 bar → 压新栈帧
}
每个线程的栈里是栈帧的叠放。一个栈帧包含:
| 栈帧组成 | 存什么 |
|---|---|
| 局部变量表 | 方法参数、局部变量(引用类型存指针,基本类型存值) |
| 操作数栈 | 字节码指令执行的中间结果 |
| 动态链接 | 指向运行时常量池中该方法的符号引用 |
| 返回地址 | 方法结束后回到哪 |
栈里存的永远是指针/基本类型值,对象本体只在堆。
3.2 JVM 栈 vs 本地方法栈
| 栈 | 服务于 |
|---|---|
| Java 虚拟机栈 | Java 方法调用 |
| 本地方法栈 | native 方法(C/C++ 写的)调用 |
HotSpot 里这两个合二为一。日常说"方法栈"默认就是 JVM 栈。
3.3 递归 vs 迭代:栈溢出的根源
// 递归 — 每调一次压一个栈帧,10万层 = 10万个栈帧
public void traverse(TreeNode node) {
if (node == null) return;
traverse(node.left); // 压栈
traverse(node.right); // 再压
}
// → StackOverflowError
// 迭代 — 只有一个栈帧,手动维护堆上的 Deque
public void traverse(TreeNode root) {
Deque<TreeNode> stack = new ArrayDeque<>(); // 这个在堆里,大小弹性
stack.push(root);
while (!stack.isEmpty()) {
TreeNode node = stack.pop();
if (node.right != null) stack.push(node.right);
if (node.left != null) stack.push(node.left);
}
}
// 不管树多深,只有一个栈帧 + 堆里一个 Deque
迭代的本质:把栈从"方法调用栈"搬到"堆上的 Stack 数据结构"。限制从栈的 ~1MB 变成堆的几 GB。
3.4 StackOverflowError vs OOM: unable to create new native thread
这两个容易搞混,本质不同:
| StackOverflowError | OOM: unable to create new native thread | |
|---|---|---|
| 根因 | 一个栈的深度超了 | 栈的数量太多 |
| 场景 | 无限递归、树太深 | 无限制 new Thread() |
| 堆栈 | 一个线程的 JVM 栈被打爆 | OS 不给新线程分配栈空间了 |
// 场景一:一个线程递归太深 → StackOverflowError
public void f() { f(); }
// 场景二:创建 10 万个线程 → OOM
// 100000 × 默认 1MB 栈 = ~100GB,操作系统扛不住
for (int i = 0; i < 100000; i++) {
new Thread(() -> sleep(999999)).start();
}
四、方法区 / 元空间:类的档案馆
方法区是 JVM 规范的概念,元空间是 HotSpot 的实现(JDK 8+)。同一回事。
4.1 方法区存什么
| 内容 | 解释 |
|---|---|
| 类元数据 | 类名、父类、接口、字段、访问修饰符 |
| 方法字节码 | 每个方法的字节码指令序列(aload_0、invokevirtual…) |
| 静态变量 | static 字段,<clinit> 时赋值 |
| 运行时常量池 | 符号引用 + 字面量,运行时还能动态加 |
| JIT 代码缓存 | 热点方法编译后的本地机器码(CodeCache) |
4.2 运行时常量池和符号引用
class 文件常量池在类加载后被搬进方法区,形成运行时常量池。
指令里的操作数是编号,不是地址:
invokevirtual #6 ← #6 是谁?指令自己不知道
执行时去常量池查 #6:
解析前:常量池 #6 → 符号引用 "Foo.getName:()V"
解析后:常量池 #6 → 直接引用 0x7f8a1c...(方法实际地址)
解析就是常量池条目的"原地替换"——符号引用变为直接引用。指令不变,查表时拿到的东西变了。
这个过程叫晚期解析——第一次用到才解析,解析完了缓存,之后直接用。不用的方法永远不会被解析。
4.3 类加载全过程
加载 → 验证 → 准备 → 解析 → 初始化
| 步骤 | 干什么 |
|---|---|
| 加载 | 读 .class → 生成 Class 对象放元空间 |
| 验证 | 检查魔数、版本号、字节码安全 |
| 准备 | 静态变量分配内存、赋零值(static int a = 3 此时 a = 0) |
| 解析 | 常量池符号引用 → 直接引用 |
| 初始化 | 执行 <clinit>:静态赋值、静态代码块才真正生效 |
验证、准备、解析三个阶段合称链接。解析可以懒执行(晚期解析),其余顺序严格保证。
4.4 元空间溢出的底层原因
每次 CGLIB 生成代理类,元空间就多一个 Klass 结构:
for (int i = 0; i < 100000; i++) {
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(User.class);
enhancer.create(); // 每次生成新类:User$$EnhancerByCGLIB$$xxxx
}
// → OOM: Metaspace
释放链路:
ClassLoader 被 GC → 它加载的类的 Klass 标记可回收 → Full GC 清理元空间
如果 ClassLoader 一直没被 GC(比如被线程池复用的线程引用着),那它对应的元空间永远不释放——这就是 Metaspace OOM 的根因。
五、直接内存:堆外的世界
5.1 为什么需要它
普通 I/O(两次拷贝):
磁盘 → OS 缓冲区 → 拷进堆内 ByteBuffer → 再拷回 OS → 网卡
直接内存(一次拷贝):
磁盘 → 直接内存 → 网卡
少一次 CPU 搬运,大数据场景差距明显。Netty、Kafka 大量用直接内存就是为了这个。
5.2 壳对象 + 虚引用 + Cleaner 的三件套
ByteBuffer buf = ByteBuffer.allocateDirect(100 * 1024 * 1024);
这一行做了两件事:
1. malloc(100MB) → 在堆外分配 100MB
2. new DirectByteBuffer() → 在堆里创建一个几十字节的"壳对象",里面存着堆外地址
堆里 堆外
┌───────────┐ ┌──────────────┐
│ DirectBuf │──指针──────→ │ 100MB 数据区 │
│ (几十字节) │ │ (malloc 分配) │
└───────────┘ └──────────────┘
↑ ↑
GC 管这个壳 壳死了才释放
回收不靠 GC 直接扫堆外——靠的是壳对象的虚引用:
1. buf = null → 壳失去强引用
2. GC 发现壳只有虚引用指着 → 壳被回收
3. Cleaner(虚引用的子类)被触发 → 调用 unsafe.freeMemory() 释放堆外内存
这就是为什么 PhantomReference 几乎是专为堆外内存清理而生的。
5.3 堆外 OOM 为什么难排查
List<ByteBuf> list = new ArrayList<>();
while (true) {
list.add(Unpooled.directBuffer(10 * 1024 * 1024));
}
// 堆里看着没啥对象,GC 不触发
// 堆外一直在分配 → OOM: Direct buffer memory
壳对象太小,堆看着好好的,GC 懒得动。但壳如果晋升老年代,老年代 GC 频率更低,堆外内存迟迟不释放。jmap、jstat 都看不到堆外占用——这是最难排查的 OOM 之一。
六、引用类型:强、软、弱、虚
强 > 软 > 弱 > 虚
| 类型 | 回收时机 | 典型用途 |
|---|---|---|
| 强引用 | 绝不回收 | 所有普通的 = 赋值 |
软引用 SoftReference |
OOM 前回收 | 图片缓存、页面缓存 |
弱引用 WeakReference |
下次 GC 必死 | WeakHashMap、ThreadLocal |
虚引用 PhantomReference |
GC 时回收 + 入队通知 | 管理堆外内存(DirectByteBuffer) |
// 强引用
Object obj = new Object(); // 只要 obj 还指着,对象就活着
// 软引用 — 内存紧张才回收
SoftReference<byte[]> cache = new SoftReference<>(new byte[10 * 1024 * 1024]);
// 弱引用 — GC 必死
WeakReference<Object> weak = new WeakReference<>(new Object());
// 虚引用 — get() 永远返回 null,纯通知机制
ReferenceQueue<Object> queue = new ReferenceQueue<>();
PhantomReference<Object> phantom = new PhantomReference<>(new Object(), queue);
实际开发中你很少直接 new 这些引用类,它们更多是库作者的工具——WeakHashMap 内部用弱引用,DirectByteBuffer 内部用虚引用。
七、内存泄漏:慢性病
内存泄漏的对象在堆里正常分配、正常流转(Eden → Survivor → 老年代),区别在于GC 根可达性分析时判定它们"还活着"——因为有个长命鬼还在指着它。
7.1 静态集合
private static final List<User> cache = new ArrayList<>(); // 与类同生命周期
public void add(User u) { cache.add(u); }
// User 永远回收不了,直到 JVM 退出
cache.clear(); // ← 解决:定期清理或用 WeakReference 包裹
7.2 事件监听忘了解绑
public class EventSource {
private List<Listener> listeners = new ArrayList<>();
public void register(Listener l) { listeners.add(l); }
// ← 没有 unregister()!
}
// 观察者活着 → listeners 活着 → 所有 Listener 活着 → Listener 引用的对象都泄漏
解决:
addListener必须配removeListener,谁注册谁负责解绑。
7.3 ThreadLocal 的经典大坑
static class Entry extends WeakReference<ThreadLocal<?>> {
Object value; // ← value 是强引用!
}
- key(ThreadLocal)是弱引用 → GC 一来就回收
- value(你存的对象)是强引用 → 线程活着它就永远活着
ThreadLocal<User> tl = new ThreadLocal<>();
tl.set(new User());
// 忘了 tl.remove()
// → ThreadLocal 被 GC 了,key 变成 null
// → 但 value(User 对象) 被 Entry 强引用着,线程活着就回收不了
// 正确写法
try {
threadLocal.set(value);
// do work
} finally {
threadLocal.remove(); // 必须在 finally 里清
}
7.4 内部类持有外部类引用
public class Activity {
private User user = new User();
class MyHandler { // 非静态内部类 → 隐式持有 Activity.this
void handle() { user.doSomething(); }
}
}
// 如果 MyHandler 被线程持有 → Activity 整个泄漏
// 解决:改成静态内部类 + WeakReference<Activity>
八、内存溢出:骆驼背上的最后一根稻草
8.1 各区域 OOM 速查表
| OOM 信息 | 哪个区 | 常见原因 |
|---|---|---|
Java heap space |
堆 | 大对象分配 / 内存泄漏累积 / 堆太小 |
Metaspace |
元空间 | 动态生成类太多(CGLIB、lambda)/ ClassLoader 泄漏 |
Direct buffer memory |
直接内存 | 堆外无界分配 / 壳对象晋升老年代迟迟不回收 |
unable to create new native thread |
本地内存(栈分配) | 创建线程过多 |
StackOverflowError |
JVM 栈 | 无限递归 / 单方法栈帧过大 |
GC overhead limit exceeded |
堆(变种) | GC 占用 98% 时间却只回收了不到 2% 的内存 |
8.2 内存泄漏和内存溢出的关系
内存泄漏是量变,内存溢出是质变。泄漏攒够了就溢出。
泄漏导致的 OOM 有一个特征:重启就好了,过几天又炸。因为重启清空了泄漏累积,从零开始重新攒。其他原因(比如真的内存不够)通常一触发就到,跟运行多久无关。
8.3 排查三板斧
- dump 快照:
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=./heap.hprof - MAT / JProfiler 分析:看哪个类的实例数异常大,追 GC Root 引用链
- jstat -gc 实时监控:看各代容量、GC 频率、元空间使用量
九、总结
- 堆存所有对象,分新生代(Eden+S0/S1,比例 8:1:1)和老年代。Minor GC 用复制算法扫新生代,Full GC 用标记-整理收老年代。
- 栈存方法调用的栈帧,每个栈帧有局部变量表+操作数栈+返回地址。栈里只存指针和基本类型值,对象本体永远在堆。
- 方法区(元空间) 存五样东西:类元数据、方法字节码、运行时常量池、静态变量、JIT 代码缓存。符号引用在常量池中原地解析为直接引用。
- 直接内存在堆外,靠壳对象+虚引用+Cleaner 回收。零拷贝是价值,壳不灭导致堆外泄漏是最大的坑。
- 引用强度:强 > 软 > 弱 > 虚。软引用做缓存,弱引用防泄漏,虚引用管堆外。
- 内存泄漏是慢性病:static 集合、监听器未解绑、ThreadLocal 忘 remove。修泄漏比加内存靠谱一万倍。
- OOM 要分清楚哪个区爆了——堆、元空间、直接内存、线程过多,解决手段完全不同。
如果你觉得有用,点赞收藏,下次排查 OOM 直接翻这篇就够了。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)