与程序计数器一样,虚拟机栈也是线程私有,它的生命周期与线程相同
每创建一个线程时,虚拟机会在栈空间中分配一小块内存给该线程,用来存放与该线程运行相关的局部变量
线程内的每个方法被执行时,Java 虚拟机都会同步创建一个栈帧用于存储局部变量表、操作数栈、动态连接、方法出口等信息
一个栈帧需要分配的内存在编译 Java 源程序的时候就已经可以完全确定,并不会受到程序运行期间变量数据的影响
每一个方法被调用直至执行完毕的过程,就对应着一个栈帧从入栈到出栈的过程
和栈帧一样,局部变量表所需要的内存空间在编译期间完成分配,当进入一个方法时,该方法在栈帧中需要分配的局部变量空间就可以完全确定,在方法运行期间不会变改局部变量表的大小 (大小: 指变量槽的大小)
局部变量表是一组变量值的存储空间,用于存放「方法参数」和「方法内部定义的局部变量」,以变量槽为最小单位
一个变量槽可以存放一个 32 位以内的数据类型:boolean, byte, char, short, int, float, reference, returnAddress;对于 64 位的 long 和 double 类型数据会占用两个变量槽
变量槽还可以重复利用,如果一个变量槽存放的局部变量的作用域结束后,该变量槽就可以用来存放其他局部变量
注意:如果是实例方法,局部变量表中第 0 个变量槽存放的是所属对象实例的引用,以关键字this
来访问该隐含参数
下面用javap -v Test01
来看一下反编译后的字节码文件:
// 源代码
public class Test01 {
public void test(int a) {
double d = 1.0;
int b = 1;
}
}
// 反编译后的字节码
{
public Test01();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this LTest01;
public void test(int);
descriptor: (I)V
flags: ACC_PUBLIC
Code:
stack=2, locals=5, args_size=2 // stack (操作数栈大小);locals (局部变量表大小);args_size (参数的数量)
0: dconst_1
1: dstore_2
2: iconst_1
3: istore 4
5: return
LocalVariableTable:
Start Length Slot Name Signature
0 6 0 this LTest01; // this 占第 0 个变量槽
0 6 1 a I // 变量 a 占第 1 个变量槽
2 4 2 d D // 变量 d 占第 2-3个变量槽 (double 类型)
5 1 4 b I // 变量 b 占第 4 个变量槽
}
// Start: 表示该局部变量作用域的起点 (声明完之后)。注意:该起点是相对于方法起始位置的字节数偏移值
// Length: 表示该局部变量作用域的长度 (即从相对于 start 的位置开始可见,到 length 个字节数为止)
这里穿插一个关于「变量分类」的小知识,具体如下图所示:
和局部变量表一样,操作数栈的最大深度也在编译的时候就被写入到 Code 属性的 max_stacks 数据项之中
操作数栈主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间
操作数栈的每一个元素都可以是包括 long 和 double 在内的任意 Java 数据类型。32 位及以下 (如:boolean,byte,char,short) 的数据类型所占的栈容量为 1,64 位数据类型所占的栈容量为 2
例如:整数加法的字节码指令iadd
,在运行时要求将操作数栈中最接近栈顶的两个元素出栈相加,并将相加的结果重新入栈
下面以一个具体的例子来演示整个过程
// 源代码
public void testAdd() {
byte i = 10;
int j = 8;
int k = i + j;
}
// 对应的字节码指令
0: bipush 10 // 将单字节的常量值 10 推送至栈顶
2: istore_1 // 将栈顶 int 型数值存入第二个本地变量 (注意:下标从 0 开始,所以第二个对应下标是 1)
3: bipush 8 // 将单字节的常量值 8 推送至栈顶
5: istore_2 // 将栈顶 int 型数值存入第三个本地变量 (注意:下标从 0 开始,所以第三个对应下标是 2)
6: iload_1 // 将第二个 int 型本地变量推送至栈顶 (对应下标为 1)
7: iload_2 // 将第三个 int 型本地变量推送至栈顶 (对应下标为 2)
8: iadd // 将栈顶两 int 型数值相加并将结果压入栈顶
9: istore_3 // 将栈顶 int 型数值存入第四个本地变量 (注意:下标从 0 开始,所以第四个对应下标是 3)
10: return // 从当前方法返回 void
详细流程如下图所示:(注意:局部变量表下标为 0 的位置存放的是this
,因为该方法为实例方法,而非类方法)
下面再来一个稍微复杂一点的例子:
// 源代码
public int sum() {
int a = 1;
int b = 2;
int c = a + b;
return c;
}
public void getSum() {
int i = sum();
int j = 12;
}
// sum() 对应的字节码指令
0 iconst_1 // 将 int 型 1 推送至栈顶
1 istore_1 // 将栈顶 int 型数值存入第二个本地变量 (注意:下标从 0 开始,所以第二个对应下标是 1)
2 iconst_2 // 将 int 型 2 推送至栈顶
3 istore_2 // 将栈顶 int 型数值存入第三个本地变量 (注意:下标从 0 开始,所以第二个对应下标是 2)
4 iload_1 // 将第二个 int 型本地变量推送至栈顶 (注意:下标从 0 开始,所以第二个对应下标是 1)
5 iload_2 // 将第三个 int 型本地变量推送至栈顶 (注意:下标从 0 开始,所以第三个对应下标是 2)
6 iadd // 将栈顶两 int 型数值相加并将结果压入栈顶
7 istore_3 // 将栈顶 int 型数值存入第四个本地变量 (注意:下标从 0 开始,所以第四个对应下标是 3)
8 iload_3 // 将第四个 int 型本地变量推送至栈顶 (注意:下标从 0 开始,所以第四个对应下标是 3)
9 ireturn // 从当前方法返回 int
// getSum() 对应的字节码指令
0 aload_0 // 将第一个引用类型本地变量推送至栈顶 (此处是 this)
1 invokevirtual #2 <com/lfool/myself/Test01.sum : ()I> // 调用实例方法 (此处是 sum())
4 istore_1 // 将栈顶 int 型数值存入第二个本地变量 (注意:调用实例方法后,此时栈顶的值为调用方法的返回值)
5 bipush 12 // 将单字节的常量值 12 推送至栈顶
7 istore_2 // 将栈顶 int 型数值存入第三个本地变量 (注意:下标从 0 开始,所以第三个对应下标是 2)
8 return // 从当前方法返回 void
这里对ireturn
再强调一下,先贴出这个字节码指令的原文描述:
If no exception is thrown, value is popped from the operand stack of the current frame and pushed onto the operand stack of the frame of the invoker. Any other values on the operand stack of the current method are discarded.
解释:如果没有抛出异常,当前方法栈帧的栈顶元素被pop
,同时该元素被push
到调用者的栈帧中。当前方法栈帧中所有其他元素都将被丢弃!!
所以这也是为什么当调用者执行invokevirtual #2
指令后,调用者栈帧的栈顶元素是调用方法的返回值
最后的最后,总结了一个很烦很烦的东西 从字节码角度分析 i++ 和 ++i
一个栈帧对应一个方法,每个栈帧都都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接
连接其实是将符号引用转化为直接引用的过程。有些符号引用在加载阶段或第一次使用的时候就被转化为直接引用 (如:静态常量,静态方法等),称为静态解析;而有些符号引用只有在每一次运行期间才会转化成直接引用,称为动态连接
关于符号引用和直接引用的详细内容可见 类加载的过程 的解析部分!!
当定义一个String s = "abcd"
时,在加载阶段就完成「符号引用」到「直接引用」的转化
当利用 Java 中的多态特性进行方法调用时,会在运行期间完成「符号引用」到「直接引用」的转化。关于方法调用详细的内容可见 方法调用
大致过程如下图所示,可能不太准确!!
当一个方法开始执行后,只有两种方式可以退出这个方法:
athrow
字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出无论采用何种退出方式,在方法退出之前,都必须返回到最初方法被调用时的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层主调方法的执行状态
注意:当前执行方法对应的栈帧中保存着上层主调方法调用当前执行方法时的 PC 计数器的值!!
方法正常退出时,主调方法的 PC 计数器的值就可以作为返回地址,栈帧中很有可能会保存这个计数器的值;而异常退出时,返回地址是通过异常处理器表来确定的,栈帧中一般就不会保存这部分信息
方法退出的过程实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值压入调用者栈帧的操作数栈中,调整 PC 计数器的值以指向方法调用指令后面的一条指令等
《Java 虚拟机规范》允许虚拟机实现增加一些规范里没有描述的信息到栈帧中,例如与调试、性能收集相关的信息,这部分信息完全取决于具体的虚拟机实现
在讨论概念时,一般会把动态连接、方法返回地址与其他附加信息全部归为一类,称为栈帧信息
在给出结构图之前,先介绍几个概念!!
关于方法区详细的内容可见 方法区
Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表 (Constant Pool Table),用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后变成运行时常量池放到方法区中