03 - 运行时数据区
定位: JVM 运行时内存的全景划分——程序计数器到直接内存,7 大区域的设计原理与协作关系 面试高频度: ⭐⭐⭐⭐⭐
一、运行时数据区在 JVM 体系中的位置
类加载将 Class 文件的静态结构转化为方法区的运行时元数据后,JVM 就需要一片运行时内存来执行实际的计算。运行时数据区就是这片内存的划分方案:
- 向上: 接收类加载器的输出(类元数据存入方法区,Class 对象存入堆)
- 向下: 为执行引擎提供运行时内存(指令在栈帧上执行,对象在堆上分配)
- 横向: 与 GC 系统紧密耦合——GC 控制堆区域的生死循环
二、JVM 运行时内存全景布局
┌─────────────────────────────────────────────────────────────────────────────┐
│ JVM 运行时内存全景 │
│ │
│ ┌─────────── 线程私有 (N 个线程, N 份副本) ──────────────────┐ │
│ │ │ │
│ │ Thread 1 Thread 2 │ │
│ │ ┌───────────────────┐ ┌───────────────────┐ │ │
│ │ │ 程序计数器 (PC) │ │ 程序计数器 (PC) │ │ │
│ │ │ → 记录当前字节码 │ │ → 记录当前字节码 │ │ │
│ │ │ 行号指示器 │ │ 行号指示器 │ │ │
│ │ └───────────────────┘ └───────────────────┘ │ │
│ │ ┌───────────────────┐ ┌───────────────────┐ │ │
│ │ │ Java虚拟机栈 │ │ Java虚拟机栈 │ │ │
│ │ │ ┌─ 栈帧 1 ──────┐│ │ ┌─ 栈帧 1 ──────┐│ │ │
│ │ │ │ 局部变量表 ││ │ │ 局部变量表 ││ │ │
│ │ │ │ 操作数栈 ││ │ │ 操作数栈 ││ │ │
│ │ │ │ 动态链接 ││ │ │ 动态链接 ││ │ │
│ │ │ │ 返回地址 ││ │ │ 返回地址 ││ │ │
│ │ │ └───────────────┘│ │ └───────────────┘│ │ │
│ │ │ ┌─ 栈帧 2 ──────┐│ │ ┌─ 栈帧 2 ──────┐│ │ │
│ │ │ │ ... ││ │ │ ... ││ │ │
│ │ │ └───────────────┘│ │ └───────────────┘│ │ │
│ │ └───────────────────┘ └───────────────────┘ │ │
│ │ ┌───────────────────┐ ┌───────────────────┐ │ │
│ │ │ 本地方法栈 │ │ 本地方法栈 │ │ │
│ │ │ (HotSpot: 与 │ │ (HotSpot: 与 │ │ │
│ │ │ VM栈合并) │ │ VM栈合并) │ │ │
│ │ └───────────────────┘ └───────────────────┘ │ │
│ └───────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────── 线程共享 ─────────────────────────────────────┐ │
│ │ │ │
│ │ ┌────────────────────────────────────────────────────┐ │ │
│ │ │ 堆 (Heap) │ │ │
│ │ │ ┌───────────┬──────────┬──────────┬──────────────┐ │ │ │
│ │ │ │ Eden │ S0 │ S1 │ Old / │ │ │ │
│ │ │ │ (TLAB区域) │ (存活期1) │ (存活期2) │ 老年代 │ │ │ │
│ │ │ │ │ │ │ │ │ │ │
│ │ │ │ Thread1 │ ←Eden的 │ ←S0或S1 │ ←长期存活的对象│ │ │ │
│ │ │ │ Thread2 │ 幸存者 │ 晋升来 │ │ │ │ │
│ │ │ │ Thread3 │ │ │ │ │ │ │
│ │ │ └───────────┴──────────┴──────────┴──────────────┘ │ │ │
│ │ │ 字符串常量池 (JDK 7+) ← 堆的一部分 │ │ │
│ │ └────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ ┌────────────────────────────────────────────────────┐ │ │
│ │ │ 方法区 / 元空间 (Method Area / Metaspace) │ │ │
│ │ │ ├ 类元数据 (Class Metadata) │ │ │
│ │ │ ├ 运行时常量池 (Runtime Constant Pool) │ │ │
│ │ │ ├ 方法字节码 (Code 属性) │ │ │
│ │ │ ├ JIT 编译代码缓存 │ │ │
│ │ │ └ 字段/方法/接口信息 │ │ │
│ │ │ (JDK 8+: 使用本地内存, 不受堆大小限制) │ │ │
│ │ └────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ ┌────────────────────────────────────────────────────┐ │ │
│ │ │ 直接内存 (Direct Memory / Off-Heap) │ │ │
│ │ │ ├ NIO DirectByteBuffer (通过 Unsafe 分配) │ │ │
│ │ │ ├ 本地代码分配的内存 (JNI 调用) │ │ │
│ │ │ └ 不受 -Xmx 限制, 受 OS 可用内存限制 │ │ │
│ │ └────────────────────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘为什么这样划分?
线程私有 vs 线程共享 的划分,本质上是生命周期管理和 GC 效率的产物:
| 分区 | 包含区域 | 生命周期 | GC 是否介入 |
|---|---|---|---|
| 线程私有 | 程序计数器、虚拟机栈、本地方法栈 | 随线程生灭,无需同步 | 不参与 GC |
| 线程共享 | 堆、方法区/元空间 | 随 JVM 生灭,需同步控制 | 堆是 GC 主战场 |
线程私有数据天生是线程安全的——一个线程无法访问另一个线程的栈帧或 PC。这消除了同步开销,也让栈区域成为了 GC 的"盲区":因为栈帧弹出时内存自然释放,不需要 GC 来回收。
三、子专题导航
| # | 主题 | 面试频率 | 核心内容 |
|---|---|---|---|
| **程序计数器** | 程序计数器 | ⭐⭐ | 字节码行号指示器、线程切换恢复、唯一没有 OOM 的区域 |
| **Java虚拟机栈** | Java 虚拟机栈 | ⭐⭐⭐⭐⭐ | 栈帧 4 组件、StackOverflowError vs OOM、局部变量表 slot 复用 |
| **本地方法栈** | 本地方法栈 | ⭐⭐ | JNI 调用栈、HotSpot 栈合并、native 方法执行 |
| **Java堆** | Java 堆 | ⭐⭐⭐⭐⭐ | 分代设计、TLAB、Eden:S0:S1 比例、对象分配与晋升 |
| **方法区与元空间** | 方法区与元空间 | ⭐⭐⭐⭐ | PermGen→Metaspace 迁移、类元数据、JIT 缓存 |
| **运行时常量池** | 运行时常量池 | ⭐⭐⭐ | 两级常量池设计、符号引用→直接引用、String.intern() |
| **直接内存** | 直接内存 | ⭐⭐⭐ | 零拷贝 I/O 原理、DirectByteBuffer 回收、Off-Heap OOM |
四、核心考点速记
1. 栈 vs 堆 —— 核心区别
| 维度 | 栈 (Stack) | 堆 (Heap) |
|---|---|---|
| 存储内容 | 局部变量、方法调用上下文 | 对象实例、数组 |
| 生命周期 | 随方法生命周期:方法入栈→帧创建,方法返回→帧销毁 | 随对象生命周期:GC 判定不可达后回收 |
| 访问速度 | 快(紧贴 CPU,通过栈指针访问) | 慢(GC 要追踪引用链) |
| 线程安全 | 天生安全(线程私有) | 需同步(volatile、synchronized) |
| 异常情况 | StackOverflowError(递归太深),OOM(线程太多) | OOM(对象无法被 GC) |
| 内存分配方式 | 编译期确定 slot 大小,运行期固定 | 运行期动态分配,GC 自动回收 |
2. 方法区变迁 (PermGen → Metaspace)
JDK 6 及以前 JDK 8+
┌─────────────────────────────┐ ┌─────────────────────────────┐
│ 堆 │ │ 堆 │
│ ┌─────────┬──────────────┐ │ │ ┌─────────┬──────────────┐ │
│ │ Young │ Old │ │ │ │ Young │ Old │ │
│ ├─────────┴──────────────┤ │ │ ├─────────┴──────────────┤ │
│ │ 字符串常量池 │ │ │ │ 字符串常量池 │ │
│ └────────────────────────┘ │ │ └────────────────────────┘ │
├─────────────────────────────┤ ├─────────────────────────────┤
│ PermGen (永久代) │ │ Metaspace (本地内存) │
│ ├ 类元数据 │ │ ├ 类元数据 │
│ ├ 运行时常量池 │ │ ├ 运行时常量池 │
│ └ 字符串常量池 (JDK6及以前) │ │ └ JIT 编译代码缓存 │
│ │ │ │
│ 固定大小(-XX:MaxPermSize) │ │ 默认无上限(OS 内存决定) │
│ 字符串常量池在 PermGen → │ │ -XX:MaxMetaspaceSize 可限制 │
│ String.intern() 易 OOM │ │ 类卸载条件更宽松 │
└─────────────────────────────┘ └─────────────────────────────┘为什么迁移? PermGen 固定大小且难以调优,生产环境中 String.intern() 和类加载容易导致 OOM。Metaspace 使用本地内存,由 OS 管理,不再有固定的上限,解决了"调大浪费、调小 OOM"的两难问题。
3. 直接内存 (Direct Memory)
传统 I/O 路径 (堆内存):
磁盘 → [内核缓冲区] → [JVM 堆缓冲区] → 应用代码
①系统拷贝 ②系统拷贝 ③无拷贝
↑ CPU 参与两次拷贝
NIO I/O 路径 (直接内存):
磁盘 → [内核缓冲区] → [直接内存] → 应用代码
①系统拷贝 ②(共享映射)
↑ DMA 直接写入, CPU 拷贝减少一次直接内存绕过堆的限制,使用操作系统的 DMA 能力实现更高效的 I/O。但它的回收依赖于 Cleaner 机制——DirectByteBuffer 被 GC 时才触发 Unsafe.freeMemory()。如果堆一直不 GC,直接内存可能被耗尽,造成一种特殊的 OOM:堆明明还有空间,但系统内存不足了。
五、面试高频追问一览
| 追问 | 关联笔记 | 频次 |
|---|---|---|
| JVM 的运行时数据区分为哪几块?哪些是线程私有的? | **运行时数据区索引** | 极高 |
| 栈和堆有什么区别? | **运行时数据区索引** | 极高 |
| 方法区从 PermGen 变成 Metaspace 的原因? | **方法区与元空间** | 极高 |
| 程序计数器会不会 OOM? | **程序计数器** | 中 |
| Java 虚拟机栈会抛出哪些异常? | **Java虚拟机栈** | 高 |
| 堆为什么要分代? | **Java堆** | 极高 |
| 什么是 TLAB? | **Java堆** | 高 |
| String.intern() 的底层原理? | **运行时常量池** | 高 |
| 直接内存和堆内存有什么区别? | **直接内存** | 中高 |
| 符号引用和直接引用的区别? | **运行时常量池** | 高 |
📚 相关文件
- **Class文件结构** — 常量池的概念入口(运行时常量池的静态来源)
- **类加载生命周期** — 类加载在方法区中构建运行时数据结构
- **对象创建过程** — 对象在堆上分配的具体流程
- **对象存活判定算法** — GC 在堆上判定对象是否存活的算法
- **JVM文库设计文档**