定位 :本文是 JVM 系列的第一篇。面试中"JVM 内存区域划分"几乎是必考题,但很多人背的是八股文,一问细节就答不上来。本文从生活比喻 出发,结合 JVM 规范逐步拆解每个内存区域的作用、存储内容和常见问题,帮你真正理解而不是死记硬背。
官方规范参考:
- The Java Virtual Machine Specification - Runtime Data Areas --- JVM 运行时数据区
目录
- [1. 为什么需要了解 JVM 内存区域?](#1. 为什么需要了解 JVM 内存区域?)
- [2. JVM 内存区域全景图](#2. JVM 内存区域全景图)
- [3. 堆(Heap)------最核心的内存区域](#3. 堆(Heap)——最核心的内存区域)
- [4. 方法区(Method Area)------类的信息仓库](#4. 方法区(Method Area)——类的信息仓库)
- [5. 虚拟机栈(VM Stack)------方法执行的工作台](#5. 虚拟机栈(VM Stack)——方法执行的工作台)
- [6. 本地方法栈(Native Method Stack)------native 方法的舞台](#6. 本地方法栈(Native Method Stack)——native 方法的舞台)
- [7. 程序计数器(PC Register)------线程的导航仪](#7. 程序计数器(PC Register)——线程的导航仪)
- [8. 直接内存(Direct Memory)------堆外的特殊区域](#8. 直接内存(Direct Memory)——堆外的特殊区域)
- [9. 线程共享 vs 线程私有](#9. 线程共享 vs 线程私有)
- [10. 常见面试题精选](#10. 常见面试题精选)
- [11. 总结](#11. 总结)
1. 为什么需要了解 JVM 内存区域?
JVM 内存管理的重要性
Java 虚拟机在执行 Java 程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域都有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而存在,有些区域则依赖用户线程的启动和结束而建立和销毁。
理解内存区域划分的核心价值:
- 故障诊断能力:当出现 OutOfMemoryError 时,能够快速定位是哪个内存区域出现问题
- 性能调优基础:合理设置各个内存区域的大小,优化应用程序性能
- 代码质量提升:理解对象的生命周期,编写更高效的代码
- 面试核心考点:JVM 内存模型是 Java 开发者必须掌握的基础知识
先从生活说起
你开了一家公司,需要规划办公空间:
公司办公空间规划:
├─ 开放工位区 → 所有人共享,放办公用品(堆)
├─ 档案室 → 所有人共享,存公司制度文件(方法区)
├─ 个人办公室 → 每人独享,放自己的工作台(虚拟机栈)
├─ 会议室 → 每人独享,接待外部客户(本地方法栈)
└─ 工位号牌 → 每人独享,标记当前工作进度(程序计数器)
JVM 也是一样,它需要合理划分内存空间,让不同的数据各得其所。
内存区域划分的设计原则
1. 数据特性分离
- 不同生命周期的数据分开存储
- 不同访问模式的数据分开管理
- 不同线程安全需求的数据分开处理
2. 性能优化考虑
- 频繁分配的对象集中管理(堆)
- 生命周期与线程绑定的数据线程私有(栈)
- 全局共享但变化较少的数据统一存储(方法区)
3. 垃圾回收友好
- 按对象年龄分代存储
- 便于不同回收策略的实施
- 减少回收时的停顿时间
不了解会怎样?
| 问题场景 | 具体表现 | 后果 |
|---|---|---|
| OOM 排查 | 不知道是堆溢出还是栈溢出 | 无法定位问题根源,盲目调参 |
| 性能调优 | 不了解内存分配机制 | 调优效果差,可能适得其反 |
| 内存泄漏 | 不理解对象引用关系 | 无法识别和解决内存泄漏 |
| 并发问题 | 不清楚线程共享边界 | 写出线程不安全的代码 |
| 面试必考 | "说说 JVM 内存模型"几乎逢面必问 | 错失工作机会 |
| 理解 GC | 不懂内存区域,垃圾回收就是天书 | 无法进行 GC 调优 |
2. JVM 内存区域全景图
先看全貌,再逐一深入:
┌─────────────────────────────────────────────────────────────────────────┐
│ JVM 运行时数据区 │
│ │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ 线程共享区域(所有线程共用) │ │
│ │ │ │
│ │ ┌─────────────────────────┐ ┌─────────────────────────────┐ │ │
│ │ │ 堆(Heap) │ │ 方法区(Method Area) │ │ │
│ │ │ │ │ │ │ │
│ │ │ ┌───────┐ ┌────────┐ │ │ 类信息 │ │ │
│ │ │ │ 新生代 │ │ 老年代 │ │ │ 常量池 │ │ │
│ │ │ │ │ │ │ │ │ 静态变量 │ │ │
│ │ │ │ Eden │ │ │ │ │ JIT编译后的代码 │ │ │
│ │ │ │ S0/S1 │ │ │ │ │ │ │ │
│ │ │ └───────┘ └────────┘ │ │ │ │ │
│ │ │ │ │ │ │ │
│ │ │ 存放:对象实例、数组 │ │ 存放:类的元数据信息 │ │ │
│ │ │ 特点:GC 的主战场 │ │ 特点:JDK8后变为元空间 │ │ │
│ │ └─────────────────────────┘ └─────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ 线程私有区域(每个线程独有一份) │ │
│ │ │ │
│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────┐ │ │
│ │ │ 虚拟机栈 │ │ 本地方法栈 │ │ 程序计数器 │ │ │
│ │ │ (VM Stack) │ │ (Native │ │ (PC Register) │ │ │
│ │ │ │ │ Method │ │ │ │ │
│ │ │ 栈帧1 │ │ Stack) │ │ 当前执行的字节码 │ │ │
│ │ │ 栈帧2 │ │ │ │ 指令地址 │ │ │
│ │ │ 栈帧3 │ │ native方法 │ │ │ │ │
│ │ │ ... │ │ 的调用信息 │ │ 唯一不会OOM的区域 │ │ │
│ │ │ │ │ │ │ │ │ │
│ │ └──────────────┘ └──────────────┘ └──────────────────────┘ │ │
│ └───────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
一句话总结每个区域
| 区域 | 一句话 | 生活比喻 |
|---|---|---|
| 堆 | 存对象和数组 | 公司的开放工位区 |
| 方法区 | 存类的元数据信息 | 公司的档案室 |
| 虚拟机栈 | 存方法调用的栈帧 | 员工的个人办公桌 |
| 本地方法栈 | 为 native 方法服务 | 接待外部客户的会议室 |
| 程序计数器 | 记录当前执行位置 | 员工的工位号牌 |
3. 堆(Heap)------最核心的内存区域
生活比喻
堆就像公司的开放工位区,所有员工(线程)共享,所有办公用品(对象)都放在这里。
官方定义
The heap is the runtime data area from which memory for all class instances and arrays is allocated.
--- JVM Specification §2.5.3
翻译:堆是 JVM 中用于分配所有类实例(对象)和数组内存的运行时数据区。
堆的内部结构
┌─────────────────────────────────────────────────────────┐
│ 堆(Heap) │
│ │
│ ┌──────────────────────────────┐ ┌─────────────────┐ │
│ │ 新生代(Young) │ │ 老年代(Old) │ │
│ │ │ │ │ │
│ │ ┌──────┐ ┌──────┐ ┌──────┐ │ │ │ │
│ │ │ Eden │ │ S0 │ │ S1 │ │ │ 存活时间长的 │ │
│ │ │ │ │(From)│ │(To) │ │ │ 对象 │ │
│ │ │ 新对象│ │存活对象│ │存活对象│ │ │ │ │
│ │ │ 诞生地│ │暂存区 │ │暂存区 │ │ │ │ │
│ │ └──────┘ └──────┘ └──────┘ │ │ │ │
│ │ │ │ │ │
│ │ 特点:GC频繁,对象朝生夕死 │ │ 特点:GC较少 │ │
│ └──────────────────────────────┘ └─────────────────┘ │
│ │
│ 默认比例:新生代 : 老年代 = 1 : 2(可通过 -XX:NewRatio 调整) │
│ 新生代内部:Eden : S0 : S1 = 8 : 1 : 1 │
└─────────────────────────────────────────────────────────┘
堆内存的详细分析
堆的物理结构与逻辑结构
物理结构:堆在物理上可能不连续,但在逻辑上被视为连续的内存空间。现代 JVM 实现中,堆通常由多个内存段组成,这些段可能分布在不同的物理内存区域。
逻辑结构:从垃圾收集的角度,堆被划分为新生代和老年代,这种划分基于"弱分代假说":
- 绝大多数对象都是朝生夕死的
- 熬过越多次垃圾收集过程的对象就越难以消亡
新生代的内部机制
Eden 区(伊甸园)
- 所有新创建的对象首先在 Eden 区分配内存
- 当 Eden 区满时,触发 Minor GC
- 采用"指针碰撞"的分配方式,效率极高
Survivor 区的复制算法
Minor GC 的完整流程:
═══════════════════════════════════════
第一次 Minor GC:
Eden(满) + S0(空) + S1(空)
↓ GC 后
Eden(空) + S0(存活对象,age=1) + S1(空)
第二次 Minor GC:
Eden(满) + S0(age=1) + S1(空)
↓ GC 后
Eden(空) + S0(空) + S1(存活对象,age=2)
如此往复,S0 和 S1 轮流作为 From 和 To 区
当对象年龄达到阈值(默认15)时,晋升到老年代
老年代的管理策略
晋升条件:
- 年龄晋升 :对象年龄达到
-XX:MaxTenuringThreshold设定值 - 大对象直接晋升 :超过
-XX:PretenureSizeThreshold的对象 - 动态年龄判定:Survivor 区中同年龄所有对象大小总和超过 Survivor 空间一半
- 空间分配担保:Minor GC 后存活对象超过 Survivor 容量
老年代的特点:
- 对象存活时间长,死亡率低
- 空间较大,GC 频率低但耗时长
- 采用标记-清除或标记-整理算法
堆的核心特点
| 特点 | 详细说明 | 技术细节 |
|---|---|---|
| 线程共享 | 所有线程共享堆内存 | 需要考虑线程安全,使用 TLAB 优化分配 |
| GC 主战场 | 垃圾回收主要针对堆 | 分代收集、增量收集、并发收集等策略 |
| 动态扩展 | 堆大小可以动态调整 | -Xms 初始大小,-Xmx 最大大小,避免频繁扩容 |
| 分代管理 | 新生代和老年代分别管理 | 不同代采用不同的 GC 算法和回收频率 |
| 内存分配 | 对象和数组的唯一分配区域 | 除了直接内存,所有对象都在堆中分配 |
| TLAB 优化 | 线程本地分配缓冲 | 减少多线程分配时的同步开销 |
堆的配置参数
bash
# 设置堆的初始大小和最大大小
-Xms256m # 堆初始大小 256MB
-Xmx1024m # 堆最大大小 1024MB
# 设置新生代大小
-Xmn128m # 新生代大小 128MB
# 设置新生代与老年代比例
-XX:NewRatio=2 # 老年代:新生代 = 2:1
# 设置 Eden 与 Survivor 比例
-XX:SurvivorRatio=8 # Eden:S0:S1 = 8:1:1
对象在堆中的生命周期
new Object()
│
▼
┌─────────┐
│ Eden │ 对象诞生在 Eden 区
└────┬────┘
│ Minor GC(新生代GC)
▼
┌─────────┐
│ S0/S1 │ 存活对象复制到 Survivor 区,年龄+1
└────┬────┘
│ 年龄达到阈值(默认15)
▼
┌─────────┐
│ 老年代 │ 晋升到老年代
└────┬────┘
│ Major GC / Full GC
▼
被回收 不再被引用的对象被 GC 回收
验证:对象确实在堆中
java
public class HeapDemo {
public static void main(String[] args) {
// 对象在堆中
Object obj = new Object();
// 数组也在堆中
int[] arr = new int[1000];
// 查看堆内存使用情况
Runtime runtime = Runtime.getRuntime();
System.out.println("最大堆内存: " + runtime.maxMemory() / 1024 / 1024 + "MB");
System.out.println("已用堆内存: " + (runtime.totalMemory() - runtime.freeMemory()) / 1024 / 1024 + "MB");
System.out.println("剩余堆内存: " + runtime.freeMemory() / 1024 / 1024 + "MB");
}
}
堆溢出演示
java
import java.util.ArrayList;
import java.util.List;
// 故意制造堆溢出
public class HeapOOM {
public static void main(String[] args) {
List<byte[]> list = new ArrayList<>();
while (true) {
// 不断创建1MB的对象,直到堆溢出
list.add(new byte[1024 * 1024]);
}
}
}
// 运行参数:-Xmx32m
// 结果:java.lang.OutOfMemoryError: Java heap space
4. 方法区(Method Area)------类的信息仓库
生活比喻
方法区就像公司的档案室,存放公司的规章制度、组织架构、员工花名册。所有人都可以查阅,但内容相对稳定。
官方定义
The method area is created on virtual machine start. It stores per-class structures such as the run-time constant pool, field and method data, and the code for methods and constructors.
--- JVM Specification §2.5.4
翻译:方法区在虚拟机启动时创建,存储每个类的结构信息,如运行时常量池、字段和方法数据、方法和构造器的代码。
方法区的历史演变与设计思想
JVM 规范中的方法区定义
根据《Java虚拟机规范》,方法区是各个线程共享的内存区域,它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。虽然《Java虚拟机规范》中把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫作"非堆"(Non-Heap),目的是与Java堆区分开来。
永久代到元空间的演变历程
JDK 版本演变的技术背景:
═══════════════════════════════════════
JDK 1.7 及以前:永久代(PermGen)
┌─────────────────────────────────────────┐
│ 方法区(永久代) │
│ 位置:JVM 堆内存中 │
│ 大小:固定,通过 -XX:PermSize 设置 │
│ │
│ 存储内容: │
│ ├─ 类的元数据信息(Class 对象) │
│ ├─ 运行时常量池 │
│ ├─ 静态变量 │
│ ├─ 字符串常量池 ← 重点! │
│ ├─ 即时编译器编译的代码 │
│ └─ 方法字节码 │
│ │
│ 问题: │
│ ├─ 大小固定,容易 OutOfMemoryError │
│ ├─ 调优困难,需要预估类加载数量 │
│ └─ Full GC 时需要回收,影响性能 │
└─────────────────────────────────────────┘
JDK 1.8 及以后:元空间(Metaspace)
┌─────────────────────────────────────────┐
│ 方法区(元空间) │
│ 位置:本地内存中(堆外内存) │
│ 大小:动态扩展,受限于物理内存 │
│ │
│ 存储内容: │
│ ├─ 类的元数据信息 │
│ ├─ 运行时常量池 │
│ ├─ 静态变量 → 移到堆中! │
│ └─ 即时编译器编译的代码 │
│ │
│ 改进: │
│ ├─ 自动扩展,减少 OOM 风险 │
│ ├─ 不受堆大小限制 │
│ ├─ 减少 Full GC 频率 │
│ └─ 更好的内存利用率 │
└─────────────────────────────────────────┘
关键变化:
1. 字符串常量池:永久代 → 堆内存
2. 静态变量:永久代 → 堆内存
3. 类元数据:永久代 → 元空间(本地内存)
为什么要进行这种演变?
永久代的根本问题:
- 内存限制:永久代大小在启动时确定,难以动态调整
- GC 复杂性:永久代的垃圾回收与堆绑定,增加了 GC 的复杂性
- 调优困难:需要预估应用程序会加载多少类,设置合适的永久代大小
- 内存浪费:为了避免 OOM,往往设置过大的永久代,造成内存浪费
元空间的优势:
- 动态扩展:根据需要自动扩展,最大限制是系统可用内存
- 减少 OOM:不再受固定大小限制,大大减少了 MetaspaceOOM 的可能性
- GC 优化:元空间的垃圾回收独立于堆,简化了 GC 策略
- 内存利用:按需分配,提高了内存利用效率
永久代 vs 元空间对比分析
| 对比维度 | 永久代(JDK ≤ 1.7) | 元空间(JDK ≥ 1.8) | 技术影响 |
|---|---|---|---|
| 存储位置 | JVM 堆内存中 | 本地内存(堆外) | 不受堆大小限制,减少堆压力 |
| 大小限制 | 固定大小,启动时确定 | 动态扩展,受限于物理内存 | 大大减少 OOM 风险 |
| 参数控制 | -XX:PermSize -XX:MaxPermSize |
-XX:MetaspaceSize -XX:MaxMetaspaceSize |
参数更简单,调优更容易 |
| GC 行为 | 与堆一起进行 Full GC | 独立的垃圾回收机制 | 减少 Full GC 频率和时间 |
| 字符串常量池 | 在永久代中 | 移到堆中 | 字符串回收更及时 |
| 静态变量 | 在永久代中 | 移到堆中 | 与对象生命周期一致 |
| 类卸载 | 条件苛刻,很少发生 | 更容易触发类卸载 | 动态类加载场景更友好 |
| 内存监控 | 通过堆监控工具 | 专门的元空间监控 | 监控更精确 |
元空间的内部结构
元空间的内存管理机制:
═══════════════════════════════════════
┌─────────────────────────────────────────┐
│ 元空间(Metaspace) │
│ │
│ ┌─────────────────┐ ┌─────────────────┐│
│ │ 类空间 │ │ 非类空间 ││
│ │ (Class Space) │ │ (Non-Class) ││
│ │ │ │ ││
│ │ 存储: │ │ 存储: ││
│ │ • Klass 结构 │ │ • 方法字节码 ││
│ │ • 类的元数据 │ │ • 常量池 ││
│ │ • 虚函数表 │ │ • 注解信息 ││
│ │ │ │ • 其他元数据 ││
│ │ 特点: │ │ 特点: ││
│ │ • 使用压缩指针 │ │ • 普通指针 ││
│ │ • 内存连续 │ │ • 内存可不连续 ││
│ └─────────────────┘ └─────────────────┘│
│ │
│ 内存分配策略: │
│ ├─ 小块内存:从空闲列表分配 │
│ ├─ 大块内存:直接从操作系统申请 │
│ └─ 内存回收:类卸载时释放对应内存 │
└─────────────────────────────────────────┘
方法区存储内容
方法区存储的内容:
┌─────────────────────────────────────────────────────────┐
│ 方法区(元空间) │
│ │
│ 1. 类信息 │
│ ├─ 类的全限定名(com.bit.agents.ParallelizationWorkflow)│
│ ├─ 父类信息 │
│ ├─ 接口信息 │
│ ├─ 访问修饰符 │
│ └─ 字段信息(名称、类型、修饰符) │
│ │
│ 2. 方法信息 │
│ ├─ 方法名称 │
│ ├─ 返回类型 │
│ ├─ 参数列表 │
│ ├─ 字节码指令 │
│ └─ 异常表 │
│ │
│ 3. 运行时常量池 │
│ ├─ 字面量(字符串、数值) │
│ ├─ 符号引用(类名、方法名、字段名) │
│ └─ 静态常量 │
│ │
│ 4. 静态变量 │
│ └─ static 修饰的变量 │
│ │
│ 5. JIT 编译后的代码 │
│ └─ 热点代码编译后的本地机器码 │
└─────────────────────────────────────────────────────────┘
运行时常量池详解
java
public class ConstantPoolDemo {
// 这些都进入运行时常量池
public static final int MAX_VALUE = 100; // 字面量
public static final String GREETING = "Hello"; // 字符串字面量
public void method() {
// "World" 也是字面量,进入常量池
String s = "World";
// Integer.valueOf 会使用缓存池(-128~127)
Integer a = 127; // 使用缓存
Integer b = 127; // 使用缓存
System.out.println(a == b); // true,指向常量池同一对象
Integer c = 128; // 超出缓存范围,新对象
Integer d = 128; // 超出缓存范围,新对象
System.out.println(c == d); // false,不同对象
}
}
元空间溢出演示
java
import net.sf.cglib.proxy.Enhancer;
import net.sf.cglib.proxy.MethodInterceptor;
// 故意制造元空间溢出
// 运行参数:-XX:MaxMetaspaceSize=32m
public class MetaspaceOOM {
public static void main(String[] args) {
// 动态生成大量类,填满元空间
int count = 0;
while (true) {
// 使用 CGLib 动态创建类
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(MetaspaceOOM.class);
enhancer.setUseCache(false);
enhancer.setCallback((MethodInterceptor) (obj, method, args1, proxy) -> proxy.invokeSuper(obj, args1));
enhancer.create();
count++;
System.out.println("已创建类数量: " + count);
}
}
}
// 结果:java.lang.OutOfMemoryError: Metaspace
5. 虚拟机栈(VM Stack)------方法执行的工作台
生活比喻
虚拟机栈就像员工的个人办公桌,每个员工(线程)有自己的桌子,上面摆着一层层的工作文件(栈帧),最上面的是正在处理的工作。
官方定义
Each Java Virtual Machine thread has a private Java Virtual Machine stack, created at the same time as the thread. A Java Virtual Machine stack stores frames.
--- JVM Specification §2.5.2
翻译:每个 JVM 线程都有一个私有的虚拟机栈,与线程同时创建。虚拟机栈存储栈帧。
虚拟机栈的深度解析
栈帧(Frame)的详细结构
每个栈帧都包含四个主要组成部分,这些组成部分共同支撑着方法的执行:
┌─────────────────────────────────────────────────────────┐
│ 虚拟机栈(VM Stack) │
│ │
│ ┌─────────────────────────────────────────────────────┐│
│ │ 栈帧3(当前执行的方法) ← 栈顶 ││
│ │ ┌───────────────────────────────────────────────┐ ││
│ │ │ 局部变量表(Local Variable Table) │ ││
│ │ │ [0] this [1] arg1 [2] arg2 [3] local1 │ ││
│ │ │ • 存储方法参数和局部变量 │ ││
│ │ │ • 编译期确定大小 │ ││
│ │ │ • 以变量槽(Slot)为单位 │ ││
│ │ ├───────────────────────────────────────────────┤ ││
│ │ │ 操作数栈(Operand Stack) │ ││
│ │ │ 栈顶 → [value3] [value2] [value1] ← 栈底 │ ││
│ │ │ • 存放方法执行过程中的中间计算结果 │ ││
│ │ │ • 编译期确定最大深度 │ ││
│ │ │ • 支持各种数据类型的操作 │ ││
│ │ ├───────────────────────────────────────────────┤ ││
│ │ │ 动态链接(Dynamic Linking) │ ││
│ │ │ • 指向运行时常量池的方法引用 │ ││
│ │ │ • 支持多态性的实现 │ ││
│ │ │ • 方法调用的符号引用解析 │ ││
│ │ ├───────────────────────────────────────────────┤ ││
│ │ │ 方法返回地址(Return Address) │ ││
│ │ │ • 正常退出:调用者的PC计数器值 │ ││
│ │ │ • 异常退出:异常处理器表确定返回地址 │ ││
│ │ │ • 恢复上层方法的执行状态 │ ││
│ │ └───────────────────────────────────────────────┘ ││
│ ├─────────────────────────────────────────────────────┤│
│ │ 栈帧2(调用当前方法的方法) ││
│ ├─────────────────────────────────────────────────────┤│
│ │ 栈帧1(最开始调用的方法) ││
│ └─────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────┘
栈的操作特性:
• 后进先出(LIFO)数据结构
• 方法调用 → 压入栈帧(Push)
• 方法返回 → 弹出栈帧(Pop)
• 只有栈顶的栈帧是有效的(当前栈帧)
局部变量表的详细机制
变量槽(Slot)的概念:
-
局部变量表的容量以变量槽为最小单位
-
一个槽可以存放32位以内的数据类型
-
64位数据类型(long、double)占用两个连续的槽
局部变量表的槽位分配:
═══════════════════════════════════════实例方法:
┌─────┬─────┬─────┬─────┬─────┬─────┐
│ 0 │ 1 │ 2 │ 3 │ 4 │ 5 │
│this │arg1 │arg2 │local│long │long │
│ │ │ │ var │(低) │(高) │
└─────┴─────┴─────┴─────┴─────┴─────┘静态方法(无this):
┌─────┬─────┬─────┬─────┬─────┬─────┐
│ 0 │ 1 │ 2 │ 3 │ 4 │ 5 │
│arg1 │arg2 │local│long │long │ │
│ │ │ var │(低) │(高) │ │
└─────┴─────┴─────┴─────┴─────┴─────┘槽位复用机制:
• 当局部变量超出作用域后,其占用的槽位可以被其他变量复用
• 这种复用有助于节省栈帧空间
• 但可能影响垃圾回收(变量仍被局部变量表引用)
操作数栈的工作原理
操作数栈是一个后入先出的栈,用于存放方法执行过程中各种字节码指令的操作数。
操作数栈的典型操作流程:
═══════════════════════════════════════
以 int c = a + b; 为例:
1. iload_1 // 将局部变量表slot[1]的值压入操作数栈
操作数栈: [a的值]
2. iload_2 // 将局部变量表slot[2]的值压入操作数栈
操作数栈: [a的值] [b的值]
3. iadd // 弹出栈顶两个值,相加后将结果压入栈
操作数栈: [a+b的结果]
4. istore_3 // 将栈顶值弹出,存入局部变量表slot[3]
操作数栈: []
局部变量表slot[3]: a+b的结果
方法调用与栈帧的关系
java
public class StackDemo {
public static void main(String[] args) { // 栈帧1
int result = add(1, 2); // 栈帧2 被压入
System.out.println(result); // 栈帧3 被压入
}
public static int add(int a, int b) { // 栈帧2
int sum = a + b; // 局部变量:sum
return sum; // 返回,栈帧2弹出
}
}
执行过程:
═══════════════════════════════════════
1. main() 被调用
栈: [main栈帧]
2. add(1, 2) 被调用
栈: [main栈帧] → [add栈帧]
3. add() 返回
栈: [main栈帧] ← add栈帧弹出
4. println() 被调用
栈: [main栈帧] → [println栈帧]
5. println() 返回
栈: [main栈帧] ← println栈帧弹出
6. main() 返回
栈: 空 ← main栈帧弹出
局部变量表详解
java
public void example(String name, int age) {
// 局部变量表的索引:
// [0] = this(非静态方法才有)
// [1] = name(方法参数)
// [2] = age(方法参数)
String city = "北京"; // [3] = city(局部变量)
int score = 100; // [4] = score(局部变量)
// 注意:局部变量表的大小在编译期就确定了
// 可以通过 javap -v 查看局部变量表
}
虚拟机栈的核心特点
| 特点 | 说明 |
|---|---|
| 线程私有 | 每个线程有自己的栈 |
| 存放栈帧 | 每个方法调用对应一个栈帧 |
| 后进先出 | 方法调用压栈,方法返回弹栈 |
| 可能 OOM | StackOverflowError(递归太深)或 OutOfMemoryError(无法创建新线程) |
| 大小可配 | -Xss 设置每个线程的栈大小 |
栈溢出演示
java
// StackOverflowError:递归调用太深
public class StackOverflowDemo {
private static int count = 0;
public static void recursion() {
count++;
recursion(); // 无限递归,栈帧不断压入
}
public static void main(String[] args) {
try {
recursion();
} catch (StackOverflowError e) {
System.out.println("递归深度: " + count);
}
}
}
// 运行参数:-Xss256k(减小栈大小,更快溢出)
// 结果:java.lang.StackOverflowError,递归深度约几千次
6. 本地方法栈(Native Method Stack)------native 方法的舞台
生活比喻
本地方法栈就像接待外部客户的会议室,当公司需要和外部机构(操作系统、C/C++ 库)打交道时,就在这里进行。
官方定义
An implementation of the Java Virtual Machine may use conventional stacks (colloquially called "C stacks") to support native methods.
--- JVM Specification §2.5.6
翻译:JVM 实现可以使用传统的栈(俗称"C 栈")来支持 native 方法。
什么是 native 方法?
java
// native 方法:由 C/C++ 实现,不在 Java 中写方法体
public class Object {
// hashCode 的实现依赖操作系统
public native int hashCode();
}
public class System {
// currentTimeMillis 由操作系统提供
public static native long currentTimeMillis();
// 数组拷贝由底层实现
public static native void arraycopy(Object src, int srcPos,
Object dest, int destPos, int length);
}
public class Thread {
// 线程的启动依赖操作系统的线程API
private native void start0();
}
本地方法栈 vs 虚拟机栈
| 对比 | 虚拟机栈 | 本地方法栈 |
|---|---|---|
| 服务对象 | Java 方法 | native 方法 |
| 实现语言 | Java 字节码 | C/C++ 等本地代码 |
| 结构 | 栈帧 | 类似 C 栈的结构 |
| HotSpot 实现 | 二者合二为一 | 直接使用虚拟机栈 |
注意:HotSpot 虚拟机将虚拟机栈和本地方法栈合二为一,不区分两者。
7. 程序计数器(PC Register)------线程的导航仪
生活比喻
程序计数器就像员工的工位号牌,标记着当前正在处理哪项工作,下一步该做什么。每个员工(线程)都有自己的号牌,互不干扰。
官方定义
The Java Virtual Machine can support many threads of execution at once. Each Java Virtual Machine thread has its own pc (program counter) register.
--- JVM Specification §2.5.1
翻译:JVM 可以同时支持多个执行线程。每个 JVM 线程都有自己的程序计数器寄存器。
程序计数器的深度解析
程序计数器的作用机制
程序计数器是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。在虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。
程序计数器的工作原理:
═══════════════════════════════════════
Java 源码:
public int add(int a, int b) {
int sum = a + b; ← PC = 0
return sum; ← PC = 4
}
编译后的字节码:
0: iload_1 ← PC 指向这里
1: iload_2
2: iadd
3: istore_3
4: iload_3 ← 执行到这里时,PC = 4
5: ireturn
PC 寄存器的变化:
线程执行 → PC = 0 → PC = 1 → PC = 2 → PC = 3 → PC = 4 → PC = 5
程序计数器的特殊性
1. 唯一不会 OOM 的区域
- 程序计数器是 JVM 规范中唯一没有规定 OutOfMemoryError 情况的区域
- 因为它只是存储一个地址值,占用内存极小且固定
2. 线程切换的关键
多线程环境下的 PC 寄存器:
═══════════════════════════════════════
时间片1:线程A执行
Thread-A PC = 15 ← 执行到字节码指令15
时间片2:线程B执行
Thread-B PC = 8 ← 执行到字节码指令8
时间片3:线程A恢复
Thread-A PC = 16 ← 从指令16继续执行(15的下一条)
每个线程都有独立的PC寄存器,确保线程切换后能恢复到正确位置
3. native 方法的特殊处理
- 当线程执行 Java 方法时,PC 寄存器记录正在执行的虚拟机字节码指令地址
- 当线程执行 native 方法时,PC 寄存器的值为空(Undefined)
程序计数器的核心特点
| 特点 | 详细说明 | 技术意义 |
|---|---|---|
| 线程私有 | 每个线程独有一个PC寄存器 | 支持多线程并发执行 |
| 内存最小 | 占用内存空间极小 | 几乎不消耗系统资源 |
| 永不OOM | 唯一不会OutOfMemoryError的区域 | 系统稳定性的保障 |
| 执行指针 | 指向当前执行的字节码指令 | 程序执行流程的控制核心 |
| 线程切换 | 保存和恢复线程执行位置 | 多线程调度的基础 |
程序计数器就像员工的工作进度牌,记录"我当前做到哪一步了"。如果被领导叫去做别的事(线程切换),回来后看进度牌就知道从哪里继续。
官方定义
At any point in time, a thread that is executing a method has a program counter that stores the address of the current instruction being executed.
--- JVM Specification §2.5.1
翻译:在任何时刻,正在执行方法的线程都有一个程序计数器,存储当前正在执行的字节码指令的地址。
程序计数器的作用
为什么需要程序计数器?
═══════════════════════════════════════
CPU 是通过时间片轮转来执行多个线程的:
线程A: ┃执行┃ ┃执行┃ ┃执行┃
线程B: ┃执行┃ ┃执行┃
线程C: ┃执行┃ ┃执行┃
线程A 被挂起后,恢复执行时怎么知道从哪继续?
→ 靠程序计数器记录的地址!
程序计数器的核心特点
| 特点 | 说明 |
|---|---|
| 线程私有 | 每个线程有自己的计数器 |
| 存储指令地址 | 当前执行的字节码行号 |
| 唯一不会 OOM | 占用空间极小,不会溢出 |
| 执行 native 方法时为空 | native 方法不是字节码,没有地址 |
为什么程序计数器不会 OOM?
程序计数器只存一个地址值(一个整数)
→ 固定大小,不会增长
→ 不可能溢出
这是 JVM 规范中唯一不会发生 OOM 的区域
8. 直接内存(Direct Memory)------堆外的特殊区域
生活比喻
直接内存就像公司租的外部仓库,不在公司大楼内(堆外),但公司可以使用。适合存放大量、不频繁变动的货物(数据),减少搬运成本。
什么是直接内存?
普通 IO:
┌──────────┐ ┌──────────┐
│ JVM 堆 │ 拷贝 │ 操作系统 │
│ byte[] │ ──────→ │ IO 缓冲区 │
│ │ ←────── │ │
└──────────┘ 拷贝 └──────────┘
需要2次数据拷贝
NIO Direct Buffer:
┌──────────┐ ┌──────────┐
│ JVM 堆 │ │ 操作系统 │
│ │ │ 直接缓冲区│ ← Java 直接操作这块内存
│ │ │ │
└──────────┘ └──────────┘
不需要拷贝,性能更高
直接内存的使用
java
// 使用 NIO 的 DirectByteBuffer
ByteBuffer buffer = ByteBuffer.allocateDirect(1024 * 1024); // 分配1MB直接内存
// Spring AI 中使用直接内存的场景
// - 向量数据的批量传输
// - 大文件的零拷贝读取
// - 网络通信的缓冲区
直接内存的特点
| 特点 | 说明 |
|---|---|
| 不在堆中 | 属于本地内存 |
| 零拷贝 | 减少 JVM 堆与操作系统之间的数据拷贝 |
| 分配昂贵 | 分配和释放比堆内存慢 |
| 不受堆大小限制 | 但受物理内存限制 |
| 可能 OOM | OutOfMemoryError: Direct buffer memory |
9. 线程共享 vs 线程私有
全景对比
┌─────────────────────────────────────────────────────────────┐
│ JVM 内存区域 │
│ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ 线程共享区域 │ │
│ │ │ │
│ │ 堆(Heap) 方法区(Method Area/Metaspace) │ │
│ │ ├─ 对象实例 ├─ 类信息 │ │
│ │ ├─ 数组 ├─ 常量池 │ │
│ │ └─ 字符串常量池(JDK8) ├─ 静态变量 │ │
│ │ └─ JIT编译代码 │ │
│ │ │ │
│ │ 特点:需要同步,GC 管理,可能 OOM │ │
│ └───────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ 线程私有区域 │ │
│ │ │ │
│ │ 虚拟机栈 本地方法栈 程序计数器 │ │
│ │ ├─ 栈帧 ├─ native方法 ├─ 字节码地址 │ │
│ │ ├─ 局部变量表 ├─ C栈结构 ├─ 唯一不OOM │ │
│ │ ├─ 操作数栈 └─ └─ │ │
│ │ └─ 动态链接 │ │
│ │ │ │
│ │ 特点:不需要同步,随线程生死,可能 StackOverflow │ │
│ └───────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
对比表
| 区域 | 线程共享/私有 | 存储内容 | 是否会 OOM | GC 管理 |
|---|---|---|---|---|
| 堆 | 共享 | 对象、数组 | ✅ 会 | ✅ 是 |
| 方法区 | 共享 | 类信息、常量池 | ✅ 会 | ✅ 是 |
| 虚拟机栈 | 私有 | 栈帧 | ✅ 会(StackOverflow) | ❌ 否 |
| 本地方法栈 | 私有 | native 方法调用 | ✅ 会 | ❌ 否 |
| 程序计数器 | 私有 | 字节码地址 | ❌ 不会 | ❌ 否 |
10. 常见面试题精选
Q1:JVM 内存区域划分?
回答思路:
JVM 内存区域分为5个区域:
1. 堆 --- 线程共享,存对象和数组,GC 主战场
2. 方法区 --- 线程共享,存类信息和常量池(JDK8后为元空间)
3. 虚拟机栈 --- 线程私有,存方法调用的栈帧
4. 本地方法栈 --- 线程私有,为 native 方法服务
5. 程序计数器 --- 线程私有,存当前执行地址,唯一不会 OOM
其中堆和方法区是线程共享的,其余三个是线程私有的。
Q2:堆和栈的区别?
| 对比 | 堆 | 栈 |
|---|---|---|
| 存储内容 | 对象、数组 | 基本类型、对象引用、栈帧 |
| 线程关系 | 共享 | 私有 |
| 生命周期 | GC 管理 | 随方法调用结束 |
| 大小 | 大,可配置 | 小,固定 |
| 速度 | 慢(需要 GC) | 快(自动分配释放) |
| 异常 | OutOfMemoryError | StackOverflowError |
Q3:方法区和永久代、元空间的关系?
方法区是 JVM 规范中的概念
永久代和元空间是 HotSpot 虚拟机的具体实现
关系:
方法区(规范) → 永久代(JDK7实现) → 元空间(JDK8实现)
关键变化:JDK8 把永久代中的字符串常量池移到了堆中,
其余部分移到了本地内存中的元空间
Q4:为什么把永久代改为元空间?
- 永久代大小固定,容易 OOM;元空间使用本地内存,自动扩容
- 为 JRockit 合并铺路,JRockit 没有永久代概念
- 字符串常量池移到堆中,更容易被 GC 管理
- 减少 Full GC,元空间不与堆绑定,GC 更高效
Q5:什么情况下会 StackOverflow?
- 递归调用太深:没有终止条件的递归
- 方法调用链太长:A→B→C→D→...无限嵌套
- 栈大小太小 :
-Xss设置过小
java
// 典型 StackOverflow 场景
public void recursion() {
recursion(); // 无终止条件
}
Q6:程序计数器为什么不会 OOM?
因为程序计数器只存储一个字节码指令的地址(一个整数),大小固定,不会增长,所以不可能溢出。这是 JVM 规范中唯一不会发生 OOM 的区域。
11. 总结
一张图记住所有区域
JVM 内存区域速记口诀:
═══════════════════════════════════════
"堆方栈本计"(堆放栈本计)
堆 --- 对象的家,GC 的主战场
方 --- 方法区,类的信息仓库(JDK8后叫元空间)
栈 --- 虚拟机栈,方法执行的工作台
本 --- 本地方法栈,native 方法的舞台
计 --- 程序计数器,线程的导航仪(唯一不 OOM)
关键知识点回顾
| 知识点 | 核心内容 |
|---|---|
| 堆 | 存对象和数组,分新生代和老年代,GC 主战场 |
| 方法区 | 存类信息,JDK8后变为元空间,使用本地内存 |
| 虚拟机栈 | 存栈帧,包含局部变量表和操作数栈,可能 StackOverflow |
| 本地方法栈 | 为 native 方法服务,HotSpot 中与虚拟机栈合并 |
| 程序计数器 | 存当前执行地址,唯一不会 OOM 的区域 |
| 线程共享 | 堆、方法区 |
| 线程私有 | 虚拟机栈、本地方法栈、程序计数器 |
下一篇预告:《JVM 类加载机制详解------从 .class 文件到对象诞生》