JVM 内存模型:6 个运行时数据区 + 堆分代 + 对象生命周期
OOM 排查的本质不是"dump 分析技巧"——是先知道 JVM 6 个区各存什么,再根据报错信息反推是哪个区爆了。Java heap space→堆里找大对象。Metaspace→检查动态代理类。unable to create new native thread→数线程数。这篇文章把 6 个区的"溢出症状→排查路径"画成一张地图。
阅读约 15 分钟 | 系列第 4/17 篇
一、总体架构:6 区 + 1
JVM 规范定义了 6 个运行时数据区 + 直接内存,按线程私有/共享分为两大类:
┌──────────────────────────────────────────────────────────────┐
│ JVM 运行时数据区(逻辑视图) │
│ │
│ ┌─── 线程私有(每线程一份)───┐ ┌─── 线程共享(所有线程共用)───┐ │
│ │ │ │ │ │
│ │ ① 程序计数器(PC Register) │ │ ④ 堆(Heap) │ │
│ │ 字节码行号指示器 │ │ GC 主战场 │ │
│ │ │ │ │ │
│ │ ② 虚拟机栈(VM Stack) │ │ ⑤ 方法区(Metaspace) │ │
│ │ 每个方法 = 一个栈帧 │ │ 类的元数据 │ │
│ │ │ │ │ │
│ │ ③ 本地方法栈 │ │ ⑥ 运行时常量池 │ │
│ │ 为 native 方法服务 │ │ Class常量池的运行时版本 │ │
│ │ │ │ │ │
│ └────────────────────────────┘ │ ⑦ 直接内存 │ │
│ │ NIO Buffer │ │
│ └────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
逻辑 vs 物理
以上是 JVM 规范的逻辑划分。在 HotSpot JDK 8 的物理实现中,实际内存布局如下:
堆(-Xms/-Xmx 控制) 本地内存(操作系统直接管理)
┌─────────────────────┐ ┌─────────────────────┐
│ 对象实例 + 数组 │ │ Metaspace(方法区) │
│ Class对象 + 静态变量 │ │ 类元数据、JIT代码缓存 │
│ 运行时常量池 │ │ │
│ 字符串常量池 │ │ 直接内存(NIO) │
└─────────────────────┘ └─────────────────────┘
| 分类 | 区域 | 内容 | 物理位置(JDK 8) | 异常 |
|---|---|---|---|---|
| 线程私有 | 程序计数器 | 字节码行号 | 线程独有 | ❌ 不 OOM |
| 线程私有 | 虚拟机栈 | 栈帧 | 线程独有 | StackOverflowError / OOM |
| 线程私有 | 本地方法栈 | native 方法栈帧 | 线程独有 | StackOverflowError / OOM |
| 线程共享 | 堆 | 对象/数组/Class 对象/静态变量/常量池 | 堆 | OOM |
| 线程共享 | 方法区 | 类元数据(不含静态变量和常量) | 本地内存 | OOM: Metaspace |
| 线程共享 | 运行时常量池 | 字面量 + 符号引用 | 堆(逻辑独立) | OOM |
| 线程共享 | 直接内存 | NIO Buffer | 本地内存 | OOM: Direct buffer memory |
为什么这样分?
- 线程私有:每个线程执行到不同的方法、不同的代码行,所以程序计数器和栈必须各有一份
- 线程共享:对象是所有线程都可能用到的,放一起(堆),通过 GC 统一管理
二、线程私有区域详解
① 程序计数器(PC Register)
记录"当前线程执行到了字节码的哪一条指令"。JVM 唯一不会 OOM 的区域。
Java 代码: 编译后的字节码:
int sum = a + b; → 0: iload_1 // 取 a
1: iload_2 // 取 b
2: iadd // 相加
3: istore_3 // 存到 sum
程序计数器存的数字:0 → 1 → 2 → 3(每执行一条指令+1)
为什么需要它? CPU 在多线程间来回切换——线程被切走时 PC=2,恢复后从第 2 条继续。没有 PC,线程不知道自己执行到哪了。
程序计数器的工作方式如同书签:看书看到第 3 页被叫走开会,折个角(PC=3),回来直接翻到第 3 页继续。
② 虚拟机栈(VM Stack)——最核心的运行时数据区
栈和栈帧的关系:
栈(Stack)= 容器(一个线程一根,~1MB)
栈帧(Stack Frame)= 栈里的一个元素(一个方法对应一个栈帧)
入栈 = 方法被调用 → 在栈顶压入新栈帧 → 方法开始执行
出栈 = 方法执行完毕 → 栈顶栈帧弹出销毁 → 返回调用者
main() 调 add():
main 栈帧入栈 → add 栈帧压入 → add 执行完出栈 → main 出栈(栈空)
栈帧内部四部分:
┌─────────────────────────────────────────┐
│ 局部变量表(Local Variables) │ ← 存"数据":参数和局部变量的值
│ 静态方法 slot[0] = 第一个参数 │ 基本类型存值,引用类型存堆地址
│ 实例方法 slot[0] = this(隐式传入) │
├─────────────────────────────────────────┤
│ 操作数栈(Operand Stack) │ ← 存"演算过程":字节码指令的操作区
│ 计算 a+b:iload a → iload b → iadd │ 中间结果临时存放,用完就清
├─────────────────────────────────────────┤
│ 动态链接(Dynamic Linking) │ ← 指向当前方法所属类的运行时常量池
├─────────────────────────────────────────┤
│ 返回地址(Return Address) │ ← 方法执行完后 PC 恢复到调用者的哪一行
└─────────────────────────────────────────┘
局部变量表 vs 操作数栈——必须分清楚:
局部变量表好比冰箱(食材存放处,有名有位置),操作数栈好比料理台(操作区,用完就清)。
int sum = a + b; // a=3, b=5
① iload_1:从[1]取 a=3,压入操作数栈 操作数栈: [3]
② iload_2:从[2]取 b=5,压入操作数栈 操作数栈: [3][5]
③ iadd:弹出 3 和 5,加得 8,压回 操作数栈: [8]
④ istore_3:弹出 8,存入局部变量表[3] 操作数栈: []
sum = 8 ✓
静态方法 vs 实例方法——唯一区别在 slot[0] :
static int add(int a, int b) { … } → slot[0]=a, slot[1]=b
int multiply(int a, int b) { … } → slot[0]=this, slot[1]=a, slot[2]=b
异常:
| 异常 | 原因 | 复现 |
|---|---|---|
StackOverflowError | 栈深度超限(默认约 1024 层,以 1MB 栈、每帧约 1KB 估算) | void f() { f(); } |
OutOfMemoryError | 疯狂创建线程,每个占 ~1MB | while(true) { new Thread(…).start(); } |
③ 本地方法栈(Native Method Stack)
结构 = 和虚拟机栈一模一样,只是服务对象不同:虚拟机栈 → Java 方法,本地方法栈 → native 方法(C/C++/Rust 实现均可)。HotSpot 将两个栈合二为一,理解概念即可。
典型 native 方法:Object.hashCode()、Thread.start0()、Unsafe.compareAndSwapInt()
三、线程共享区域详解
④ 堆(Heap)——最核心
堆中存放的内容(JDK 8) :
┌────────────────────────────────────────────────┐
│ ① 对象实例 → new Person(), new ArrayList<>() │
│ ② 数组 → new int[100], new String[3] │
│ ③ Class对象 → Demo.class 这个对象就在堆里 │
│ ④ 静态变量 → 附在 Class 对象上(JDK 8 搬入) │
│ ⑤ 运行时常量池 → Class 文件常量池的运行时版本 │
│ ⑥ 字符串常量池 → "hello" 等字符串字面量(全局一份)│
└────────────────────────────────────────────────┘
分代结构:
┌─────────────────────┐ ┌─────────────────────┐
│ 新生代 │ │ 老年代 │
│ ┌──────┬─────┬────┐ │ │ │
│ │ Eden │ S0 │ S1 │ │ │ 长期存活对象 │
│ │ 8 │ 1 │ 1 │ │ │ 大对象 │
│ └──────┴─────┴────┘ │ │ │
└─────────────────────┘ └─────────────────────┘
↖ 默认比例 新生代:老年代 = 1:2 ↗
关键参数:-Xms(初始) -Xmx(最大) -Xmn(新生代大小)
Eden:S0:S1=8:1:1——为什么这样设计?每次 Minor GC 存活对象进入 Survivor,两个 Survivor 交替使用(一个空着等下次 Copy)。8:1:1 意味着 90% 的新对象在 Eden 中,10% 在 Survivor 中周转,符合"绝大多数对象朝生夕死"的假设。
⑤ 方法区(Metaspace)
存放类的元数据(描述类的信息,而非对象本身):
Demo 类被加载后,Metaspace 中存入:
├── 类的完整限定名 → com.example.Demo
├── 类的修饰符 → public
├── 父类名 → java.lang.Object
├── 实现的接口 → Serializable
├── 字段信息:name(String), age(int), 修饰符 private
├── 方法信息:add, 签名 (II)I, 字节码 [iload_0, iload_1…], 异常表
├── 运行时常量池的引用 → 指向堆
└── JIT 编译后的本地代码(CodeCache)
⑥ 运行时常量池 vs 字符串常量池(关键区分)
Class 文件常量池(编译时,在 .class 文件里)
↓ 类加载时复制
运行时常量池(运行时,在堆中)← 每个类独立一份
├ 符号引用:#10 = Demo.add:(II)I
└ 字面量:#15 → "hello"(指向字符串常量池)
↓
字符串常量池(运行时,在堆中)← 全局只有一份,去重
├ "hello" = 一个堆对象
├ "world" = 一个堆对象
└ "paid" = 一个堆对象
| 维度 | 运行时常量池 | 字符串常量池 |
|---|---|---|
| 存什么 | 符号引用 + 各种字面量 | 只存字符串 |
| 范围 | 每个类各有一份 | 全局唯一,去重 |
| String.intern() | — | 操作的就是它 |
| GC | 类卸载时释放 | 没引用时被回收 |
String.intern() 经典问题:
String s1 = new String("hello"); // 堆中新建对象
String s2 = s1.intern(); // 常量池已有"hello",返回池中引用
String s3 = "hello"; // 从常量池取,和 s2 是同一个引用
System.out.println(s1 == s2); // false(s1 是堆对象,s2 是常量池引用)
System.out.println(s2 == s3); // true(两者都是常量池引用)
⑦ 直接内存(Direct Memory)
不在 JVM 堆内,直接向操作系统申请的内存。核心价值:零拷贝(Zero Copy) 。
传统 I/O(2 次拷贝):磁盘 → OS缓冲区 → JVM堆 → 应用程序
直接内存 I/O(1 次拷贝):磁盘 → OS缓冲区 → 应用程序(直接读写 OS 内存)
- 用途:NIO 大文件读写、Netty 网络编程
- 分配:
ByteBuffer.allocateDirect(1024)→Unsafe.allocateMemory()→ OS malloc - 代价:不受 GC 直接管——只有 ByteBuffer 对象被 GC 时才连带释放底层内存。直接内存满 ≠ 堆满,堆没触发 GC 时不会释放直接内存 →
OOM: Direct buffer memory。监控盲区:-Xmx设了 4G 感觉安全,但直接内存也在吃系统内存
四、JDK 6 → 7 → 8 内存模型演变
JDK 6 及之前 JDK 7 JDK 8+
─────────────────────────────────────────────────────────────
方法区 = PermGen(堆内) PermGen(堆内) Metaspace(堆外本地内存)
PermGen 中: PermGen 中剩下: Metaspace 中只有:
├ 类元数据 ├ 类元数据 └ 类元数据(纯元数据)
├ 常量 ──────→ 搬到堆(运行时常量池)
└ 静态变量 ────────→ 搬到堆(附在Class对象上)
参数变化:-XX:MaxPermSize → -XX:MaxMetaspaceSize,默认无上限(有多少物理内存用多少)。
"JDK 7 把'数据'搬出去(常量、静态变量),JDK 8 把方法区自身也搬出去了(PermGen→Metaspace)。目的是消除 PermGen 固定大小导致的 OOM——Metaspace 在本地内存中动态扩容。"
五、哪些区域会 OOM?
| 区域 | OOM 信息 | 常见原因 |
|---|---|---|
| 堆 | Java heap space | 对象太多、内存泄漏、大对象分配失败 |
| Metaspace | Metaspace | CGLIB 动态生成大量类、MaxMetaspaceSize 太小 |
| 虚拟机栈 | unable to create new native thread | 线程创建过多,每个占 ~1MB 栈 |
| 直接内存 | Direct buffer memory | NIO 大量分配未释放 |
六、堆和栈的本质区别(核心必知)
| 维度 | 堆(Heap) | 栈(Stack) |
|---|---|---|
| 存什么 | 对象实例、数组 | 方法调用的栈帧 |
| 线程归属 | 所有线程共享 | 每个线程私有 |
| 大小 | 大(-Xmx,几百MB~几十GB) | 小(每线程默认 ~1MB) |
| 生命周期 | 由 GC 决定 | 方法结束即释放 |
| 异常 | OutOfMemoryError | StackOverflowError |
| 速度 | 慢(需 GC) | 快(确定性入栈出栈) |
七、栈上分配(Stack Allocation)
JIT 编译器的逃逸分析优化:如果对象不会逃逸出方法(没有 return、没有赋值给静态变量),直接在栈上分配,方法结束自动销毁——不需要 GC 参与。
void foo() {
Point p = new Point(1, 2); // p 没有逃逸出 foo()
int x = p.x; // JIT 可能把 p 拆成标量 + 栈上分配
}
这是 JIT 编译器的自动优化,不需要手动干预。但理解它有助于回答"为什么现代 JVM 中 GC 的压力比以前小"这类问题。
八、对象生命周期:从 new 到回收的完整链路
标题承诺了"对象生命周期"——这一节就是兑现这个承诺。从一行 new User() 开始,追踪一个对象在 JVM 中的完整旅程。
new User("张三", 25)
│
▼
① 类加载检查 — User.class 是否已加载?未加载则触发类加载(加载→验证→准备→解析→初始化)
│
▼
② 分配内存 — 在 Eden 区为新对象分配空间
├── TLAB(Thread Local Allocation Buffer):每个线程在 Eden 中有自己的小块缓冲区
│ 优先在 TLAB 中分配,无锁、极快。TLAB 用完 → 申请新的 TLAB(CAS 一次)
└── TLAB 外分配:大对象(-XX:PretenureSizeThreshold)或 TLAB 申请失败时
│
▼
③ 初始化零值 — 所有实例字段赋默认值(int=0, Object=null, boolean=false)
│
▼
④ 设置对象头 — Mark Word(hashCode/GC 年龄/锁状态)+ 类型指针(指向 User.class)
│
▼
⑤ 执行 <init> — 调用构造函数,字段赋程序员指定的值(name="张三", age=25)
│
▼
⑥ 在 Eden 区"活着" — 对象在 Eden 中等待被引用、被使用
│
▼
⑦ Minor GC 触发 — Eden 区满 → Young GC
├── 无引用(垃圾)→ 直接回收,内存释放
└── 有引用(存活)→ 复制到 Survivor 区(S0),GC 年龄 +1
│
▼
⑧ Survivor 区来回复制 — 每次 Minor GC:S0↔S1 交替
├── 存活对象从一个 Survivor 复制到另一个
├── GC 年龄每次 +1
└── 年龄达到阈值(默认 15,-XX:MaxTenuringThreshold)→ 晋升老年代
│
▼
⑨ 晋升老年代(Tenured) — 以下情况之一触发:
├── GC 年龄 ≥ 15(默认)
├── 动态年龄判断:Survivor 中同龄对象总大小 > Survivor 空间 50% → 该年龄及以上直接晋升
└── 分配担保失败:Minor GC 后 Survivor 放不下 → 多余对象直接进老年代
│
▼
⑩ 在老年代中继续存活 — 由 Major GC / Full GC 管理
│
▼
⑪ Full GC 触发 — 老年代满或 System.gc()(不建议)→ 标记-清除-整理
├── 仍被引用 → 存活,整理到老年代一端(减少碎片)
└── 无引用 → 回收
│
▼
⑫ 对象彻底消亡 — 内存被回收,finalize() 仅执行一次(JDK 9 已废弃,不推荐使用)
关键参数串联:
| 参数 | 作用 | 默认值 |
|---|---|---|
-XX:MaxTenuringThreshold | Survivor→老年代的年龄门槛 | 15 |
-XX:PretenureSizeThreshold | 超过该大小的对象直接在老年代分配 | 0(不启用) |
-XX:SurvivorRatio | Eden 与单个 Survivor 的比例 | 8 |
-XX:+UseTLAB | 是否启用线程本地分配缓冲 | 默认开启 |
三个触发晋升的时机,记住口诀:年龄够了直接升、Survivor 放不下了溢升、大对象跳过 Eden 直升老年代。
核心要点回顾
JVM 运行时数据区按线程归属分为两大类:程序计数器、虚拟机栈、本地方法栈为线程私有,每个线程独立一份;堆、方法区(Metaspace)、运行时常量池、直接内存为线程共享,所有线程共同使用。JVM 规范定义的是逻辑划分,HotSpot JDK 8 的物理实现将 Class 对象、静态变量、运行时常量池、字符串常量池统一放在堆中,仅将类元数据和 JIT 编译后的本地代码放在本地内存的 Metaspace 中。堆的分代设计(Eden:S0:S1=8:1:1)基于"绝大多数对象朝生夕死"的经验假设——90% 的新生代空间分配给 Eden,两个 Survivor 各占 10% 并交替使用,每轮 Minor GC 存活对象在 S0 和 S1 之间来回复制。JDK 8 的关键变化是将 PermGen 彻底移除,方法区迁移到本地内存中的 Metaspace,同时将静态变量和常量池搬入堆中,核心目的是消除固定大小的 PermGen 导致的 OOM——Metaspace 默认无上限,按需动态扩容。字符串常量池从 JDK 7 起位于堆中,全局唯一,String.intern() 操作的就是这个池;运行时常量池则每个类独立一份,存储 Class 文件常量池的运行时版本(符号引用和各种字面量),两者是不同的概念。栈上分配是 JIT 编译器通过逃逸分析实现的自动优化——如果对象不会逃逸出方法,直接在栈上分配,方法结束自动销毁,无需 GC 参与。
下次线上 OOM 别慌,对照6个区一张图按图索骥。收藏下来,出问题时直接翻。
上一篇:《MySQL数据库原理》 | 下一篇:《JVM GC与调优:7种收集器选型+参数调优实战》 系列合集:掘金Java合集