运行时数据区
JVM将执行Java程序过程中所管理的内存划分为若干区域。这些区域有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而一直存在,有些区域则是依赖用户线程的启动和结束而建立和销毁。
在《Java虚拟机规范》中规定,内存将包括以下几个运行时数据区域:
-
线程私有的区域:
- 程序计数器
- 虚拟机栈
- 本地方法栈
-
线程公共的区域:
- 方法区
- 堆
线程私有区域
程序计数器
程序计数器可以看作当前线程所执行的字节码行号指示器。字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
因此多线程是通过线程轮流分配CPU来实现的,因此每个线程内部都需要有一个独立的程序计数器。
需要注意的是:
- 当线程正在执行一个
Java方法,程序计数器记录的是当前执行的字节码指令的地址。 - 当线程正在执行一个
Native方法,程序计数器记录的是Undefined。 - 程序计数器是唯一一个不会出现
OOM的区域
虚拟机栈
虚拟机栈描述的是Java方法执行的线程内存模型:每个方法被执行的时候,jvm都会同步创建一个栈帧用于存储局部变量表、操作数栈、动态连接、方法出口等信息。
栈帧中存放着:
- 局部变量表:存储方法中的方法参数和全部局部变量
- 操作数栈:字节码指令的工作区
- 动态链接:指向运行时常量池的方法引用
- 方法返回地址:方法退出后回到哪里
- 附加信息:调试、性能监控等
每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
在这个内存区域,存在两种异常情况:
- 如果线程请求栈的深度大于虚拟机允许的深度,就会抛出
StackOverflowError - 如果
JVM的栈容量可以动态扩展,扩展到无法申请到足够的内存就会抛出OOMHotSpot虚拟机不可用动态扩展,但是在线程申请栈空间不成功时,一样会抛出OOM
局部变量表
在局部变量表中,存储了:
- 方法参数
- 方法内部定义的全部局部变量
- 实例方法中隐式存在的
this
在局部变量表中,存放了编译期可知的各种Java虚拟机基本数据类型、对象引用和returnAddress类型(指向了一条字节码指令的地址,被无条件转移指令使用)。
这些数据类型在局部变量表中的存储空间以局部变量槽来表示,64位的long和double类型的数据会占用两个变量槽,其余都只占一个变量槽。
| 类型 | 占用槽位(Slot) |
|---|---|
| boolean / byte / char / short / int / float | 1 个 Slot |
| reference(对象引用) | 1 个 Slot |
| long / double | 2 个 Slot |
| 这些数据类型所占的全部内存空间的大小在编译期间确定,因此当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小,但是会随着操作数栈的工作,局部变量表的内容会改变。 |
下面给出一个示例:
public void foo(int a, String s) {
int x = 10;
}
对于上述这段代码,其局部变量如下:
| Slot 索引 | 内容 | 说明 |
|---|---|---|
| 0 | this | 实例方法的隐式引用 |
| 1 | a | 方法参数,int 值 直接存储 |
| 2 | s | 方法参数,String 的引用 |
| 3 | x | 局部变量,值 10 直接存储 |
如果方法是实例方法,那么Slot 0中存储this。
操作数栈
操作数栈是一个后进先出的栈结构,用于在方法执行过程中临时存放计算所需的中间结果。它只能通过push和pop两个操作访问,存储的单位是Slot,该栈的最大深度在编译期确定,写在Code属性中,栈中的内容随着字节码的执行而变化。
可以将局部变量表理解为一个仓库,而操作数栈是一个工作台,随着操作数栈的工作,不断从局部变量中取数据并且写入数据,局部变量表中的内容也会随着改变。
它存储的内容包括:
- 中间计算结果
- 指令操作数
下面给出一个示例:
int a = 1;
int b = 2;
int c = a + b;
对于上述代码,在字节码层面:
iconst_1 // 1 → 操作数栈
istore_0 // 操作数栈 → 局部变量表[0]
iconst_2 // 2 → 操作数栈
istore_1 // 操作数栈 → 局部变量表[1]
iload_0 // 将局部变量表[0] → 操作数栈
iload_1 // 将局部变量表[1] → 操作数栈
iadd // 弹出两个,相加,结果压回
istore_2 // 存入局部变量表[2]
JVM的操作数栈和物理中的CPU是什么关系?
操作数栈是JVM规范里的一个“虚拟概念”,而CPU是真实存在的“物理硬件”。它们之间隔着一个巨大的鸿沟,无法直接对话。为了跨越这个鸿沟,JVM扮演了 中间人的角色。具体来说,它们之间的关系会随着代码的执行方式(解释执行 vs 即时编译)发生截然不同的变化。
解释执行阶段:JVM 用 CPU 指令“模拟”操作数栈
刚启动Java程序时,代码默认处于解释执行模式。此时,操作数栈是货真价实地存在于RAM 中的。但是,CPU天生根本不认识什么操作数栈,它只认识自己的寄存器和系统栈 。所以,JVM的解释器必须用软件层面的逻辑,把操作数栈的指令“翻译”成CPU能懂的机器指令。
底层执行流程如下:
- 读取字节码:
JVM解释器读到iadd(整数加法)指令。 - 访存(Load) :
JVM指示CPU将操作数栈顶的两个值从内存中读取出来,暂存到CPU的通用寄存器中。 - 计算(Execute) :
CPU执行自身的ADD汇编指令,在寄存器内完成加法运算。 - 写回(Store) :
JVM再指示CPU将寄存器中的结果写回到主内存的操作数栈中。
在这个阶段,操作数栈完全由内存和JVM软件逻辑在支撑。CPU只是在机械地执行JVM发出的“取数-计算-写回”的底层指令。这是一种典型的“脱裤子放屁”但不得不为之的跨平台妥协, 为了实现和硬件无关。
JIT 编译阶段:操作数栈被彻底“物理化”
由于解释执行效率太低,HotSpot JVM内置了 JIT 。当某段代码被频繁执行,成为热点代码时,JIT会将其直接编译成本地机器码。
在这个阶段,操作数栈发生了翻天覆地的变化——它大概率会直接消失,或者转化为 CPU 的物理寄存器。
对于下面这个代码:
// 源码
int a = b + c;
在解释执行时,这会表现为:把 b、c压入操作数栈,执行 iadd,再把结果存回。
但在JIT编译后,生成的本地机器码可能直接变成:
mov eax, [b的内存地址] ; 将 b 载入 CPU 的 eax 寄存器
add eax, [c的内存地址] ; 加上 c
mov [a的内存地址], eax ; 存回 a
动态链接
每一个栈帧中都持有一个指向当前方法所属类的运行时常量池的引用,用于支持方法调用过程中的符号引用解析。这个引用就被称之为动态链接。
在编译期间,方法调用用的是符号引用,而在运行期间,JVM通过动态链接把符号引用变成直接引用。
| 链接方式 | 时机 | 示例 |
|---|---|---|
| 静态链接 | 编译期 / 类加载阶段 | invokestatic |
| 动态链接 | 运行期(方法调用时) | invokevirtual |
在引入示例之前,先介绍一下vtable以及itable:
在 HotSpot JVM 中,vtable虚函数表和itable接口方法表是实现Java多态(动态绑定)的核心数据结构。它们都位于Class的方法区 中,用于解决:
运行时到底该调用哪个具体实现的方法?
在Java中:
Animal a = new Dog();
a.eat();
编译期只知道 a的类型是 Animal,运行期才知道真正对象是 Dog。JVM必须在运行时完成方法的分发,因此不能够每次都一个个遍历方法列表,所以需要O(1)的查找结构:
| 场景 | 使用的表 |
|---|---|
普通类方法(invokevirtual) | vtable |
接口方法(invokeinterface) | itable |
vtable是每个类存储所有可重写方法的实际入口地址的表,子类的vtable继承并且覆盖父类的vtable。
假设有一个类:
class A {
void m1() {}
void m2() {}
}
class B extends A {
void m2() {}
}
它们的vtable如下:
| 索引 | A 的 vtable | B 的 vtable |
|---|---|---|
| 0 | A.m1 | B.m1 |
| 1 | A.m2 | B.m2 |
按照索引,方法的顺序严格一致,子类只是替换指针。
因为一个类可以实现多个接口,且接口方法没有层级顺序,无法像vtable那样线性继承,因此引入itable。
每个类为每个接口维护一张itable,每张 itable 存储该接口的 方法实现地址
Class C
├── vtable
├── itable for InterfaceA
│ ├── InterfaceA.m1 → C.m1
│ └── InterfaceA.m2 → C.m2
└── itable for InterfaceB
└── InterfaceB.m1 → C.m3
因为有多个接口表,所以查找的速度比vtable要慢。
这些可以被重写的方法都称之为虚方法下面给出一个示例:
class A {
void foo() {
B.bar();
}
}
这段代码编译后,在A.class中:
Constant Pool:
#1 = Methodref #2.#3 // B.bar:()V
#2 = Class #4 // B
#3 = NameAndType #5:#6
这里面只有符号,而不存在真实的内存地址
-
非虚方法(
static/private/final/<init>)- 在 类加载的解析阶段 完成符号 → 直接引用
-
虚方法
- 延迟到 运行期 才真正绑定
在栈帧中,动态链接是一个指向运行时常量池的指针。
每一个栈帧都对应着一个方法,每一个方法都属于着一个类,每一个类都有一个运行时常量池,栈帧可以通过动态链接找到这个方法所需的符号引用。
下面给出一个invokevirtual示例:
Animal a = new Dog();
a.speak();
这段代码编译成字节码:
invokevirtual #7 // Method Animal.speak:()V
以下是执行流程:
- 栈帧中的动态链接指向当前方法所属类的运行时常量池
- 通过常量池解析
Animal.speak,得到其在 vtable 中的 index(类加载时确定) - 从局部变量表中获取
a的引用 - 通过对象头中的 Klass Pointer 找到
Dog的类元数据 - 在
Dog的 vtable 中用同一 index 找到真正执行的方法
这个本质就是动态绑定和多态的底层实现,通过父类的方法调用解析出该方法在父类的vtable的index,再通过局部变量表的实际引用和index直接锁定子类实际执行的方法。
方法返回地址
方法返回地址是一个指向调用者方法中下一条待执行指令的指针。它记录了方法执行结束后,JVM应该从哪里继续执行。
本地方法栈
本地方法栈和虚拟机栈是相似的,只是一个服务于Java方法,一个服务于Native方法。
《Java虚拟机规范》对本地方法栈中方法使用的语言、使用方式与数据结构并没有任何强制规定,因此具体的虚拟机可以根据需要自由实现它,甚至有的JVM(譬如HotSpot)直接就把本地方法栈和虚拟机栈合二为一。
线程共有区域
堆
堆是所有内存区域中最大的一块,被所有线程共享,在虚拟机启动时创建。
这块区域的目的就是为了存放对象实例,Java的几乎所有对象实例都是在这里分配内存。与此同时,堆也是垃圾回收器管理的内存区域。
如果一个对象不会逃逸出当前方法胡总和线程,那么被视为未逃逸,这时可能分配在栈上,将其拆分为多个基本类型,直接在栈帧中的局部变量表存储,不存在对象头
在这个内存区域,如果没有内存来完成实例分配时,会抛出OOM异常。
方法区
方法区和堆一样,都是可以被所有线程所共享,它用于存储已经被虚拟机加载的类型信息、常量、静态变量、以及由JIT编译后的代码缓存。
静态变量的存储详解
public class StaticObjectExample { // 情况1:基本类型静态变量 private static int count = 10; // 值直接存储在常量池 // 情况2:引用类型静态变量 private static Person manager; // 引用存储在方法区 private static List< String > cache; // 引用存储在方法区 // 情况3:静态常量 private static final String APP_NAME = "MyApp"; // 值在常量池 }
这个内存区域,当无法满足新的内存分配需求时,将抛出OOM
方法区是一个逻辑区域:
- 对于
HotSpot,1.7版本之前,方法区是使用永久代来实现的。 - 对于
HotSpot,1.8版本以及之后,方法区是使用元空间来实现。
什么是永久代?
永久代是 HotSpot 虚拟机在 JDK 7 及之前版本中,对 JVM 规范中“方法区”的一种实现方式。它主要存储类的元数据、常量池、静态变量、即时编译器编译后的代码等数据。但在 JDK 8 中被彻底移除,被“元空间”(Metaspace)替代。
- 新生代 + 老年代 = 堆内存(Heap) :存放对象实例。
- 永久代(JDK 7 及之前) = 方法区的实现:存放类的元数据等信息,不属于堆,但与堆共享物理内存,是堆的一个逻辑分区。
什么是元空间?
元空间是HotSpot虚拟机对JVM规范中"方法区"的新实现,专门用于存储类的元数据信息,字符串常量池和静态变量的基础类型部分(具体引用指向的对象仍在堆里)被移到了Java堆中,元空间位于本地内存,而永久代位于Java堆内存中。这意味着元空间的大小默认只受系统可用内存限制,不再受-Xmx参数约束。
运行时常量池
运行时常量池是方法区的一部分,在Class文件中,除了有类的版本、字段、方法、接口等描述信息外,还有一个常量池表。
这个常量池表中用于存放编译期生成的各种字面量和符号引用以及由符号引用翻译出来的直接引用,当类加载到JVM中后,这个常量池表就会放在方法区的运行时常量池中。
运行时常量池可以在运行期间将新的常量放入,比如String类的intern()方法
字面量是什么?
字面量是代码中直接写出来的固定值,是数据本身的直观表示。它是“看得见摸得着”的具体值。
符号引用是什么?
符号引用是JVM中的一个核心概念。它是一组符号,用来描述所引用的目标,而不是目标在内存中的实际地址。它是一种“承诺”或“契约”,告诉JVM“我要用某个东西”。
符号引用是静态的,是保存在字节码文件中的,直接引用是动态的,在字节码文件加载到JVM后由符号引用解析出来的,但是有些情况下,符号引用不会立即解析,因此运行时常量池中符号引用和直接引用都存在。
// Test.java
public class Test {
public static void main(String[] args) {
String message = "Hello"; // 1. message是引用,“hello”是字符串字面量
Object obj = null; // 2. obj是引用,null是空字面量
int num = 100; // 3. num是局部变量,100是整数字面量
System.out.println(message); // 4. 符号引用 System/out/println
}
}
HotSpot虚拟对象的探究
对象创建
在语言的层面,对象创建仅仅需要一个new关键字,现在就来探讨其内部发生了什么。
-
类加载检查:当
JVM遇到一条字节码new指令时,会先检查是否能在当前类的运行时常量池能定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经加载、解析和初始化过。 -
分配内存:当类检查通过后,分配对应大小的空间给该对象。
- 划分可用空间的方式有两种:指针碰撞(可用内存和不可用内存泾渭分明)和空闲列表
- 并发下的线程安全控制方式:
CAS+重试或者使用本地线程分配缓冲
-
初始化零值:分配到的内存空间,除了对象头之外,都初始化为零值。
-
必要的设置:设置对象的对象头,比如
Klass word用于指向这个对象的类,Mark word中的hashcode,gc分代年龄,是否启用偏向锁等。 -
执行构造函数:构造对象的状态和资源。
对象的内存布局
在HotSpot虚拟机中,对象在堆内存中的存储布局可以划分为三个部分:
- 对象头
- 实例数据
- 填充数据:
HotSpot虚拟机要求对象起始地址必须是8字节的整数倍。
对象头
下面以32位虚拟机来介绍对象头的结构:
- 普通对象
普通对象的对象头共64位,包括:
- 32位的
Mark Word:最核心的部分,存储了对象的运行时数据 - 32位的
Klass Word:指向方法区中类元数据的指针,JVM通过这个指针确定对象属于哪个类。
- 数组对象
数组对象相对于普通对象,多了一个
array length字段,用于存放数组声明时的大小。
对象的访问定位
Java程序会通过栈上的reference数据来操作堆上的具体对象。
主流的对象访问方式有使用句柄和直接指针两种:
- 使用句柄访问的时候,堆内会划分出一块内存来作为句柄池,这时
reference中存储的就是对象的句柄地址。 - 使用直接指针访问的时候,
reference存储的直接是对象的地址,通过对象头来访问对象类型数据。
使用句柄访问的好处是:reference中存储的是稳定句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而reference本身不用修改。
使用直接指针的好处是:它节省了一次指针定位的时间开销。