Java虚拟机–栈帧、操作数栈和局部变量表
前言
本文主要分为两部分:
- Java虚拟机运行时栈帧介绍
- 一个关于字节码指令以及操作数出栈/入栈的小实验
1. Java虚拟机栈和运行时栈帧结构
Java虚拟机是基于栈架构的,如图所示:
为什么要深入研究虚拟机栈?因为它很重要。除了一些native方法是基于本地方法栈实现的,所有的Java方法几乎都是通过Java虚拟机栈来实现方法的调用和执行过程(当然,需要程序计数器、堆、方法区的配合),所以Java虚拟机栈是虚拟机执行引擎的核心之一。而Java虚拟机栈中出栈入栈的元素就称为栈帧。
1.1 栈帧的概念
栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构。栈帧存储了方法的局部变量表、操作数栈、动态链接、方法返回地址等信息。每一个方法从调用至执行完成的过程,都对应着一个栈帧在虚拟机栈里从入栈到出栈的过程。
一个线程中方法的调用链可能会很长,很多方法都同时处于执行状态。对于JVM执行引擎来说,在活动线程中,只有位于JVM虚拟机栈栈顶的元素才是有效的,即称为当前栈帧,与这个栈帧相关连的方法称为当前方法称为当前方法,定义这个方法的类叫作当前类。
执行引擎运行的所有字节码指令都只针对当前栈帧进行操作。如果当前方法调用了其他方法,或者当前方法执行结束,那这个方法的栈帧就不再是当前栈帧了。
调用新的方法时,新的栈帧也会随之创建。并且随着程序控制权转移到新方法,新的栈帧成为了当前栈帧。方法返回之际,原栈帧会返回方法的执行结果给之前的栈帧(返回给方法调用者),随后虚拟机将会丢弃此栈帧。
栈帧是线程本地的私有数据,不可能在一个栈帧中引用另外一个线程的栈帧。
在概念模型上,典型的栈帧结构如下:
关于栈帧,在《Java虚拟机规范》中的描述如下:
栈帧是用来存储数据和部分过程结果的数据结构,同时也用来处理动态链接、方法返回值和异常分派。
栈帧随着方法调用而创建,随着方法结束而销毁–无论方法正常完成还是异常完成都算作方法结束。
栈帧的存储空间由创建它的线程分配在Java虚拟机栈之中,每一个栈帧都有自己的本地变量表(局部变量表)、操作数栈和指向当前方法所属的类的运行时常量池引用。
1.2 局部变量表
局部变量表(Local Variable Table)是一组变量值存储空间,用于存放方法参数和方法内定义的局部变量。局部变量表的容量以变量槽(Variable Slot)为最小单位,Java虚拟机规范并没有定义一个槽所应该占用内存空间的大小,但是规定了一个槽应该可以存放一个32位以内的数据类型。
在Java程序编译为Class文件时,就在方法的Code属性中的max_locals数据项中确定了该方法所需分配的局部变量表的最大容量(最大Slot数量)。
一个局部变量可以保存一个类型为boolean、byte、char、short、int、float、reference和returnAddress类型的数据。reference类型表示对一个对象实例的引用。returnAddress类型是为jsr、jsr_w和ret指令服务的,目前已经很少使用了。
虚拟机通过索引定位的方法查找相应的局部变量,索引的范围是从0~局部变量表最大容量。如果Slot是32为的,则遇到一个64位数据类型的变量(如long或double),则会连续使用两个连续的Slot来存储。
1.3 操作数栈
操作数栈(Operand Stack)也常称为操作栈,他是一个后入先出栈(LIFO)。同局部变量表一样,操作数栈的最大深度也是在编译的时候写入到方法的Code属性的max_stacks数据项中。
操作数栈的每一个元素是任意Java数据类型,32位的数据类型占一个栈容量,64位的数据类型占2个栈容量,且在方法执行的任意时刻,操作数栈的深度不会超过max_stacks中设置的最大值。
当一个方法刚刚开始执行时,其操作数栈是空的,随着方法执行和字节码指令的执行,会从局部变量表或对象实例的字段中复制常量或变量写入到操作数栈,再随着计算的进行将栈中元素出栈到局部变量或者返回给方法调用者,也就是出栈/入栈操作。一个完整的方法执行期间往往包含多个这样出栈/入栈操作。
1.4 动态链接
在一个Class文件中,一个方法要调用其他方法,需要将这些方法的符号引用转化为其在内存地址中的直接引用,而符号引用存在于方法区中的运行时常量池。
Java虚拟机栈中,每个栈帧都包含一个指向运行时常量池中该栈所属方法的符号引用,持有这个引用的目的是为了支持方法调用过程中的动态链接(Dynamic Linking)。
这些符号引用一部分会在类加载阶段或者第一次使用时就直接转化为直接引用,这类转化称为静态解析。另一部分将在每次运行期间转化为直接引用,这类转化称为动态链接。
1.5 方法返回
当一个方法开始执行时,可能有两种方式退出该方法:
- 正常完成出口
- 异常完成出口
正常完成出口:是指方法正常完成并退出,没有抛出任何异常(包括Java虚拟机异常以及执行时通过throw语句显示抛出的异常)。如果当前方法正常完成,则根据当前方法返回字节码指令,这是有可能会有返回值传递给方法调用者,或者无返回值。具体是否有返回值以及返回值的数据类型将根据改方法返回的字节码指令确定。
异常完成出口:是指方法执行过程中遇到异常,并且这个异常在方法体内部没有得到处理,导致方法退出。
无论是Java虚拟机抛出的异常还是代码中使用throw指令产生的异常,只要在本方法的异常表中没有搜索到相应的异常处理器,就会导致方法退出。
无论方法采用何种方式退出,在方法推出后都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在当前栈帧中保存一些信息,用来帮它恢复它的上层方法执行状态。
方法退出过程实际上就等同于把当前栈帧出栈,因此退出可以执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者的操作数栈,调整PC计数器的值以执行方法调用指令后的下一条指令。
一般来说,方法正常退出时,调用者的PC计数值可以作为返回地址,栈帧中可能保存此计数值。而方法异常退出时,返回地址是通过异常处理器表确定的,栈帧中一般不会保存此部分信息。
1.6 附加信息
虚拟机规范允许具体的虚拟机实现增加一些规范中没有的信息到栈帧之中,例如和调试相关的信息,这部分信息完全取决于不同的虚拟机实现。在实际开发中,一般会把动态链接,方法返回地址与其他附加信息一起归为一类,称为栈帧信息。
2. 字节码指令以及操作数出栈/入栈过程的实验
2.1 关于++操作
public class TestPlusPlus {
public static void main(String[] args) {
test1();
test2();
test3();
}
public static void test1(){
int i = 8;
i = i + 1;
System.out.println(i);
}
public static void test2(){
int i = 8;
i = i++;
System.out.println(i);
}
public static void test3(){
int i = 8;
i = ++i;
System.out.println(i);
}
}
问题:test1();test2();test3();分别运行后会输出哪三个数字?
答案:
test1(); ------------- 9
test2(); ------------- 8
test3(); ------------- 9
执行情况,看下面分析:
test1()方法的字节码如下:
0 bipush 8 ------------ 将8被压入操作数栈。
2 istore_0 ------------ 将操作数栈栈顶的8弹出,赋值给局部变量表中0位置的i变量,此时i = 8;
3 iload_0 ------------ 将局部表列表0位置的值8压入操作数栈
4 iconst_1 ------------ 将int常量1压入操作数栈
5 iadd ------------ 从操作数栈的栈顶弹出两个数(1和8),将相加后的结果9压入操作数栈
6 istore_0 ------------ 将操作数栈栈顶的8弹出,赋值给局部变量表中0位置的i变量,此时i = 9;
7 getstatic #5 <java/lang/System.out> ---------- System.out
10 iload_0 ------------ 将局部表列表0位置的值9压入操作数栈
11 invokevirtual #6 <java/io/PrintStream.println> ------------ 执行打印方法
14 return
test2()方法字节码如下:
0 bipush 8 ------------ 将8被压入操作数栈。
2 istore_0 ------------ 将操作数栈栈顶的8弹出,赋值给局部变量表中0位置的i变量,此时i = 8;
3 iload_0 ------------ 将局部表列表0位置的值8压入操作数栈
4 iinc 0 by 1 -------------- 将局部变量表0位置加1,此时i = 8
7 istore_0 ------------ 将操作数栈栈顶的8弹出,赋值给局部变量表中0位置的i变量,此时i = 8;
8 getstatic #5 <java/lang/System.out> ---------- System.out
11 iload_0 ------------ 将局部表列表0位置的值9压入操作数栈
12 invokevirtual #6 <java/io/PrintStream.println> ------------ 执行打印方法
15 return
test3()方法字节码如下:
0 bipush 8 ------------ 将8被压入操作数栈。
2 istore_0 ------------ 将操作数栈栈顶的8弹出,赋值给局部变量表中0位置的i变量,此时i = 8;
3 iinc 0 by 1 -------------- 将局部变量表0位置加1,此时i = 9
6 iload_0 ------------ 将局部表列表0位置的值9压入操作数栈
7 istore_0 ------------ 将操作数栈栈顶的9弹出,赋值给局部变量表中0位置的i变量,此时i = 9;
8 getstatic #5 <java/lang/System.out> ---------- System.out
11 iload_0 ------------ 将局部表列表0位置的值9压入操作数栈
12 invokevirtual #6 <java/io/PrintStream.println> ------------ 执行打印方法
15 return
2.2 基本类型finally代码块中的各种操作
public class FinallyTest {
public static void main(String[] args) {
int num = 10;
System.out.println(test1(num));//第一个输出
System.out.println(test2(num));//第三个输出
}
public static int test1(int a){
try{
a += 20;
return a;
}finally {
a += 30;
return a;
}
}
public static int test2(int b){
try{
b += 20;
return b;
}finally {
b += 30;
System.out.println(b);//第二个输出
}
}
}
答案:
System.out.println(test1(num)) ---- 60
System.out.println(b); ---- 60
System.out.println(test2(num)); -----30
学Java时我们知道:
- 执行完try中的语句后,无论是否有异常被catch到,finally中的语句块都会被执行(除了exit以及其它异常外),所以finally中通常用于关闭流、关闭连接等操作。
- finally中如果有return语句,则会用finally中的语句覆盖掉try{}catch{}中的return。
test1()方法字节码如下:
0 iinc 0 by 20 -------- 将局部变量表0位置加20,此时a = 30
3 iload_0 -------- 将局部变量表0位置的值30压入操作数栈
4 istore_1 ---- 将操作数栈栈顶的值30弹出,赋值给局部变量表1位置。其实是在return前,将a = 30缓存
5 iinc 0 by 30 ---- 在finally代码块中,对局部变量表0位置加30,此时0位置 = 60
8 iload_0 ---- 将局部变量表0位置60压入操作数栈
9 ireturn ---- finally代码块中的return,返回操作数栈栈顶的60,后面的代码不再执行了
10 astore_2
11 iinc 0 by 30
14 iload_0
15 ireturn
test2()方法字节码如下:
0 iinc 0 by 20 ---- 将局部变量表0位置加20,此时a = 30
3 iload_0 ---- 将局部变量表0位置的值30压入操作数栈
4 istore_1 ---- 将操作数栈栈顶的值30弹出,赋值给局部变量表1位置。其实是在return前,将a = 30缓存
5 iinc 0 by 30 ---- 在finally代码块中,对局部变量表0位置加30,此时0位置 = 60
8 getstatic #2 <java/lang/System.out>
11 iload_0 ---- 将局部变量表0位置的值60压入操作数栈
12 invokevirtual #4 <java/io/PrintStream.println> ---- finally语句块中,打印60
15 iload_1 ---- try语句块中,将局部变量表1位置的值30压入操作数栈
16 ireturn ---- 返回操作数栈栈顶的30
17 astore_2
18 iinc 0 by 30
21 getstatic #2 <java/lang/System.out>
24 iload_0
25 invokevirtual #4 <java/io/PrintStream.println>
28 aload_2
29 athrow
2.3 引用类型finally代码块中的操作
public class FinallyReferenceTypeTest {
public static void main(String[] args) {
Person person1 = test1();
System.out.println("Person:{age = " + person1.age + ", name = " + person1.name + "}");
Person person2 = test2();
System.out.println(person2);
Person person3 = test3();
System.out.println("Person:{age = " + person3.age + ", name = " + person3.name + "}");
}
public static Person test1(){
Person person = new Person();
try{
return person;
}finally {
person = null;
}
}
public static Person test2(){
Person person = new Person();
try{
return person;
}finally {
person = null;
return person;
}
}
public static Person test3(){
Person person = new Person();
try{
return person;
}finally {
person.age = 100;
person.name = "world";
}
}
static class Person{
int age = 10;
String name = "hello";
}
}
答案:
Person:{age = 10, name = hello}
null
Person:{age = 100, name = world}
2.4 结论:
- 如果在执行finally块前出现return语句,会先把值缓存起来,等执行完finally块后,再返回缓存起来的值。
- 如果是返回基本类型的值,那么在缓存时也是缓存值本身,所以后面在finally块中重新赋值时,方法返回的值不会受finally块中重新赋值的影响。
- 如果返回的是引用类型的值,那么缓存时,缓存的是引用类型对象的引用,所以虽然在test1()方法的finally块中重新赋值为null,犯法返回的值不受影响,但是如果是修改对象的属性,那么会影响到返回的值。