排查 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 出来的对象、数组,都在堆里。privatestatic 只是访问权限,改不了对象放哪。

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_0invokevirtual…)
静态变量 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 必死 WeakHashMapThreadLocal
虚引用 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 排查三板斧

  1. dump 快照-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=./heap.hprof
  2. MAT / JProfiler 分析:看哪个类的实例数异常大,追 GC Root 引用链
  3. jstat -gc 实时监控:看各代容量、GC 频率、元空间使用量

九、总结

  1. 存所有对象,分新生代(Eden+S0/S1,比例 8:1:1)和老年代。Minor GC 用复制算法扫新生代,Full GC 用标记-整理收老年代。
  2. 存方法调用的栈帧,每个栈帧有局部变量表+操作数栈+返回地址。栈里只存指针和基本类型值,对象本体永远在堆。
  3. 方法区(元空间) 存五样东西:类元数据、方法字节码、运行时常量池、静态变量、JIT 代码缓存。符号引用在常量池中原地解析为直接引用。
  4. 直接内存在堆外,靠壳对象+虚引用+Cleaner 回收。零拷贝是价值,壳不灭导致堆外泄漏是最大的坑。
  5. 引用强度:强 > 软 > 弱 > 虚。软引用做缓存,弱引用防泄漏,虚引用管堆外。
  6. 内存泄漏是慢性病:static 集合、监听器未解绑、ThreadLocal 忘 remove。修泄漏比加内存靠谱一万倍。
  7. OOM 要分清楚哪个区爆了——堆、元空间、直接内存、线程过多,解决手段完全不同。

如果你觉得有用,点赞收藏,下次排查 OOM 直接翻这篇就够了。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐