JVM 运行时数据区域
JVM 运行时数据区域规定线程、对象和类型数据在运行时怎样组织。本文先介绍各区域保存什么、由哪些线程共享,再通过栈帧示例说明局部变量与对象的关系。最后把这些逻辑区域与 HotSpot 的进程内存联系起来。
1. 面试核心
先记住这几条,再读正文- 线程私有与共享区域。 程序计数器、JVM 栈和本地方法栈由各线程独有,堆和方法区由所有线程共享。卡 · 程序计数器
- 栈帧生命周期。 栈帧随方法调用创建,保存局部变量、操作数栈及调用状态,并在方法结束后销毁。
- 引用与对象。 堆管理对象与数组,栈帧中的引用值指向对象。卡 · 虚拟机栈
- 局部变量槽位。
long和double占两个局部变量槽位,实例方法的槽位 0 保存this。卡 · 局部变量槽位与操作数栈 - 类元数据实现。 HotSpot 自 JDK 8 起用本地内存中的 Metaspace 管理类元数据。
2. 程序计数器
线程切换后,需要知道从哪条指令继续执行。每个线程都有独立的程序计数器,用来记录非 native 方法中当前 JVM 指令的位置。native 方法指由 C、C++ 等语言实现的方法,执行它时,程序计数器的值在规范中未定义。
程序计数器保存的信息很少,JVM 规范没有为它规定内存溢出异常。恢复线程时,JVM 还需要恢复栈帧等调用状态,才能接着执行之前的计算。
3. 虚拟机栈
JVM 栈保存一个线程的方法调用过程。每次调用方法时,JVM 创建一个栈帧,也就是本次调用需要的一组数据。方法正常返回或异常退出后,对应的栈帧就会销毁。
栈帧主要保存以下四类数据:
- 局部变量表保存方法参数和局部变量。
- 操作数栈保存指令计算所需的值和中间结果。
- 当前类的运行时常量池引用用于动态链接,让符号引用关联到运行时目标。
- 调用状态用于方法返回和异常处理,让执行能回到调用方或转入异常处理代码。
递归过深会超过栈深限制,产生 StackOverflowError。大量平台线程创建失败还会报 OutOfMemoryError: unable to create native thread。这类失败要结合进程内存、系统线程限制和容器配额判断。
具体诊断见 OutOfMemoryError 与 StackOverflowError。
3.1 局部变量槽位与操作数栈
槽位是局部变量表存放值的逻辑单位。下面用一次加法说明参数、局部变量和中间结果分别放在哪里:
static long sum(int left, int right) {
long result = (long) left + right;
return result;
}
left、right 分别占槽位 0、1,result 是 long,占槽位 2、3。执行时,指令把参数读入操作数栈,转换为 long 后相加。结果再存入 result,最后返回,所以加法的中间计算与局部变量的保存分属两种结构。
实例方法会先用槽位 0 保存 this,显式参数再使用后面的槽位。long 和 double 各占两个槽位,引用占一个。这些槽位描述的是逻辑容量,实际字节布局由 JVM 决定。
Class 文件的 Code 属性用 max_locals 记录局部变量表容量,用 max_stack 记录操作数栈所需的最大深度。即时编译器(JIT)在运行时把热点代码编译为机器码。编译后,值可以放进寄存器,不再使用的位置也可以复用。
4. 堆
堆保存对象和数组,由所有线程共享,也是垃圾收集器(GC)管理的主要区域。对象的生命周期由可达性决定,因此方法返回后,被其他地方引用的对象仍能继续使用。规范允许堆采用非连续物理布局,是否分代、使用哪种收集器则由实现决定。
JIT 可以消除某些完整对象的分配,同时保留程序可观察到的行为。原理见 逃逸分析与标量替换。
HotSpot 用 -Xms 设置初始堆大小,用 -Xmx 设置最大堆。读内存统计时,需要区分三种容量:
- reserved 是保留的虚拟地址范围,供后续使用。
- committed 是已承诺可供 JVM 使用的内存。
- used 是当前已经使用的空间。
因此,保留了多少地址、当前可用多少内存,以及已经用了多少空间,需要分别观察。
5. 方法区
方法区保存类或接口在运行时所需的结构,由所有线程共享。其中包括字段与方法信息、方法代码和运行时常量池。规范定义的是这些数据的职责,物理布局和回收策略由 JVM 实现选择。
HotSpot 在 JDK 8 移除永久代,用 Metaspace 管理类元数据。方法区是规范中的逻辑区域,元空间是实现中的存储机制。
静态字段属于类状态,随对应类及其定义加载器的生命周期存在。在 HotSpot 17、21 中,静态字段值随堆中的 Class 镜像存放。Class 镜像就是 Java 代码看到的 Class 对象。类元数据则使用元空间,所以与类相关的数据实际分布在多个区域。
6. 运行时常量池
运行时常量池让类在执行时取得常量,并找到需要使用的其他类型和成员。每个类或接口各有一份,由 Class 文件常量池构造,属于方法区。它保存字面量,以及类、字段和方法的符号引用。符号引用用名称和类型描述目标,解析后再关联到运行时目标。
解析过程见 类加载。
字符串驻留池则供 String.intern() 查找内容相同的规范实例,也就是可复用的同一个字符串对象。Class 文件常量池是文件中的表,运行时常量池属于每个运行时类型,字符串驻留池负责共享字符串实例。三者需要分别理解。
7. 本地方法栈
Java 调用本地方法时,也需要保存本地函数的参数和调用状态,这就是本地方法栈承担的职责。HotSpot 可让 Java 调用和本地调用共用底层线程栈。也就是说,规范区分两者的职责,实现可以合并物理存储。
JNI 是 Java 调用本地代码的接口。调用过程中还会使用本地堆、对象句柄和本地库状态,这些分配也计入进程内存。
8. 进程内存的其他组成
操作系统统计整个 Java 进程时,看到的内存比 Java 堆更广。除了堆,还要为以下进程内存组成预留容量:
- 元空间与压缩类空间保存类元数据。
- Code Cache 保存 JIT 生成的机器码。
- GC 数据结构用于记录对象的存活和引用关系。
- 平台线程栈保存各线程的调用状态。
- DirectByteBuffer 与 JNI 分配会使用本地内存。
- 内存映射文件与动态链接库也占用进程地址空间。
比如,-Xmx=4g 只把 Java 堆限制为 4 GiB,进程的总内存还要加上这些区域。配置容器容量时,应为它们和 JVM 自身的开销留出余量。
各区域的统计和容量关系见 Java 直接内存。
9. 常见问题
规范描述的是数据职责,HotSpot 决定具体怎样存放。下面几个问题都需要把这两个层次分开。
9.1 局部变量的物理位置
局部变量在规范中使用栈帧槽位描述,实际执行时也可以保存在寄存器中。JIT 还能消除无用计算,因此源码中的变量未必对应一块固定栈内存。局部引用保存的是访问对象的引用值,对象本身的存储要另外判断。
9.2 方法区的实现差异
方法区规定的是类型数据的逻辑职责,永久代和元空间则是 HotSpot 不同时期的实现方案。其他 JVM 可以选择不同的存储结构,所以要先说明讨论的是规范还是具体实现。
9.3 直接内存的归属
NIO 直接缓冲区的存储位于 Java 堆外,不属于 JVMS 列出的运行时数据区域。它仍计入进程内存,所以排查进程内存增长时也要检查这部分分配。
具体管理见 Java 直接内存。
10 · 面试题卡片
10.1 JVM 有哪些运行时数据区域,哪些在线程间共享
出现公司:暂无已核验的公开来源(本站自测)
考察重点:说明各区域保存什么、由哪些线程共享,并区分方法区的规范职责与元空间的实现。
参考回答
程序计数器、JVM 栈和本地方法栈由各线程独有,堆和方法区在线程之间共享。
方法调用会创建栈帧,用来保存参数、局部变量和调用状态。堆保存对象与数组,方法区保存类型的运行时结构。每个类或接口还有一份运行时常量池,它属于方法区。
这些是规范中的逻辑区域,HotSpot 用元空间管理类元数据。进程内存还包含 Code Cache、直接内存等部分,所以 -Xmx 只限制堆的容量。
10.2 堆和栈的区别是什么
出现公司:暂无已核验的公开来源(本站自测)
考察重点:比较堆和栈的生命周期、共享范围与内存管理方式,并区分引用变量和对象本身。
参考回答
JVM 栈由各线程独有,保存方法调用状态;堆由线程共享,保存 GC 管理的对象和数组。
栈帧随方法调用创建,在方法返回或异常退出后销毁。堆中对象则按可达性回收,可以在创建它的方法结束后继续存在。栈帧中的引用值用于访问对象,基本类型字段也会随对象存放,因此位置取决于数据所属的结构。
递归超过栈深限制时会报 StackOverflowError。堆无法为新对象提供空间时,会报 OutOfMemoryError: Java heap space。JIT 还可以把值放进寄存器或消除完整对象分配,实际物理布局需要结合实现判断。
10.3 long 局部变量占几个槽位,实例方法的槽位 0 保存什么?
出现公司:暂无已核验的公开来源(本站自测)
考察重点:说明 long、引用和 this 怎样使用槽位,并区分逻辑容量与实际机器布局。
相关内容:局部变量槽位与操作数栈。
参考回答
long 占两个局部变量槽位,实例方法的槽位 0 保存 this。
显式参数从后续槽位开始排列,double 同样占两个槽位,引用值占一个。槽位表示规范中的逻辑容量,所以一个槽位并不对应所有 JVM 上固定的字节数。实际机器布局由 JVM 决定。