JVM 内存区域划分
1. JVM 规范内存区域划分
1.1 总览
根据 Java 虚拟机规范(JVM Specification)的约定,JVM 内存区域划分为以下 5 个部分:
JVM 内存区域(规范层面)
线程私有区域
虚拟机栈
(VM Stack)
本地方法栈
(Native Method Stack)
程序计数器
(PC Register)
线程共享区域
方法区
(Method Area)
堆
(Heap)
- 规范 vs 实现:规范定义的是逻辑边界和功能清单,实际实现(如 HotSpot)往往不会严格按照规范来实现
- 规范外内存 :JVM 实际运行还需要很多规范外的内存,包括:
- JVM 自身占用
- GC 占用
- 线程相关内存(OS 层)
- 外部资源句柄
- 堆外内存(DirectByteBuffer)
1.2 方法区
定义:存储类的类型信息(Type Information),即描述类本身的信息。
规范定义的存储内容
根据 JVM 规范,方法区存储:
| 类型 | 说明 | 示例 |
|---|---|---|
| 类信息 | 类的基本信息 | 类名、父类、接口、访问修饰符 |
| 字段信息 | 类的字段定义 | 字段名、字段类型、访问修饰符 |
| 方法信息 | 类的方法定义 | 方法字节码、方法签名、异常表 |
| 常量池 | 类的常量池引用 | 符号引用、字面量 |
特点
- 线程共享:所有线程共享同一个方法区
- 可抛出 OOM:当加载的类过多时,可能抛出
OutOfMemoryError - GC 回收:规范要求支持垃圾回收,但条件苛刻
注意:
- 规范没有规定方法区的物理位置(可以在堆中,也可以在本地内存)
- 规范没有规定是否包含 JIT 编译后的代码(这是实现细节)
- 规范没有规定静态变量和字符串常量池的位置(这是实现细节)
1.3 堆
定义:存储对象实例和数组的运行时数据区域。
规范定义
根据 JVM 规范:
The heap stores objects and arrays.
即:堆只存储对象实例和数组。
特点
- 线程共享:所有线程共享同一个堆
- GC 主要区域:垃圾收集器管理的主要区域
- 可抛出 OOM:当对象过多无法分配时,抛出
OutOfMemoryError: Java heap space - 最大内存区域:通常占 JVM 内存的最大部分
注意:
- 规范只规定了堆存储对象和数组
- 规范没有规定字符串常量池、静态变量的位置(这是实现细节)
- 规范没有规定堆的内存结构(新生代、老年代等,这是 GC 实现细节)
1.4 虚拟机栈
定义:描述 Java 方法执行的内存模型,每个方法调用对应一个栈帧的入栈和出栈。
规范定义
根据 JVM 规范:
- 每个线程都有独立的虚拟机栈
- 虚拟机栈的生命周期与线程相同
- 栈帧(Stack Frame)用于存储方法执行的相关数据
栈帧结构(规范定义)
JVM 规范定义了栈帧的四个组成部分:
栈帧 (Stack Frame)
1.局部变量表
基本类型 int, boolean
对象引用 reference
returnAddress
2.操作数栈
方法执行的中间结果
3.动态连接
常量池引用
运行时常量池
4.返回地址
方法正常返回地址
方法异常返回地址
注意 :
规范定义了栈帧的逻辑结构
规范没有规定栈的物理实现方式
局部变量表详解
| 类型 | 说明 | 示例 |
|---|---|---|
| 基本类型 | boolean, byte, char, short, int, long, float, double | int a = 10 |
| 对象引用 | 指向堆中对象的引用 | String s = "hello" |
| returnAddress | 方法返回地址(字节码级别) | return 指令 |
异常情况
| 异常 | 触发条件 |
|---|---|
StackOverflowError |
线程请求的栈深度大于 JVM 允许的深度(如无限递归) |
OutOfMemoryError |
无法分配足够的内存创建新线程 |
1.5 本地方法栈
定义 :为 Native 方法(使用 native 关键字修饰的方法)服务的内存区域。
特点
- 功能类似虚拟机栈:也是栈帧结构
- 执行 Native 方法:调用 Java 以外的语言(如 C/C++)编写的方法
- 依赖 JVM 实现:某些 JVM(如 HotSpot)将虚拟机栈和本地方法栈合并
1.6 程序计数器
定义:记录当前线程所执行的字节码的行号指示器。
特点
- 线程私有:每个线程都有独立的程序计数器
- 指向字节码地址:记录当前执行到哪一条字节码指令
- 控制程序流程:相当于图遍历中的
next指针 - 唯一不会 OOM 的区域:JVM 规范中唯一规定不会发生
OutOfMemoryError的区域
作用
| 场景 | 说明 |
|---|---|
| 顺序执行 | 指向下一条字节码指令 |
| 方法调用 | 保存返回地址(调用点) |
| 方法返回 | 恢复到调用点继续执行 |
| 异常跳转 | 跳转到异常处理器 |
特殊情况
- 执行 Native 方法:程序计数器值为
Undefined(因为 Native 方法不是字节码)
2. HotSpot 实现
注意:以下内容针对 HotSpot JVM(最常用的 JVM 实现),其他 JVM 实现可能不同。
2.1 元空间
定义:HotSpot 对方法区的不完整的实现(JDK 8+)。
与永久代的区别
| 特性 | 永久代(JDK 7 及之前) | 元空间(JDK 8+) |
|---|---|---|
| 内存位置 | JVM 进程内存(非堆,但由 GC 管理) | 本地内存(Native Memory) |
| GC 逻辑 | 与堆共用 | 独立的 GC 逻辑 |
| 容量限制 | 固定大小 | 默认无限制(受物理内存限制) |
| 调整参数 | -XX:PermSize、-XX:MaxPermSize |
-XX:MetaspaceSize、-XX:MaxMetaspaceSize |
| OOM 影响 | Full GC 可能无休止 | GC 效率更高 |
为什么废弃永久代?
历史原因:JDK 6 → 7 的升级已经将类的静态变量引用和字符串字面值移动到了堆中,永久代的问题已部分缓解。
但永久代与堆共用 GC 逻辑仍导致两个严重问题:
问题 1:堆满触发 GC,扫描永久代效率低
堆满了
触发 Full GC
扫描堆 + 永久代
永久代引用复杂
且生命周期长
可能忙了很久
但什么都没收集到
问题 2:永久代满触发 GC,无休止直到 OOM
永久代满了
触发 Full GC
扫描堆 + 永久代
即使堆很空也要扫描
永久代 Full GC
基本回收不到空间
GC 起来没完直到 OOM
元空间的优化
独立的 GC 逻辑:
- 元空间有专门的 GC 回收策略
- 不再与堆互相影响
- GC 效率显著提升
容量自动扩展:
- 默认最大无限制(受物理内存限制)
- 可设置
-XX:MaxMetaspaceSize防止过度占用
类加载器隔离:
- 以类加载器为单位分配内存
- 类加载器卸载时一次性回收对应内存
2.2 堆
定义 :HotSpot 的堆是 JVM 独享且全权管辖的区域 ,实际上成为了 JVM 的通用对象容器。
存储内容扩展
| 类型 | 说明 | 为何在堆中 |
|---|---|---|
| 对象实例 | 用户定义的对象 | 规范要求 |
| 字符串常量池 | 字符串字面量 | JDK 7+ 从永久代迁移 |
| 静态变量 | 类的静态变量 | JDK 7+ 从永久代迁移 |
| 虚拟线程栈帧 | 虚拟线程的栈帧 | 利用 GC 简化内存管理 |
设计思想:将字符串常量池和静态变量移入堆,是为了复用 GC 机制和简化 GC 流程。
堆的内存结构
堆的内存结构与 GC 算法强相关,因此此处不作具体介绍。
2.3 栈
定义 :HotSpot 对虚拟机栈 和本地方法栈的实现。
统一实现
HotSpot 中,虚拟机栈和本地方法栈使用同一套实现。
普通线程的栈实现
| 特性 | 说明 |
|---|---|
| 栈类型 | 物理栈(操作系统栈) |
| 内存占用 | 通常为 1MB(可通过 -Xss 调整) |
| 分配位置 | 操作系统本地内存 |
| 生命周期 | 与线程相同 |
虚拟线程的栈实现(JDK 21+)
HotSpot 为虚拟线程引入了栈帧拷贝机制:
| 特性 | 普通线程 | 虚拟线程 |
|---|---|---|
| 栈类型 | 物理栈 | 栈帧拷贝 |
| 运行时 | 操作系统栈 | 物理栈 |
| 挂起时 | 仍在操作系统栈 | 冻结到堆内存 |
| 内存占用 | 约 1MB | 几 KB |
| 上下文切换 | 昂贵(OS 级别) | 便宜(JVM 级别) |
| 数量级 | 数千个 | 数百万个 |
栈帧拷贝机制详解
虚拟线程启动
阻塞IO发生
持续状态
IO操作完成
继续执行
栈帧在物理栈
栈帧冻结并拷贝到堆
链表伪装栈
等待IO完成
从堆拷贝回物理栈
物理栈
高效执行
堆内存
轻量级挂起
设计优势:
- 轻量级:栈帧可以冻结在堆中,不占用操作系统栈
- 高效:挂起/恢复只需要内存拷贝,不需要 OS 上下文切换
- 可扩展:可以创建数百万个虚拟线程
JIT 优化:逃逸分析与标量替换
逃逸分析:分析对象的作用域,判断对象是否"逃逸"出方法。
标量替换 :如果对象未逃逸,可能被拆解成若干个基础变量,直接分配到栈的局部变量表,而不在堆中创建对象。
java
// 示例代码
public void method() {
Point p = new Point(10, 20); // 未逃逸
int x = p.x;
int y = p.y;
// JIT 优化后可能变成:
// int x = 10;
// int y = 20;
// 不再创建 Point 对象!
}
优势:
- 减轻 GC 压力
- 提高访问速度(栈访问比堆快)
- 提高缓存命中率
2.4 程序计数器
定义:HotSpot 对程序计数器的实现。
存储位置
| 线程类型 | 程序计数器位置 | 说明 |
|---|---|---|
| 普通线程 | CPU 寄存器 | 利用硬件加速 |
| 虚拟线程 | 寄存器(运行时)→ 堆(挂起时) | 挂起时冻结到堆 |
虚拟线程的程序计数器
虚拟线程启动
IO操作挂起
持续状态
IO完成
继续执行
PC在CPU寄存器
PC冻结到堆内存
等待IO
PC回填到CPU寄存器
CPU寄存器
硬件加速
堆内存
上下文冻结
优势:虚拟线程挂起时,整个上下文(包括程序计数器)都冻结在堆中,可以高效地恢复执行。
3. 规范 vs 实现对比
3.1 关键区别总结
| 方面 | JVM 规范 | HotSpot 实现 |
|---|---|---|
| 定义方式 | 逻辑边界、功能清单 | 具体的物理实现 |
| 约束力 | 所有 JVM 必须遵守 | HotSpot 特有的选择 |
| 灵活性 | 留有实现空间 | 可以优化和创新 |
3.2 各区域的规范 vs 实现
方法区
| 维度 | 规范要求 | HotSpot 实现 |
|---|---|---|
| 存储内容 | 类的类型信息 | 元空间(Metaspace) |
| 物理位置 | 未规定 | 本地内存(JDK 8+) |
| 容量限制 | 未规定 | 可设置 MaxMetaspaceSize |
| 字符串常量池 | 未规定位置 | 在堆中(JDK 7+) |
| 静态变量 | 未规定位置 | 在堆中(JDK 7+) |
| JIT 代码 | 未规定 | 可能包含在元空间 |
堆
| 维度 | 规范要求 | HotSpot 实现 |
|---|---|---|
| 存储内容 | 对象和数组 | 对象、数组、字符串常量池、静态变量、虚拟线程栈帧 |
| 内存结构 | 未规定 | GC 相关 |
| GC 算法 | 要求有 GC | Serial/Parallel/G1/ZGC 等 |
虚拟机栈
| 维度 | 规范要求 | HotSpot 实现 |
|---|---|---|
| 栈帧结构 | 规定了 4 个部分 | 完全遵循 |
| 物理实现 | 未规定 | 物理栈(普通线程) |
| 虚拟线程 | 未规定 | 栈帧拷贝(JDK 21+) |
本地方法栈
| 维度 | 规范要求 | HotSpot 实现 |
|---|---|---|
| 功能 | 服务 Native 方法 | 与虚拟机栈合并实现 |
程序计数器
| 维度 | 规范要求 | HotSpot 实现 |
|---|---|---|
| 功能 | 记录字节码地址 | CPU 寄存器(普通线程) |
| 虚拟线程 | 未规定 | 挂起时冻结到堆 |
4. 内存区域对比
4.1 线程共享 vs 线程私有
| 维度 | 线程共享区域 | 线程私有区域 |
|---|---|---|
| 包含 | 方法区、堆 | 虚拟机栈、本地方法栈、程序计数器 |
| 生命周期 | 从 JVM 启动到关闭 | 与线程相同 |
| 访问冲突 | 需要同步控制 | 无冲突 |
| OOM 风险 | 是(容易发生) | 是(栈深度超限) |
| GC 回收 | 主要区域 | 不是主要区域 |
5. 常见误区
误区 1:混淆规范与实现
错误理解:认为 JVM 规范描述的所有内容(如字符串常量池、静态变量在堆中)都是所有 JVM 必须遵守的。
正确理解:
- JVM 规范只定义了逻辑边界和功能要求
- 具体实现(如字符串常量池的位置)由各 JVM 自己决定
- 不同 JVM 实现可能有不同的内存布局
误区 2:方法区和堆是分开的
错误理解:方法区和堆是两个完全独立的区域,互不影响。
正确理解:
- JDK 7 及之前:永久代(方法区实现)虽然是堆外内存,但是和堆共用 GC
- JDK 8+:元空间(方法区实现)在本地内存,与堆分离
- 但字符串常量池和静态变量在 JDK 7+ 已移入堆
误区 3:局部变量在栈中,对象在堆中
错误理解:所有局部变量都在栈中,所有对象都在堆中。
正确理解:
- 基本类型局部变量:栈
- 对象引用:栈(引用本身)→ 堆(实际对象)
- 未逃逸对象:可能被 JIT 优化到栈(标量替换)
- 虚拟线程栈帧:堆(挂起时)
误区 4:程序计数器指向 Java 代码行号
错误理解:程序计数器记录的是 Java 源代码的行号。
正确理解:
- 程序计数器指向的是字节码的地址,不是 Java 源代码行号
- 字节码经过 JIT 编译后变成机器码,程序计数器指向机器码地址
- 调试器可以通过字节码映射表反查到源代码行号
误区 5:虚拟机栈越大越好
错误理解 :增加栈大小可以避免 StackOverflowError。
正确理解:
- 增加栈大小确实可以减少
StackOverflowError的风险 - 但每个线程占用的内存会增加
- 可创建的线程数量会减少
- 权衡:根据实际递归深度和并发需求调整
6. 总结
理解 JVM 内存区域的关键在于严格区分规范定义和具体实现,例如:
| 方面 | JVM 规范 | HotSpot 实现 |
|---|---|---|
| 方法区 | 定义为存储类类型信息的逻辑区域 | 元空间(本地内存) |
| 堆 | 定义为存储对象和数组的区域 | 对象+数组+字符串常量池+静态变量 |
| 栈 | 定义栈帧的逻辑结构 | 物理栈(普通线程) 栈帧拷贝(虚拟线程) |
关键理解:
- 规范只定义"是什么"(what)
- 实现决定"怎么做"(how)
- 不同 JVM 实现可以有不同的实现方式