Java 虚拟机以方法作为基本的执行单元,“栈帧(Stack Frame)”则是用于支持 Java 虚拟机进行方法调用和方法执行的基本数据结构。每一个栈帧中都包含了局部变量表、操作数栈、动态链接、方法返回地址和一些额外的附加信息(比如与调试、性能手机相关的信息)。之前的文章里有提到过这些概念,并做了一些简单扼要的介绍,但我觉得还不够详细,所以这篇重点要来介绍一下栈帧中的这些概念。
1)局部变量表
局部变量表(Local Variables Table)用来保存方法中的局部变量,以及方法参数。当 Java 源代码文件被编译成 class 文件的时候,局部变量表的最大容量就已经确定了。
我们来看这样一段代码。
public class LocalVaraiablesTable { private void write(int age) { String name = "沉默王二"; } }
write() 方法有一个参数 age,一个局部变量 name。
然后用 Intellij IDEA 的 jclasslib 查看一下编译后的字节码文件 LocalVaraiablesTable.class。可以看到 write() 方法的 Code 属性中,Maximum local variables(局部变量表的最大容量)的值为 3。
按理说,局部变量表的最大容量应该为 2 才对,一个 age,一个 name,为什么是 3 呢?
当一个成员方法(非静态方法)被调用时,第 0 个变量其实是调用这个成员方法的对象引用,也就是那个大名鼎鼎的 this。调用方法 write(18),实际上是调用 write(this, 18)。
点开 Code 属性,查看 LocalVaraiableTable 就可以看到详细的信息了。
第 0 个是 this,类型为 LocalVaraiablesTable 对象;第 1 个是方法参数 age,类型为整形 int;第 2 个是方法内部的局部变量 name,类型为字符串 String。
当然了,局部变量表的大小并不是方法中所有局部变量的数量之和,它与变量的类型和变量的作用域有关。当一个局部变量的作用域结束了,它占用的局部变量表中的位置就被接下来的局部变量取代了。
来看下面这段代码。
public static void method() { // ① if (true) { // ② String name = "沉默王二"; } // ③ if(true) { // ④ int age = 18; } // ⑤ }
在此,我还有一点要提醒大家。为了尽可能节省栈帧耗用的内存空间,局部变量表中的槽是可以重用的,就像 method() 方法演示的那样,这就意味着,合理的作用域有助于提高程序的性能。
局部变量表的容量以槽(slot)为最小单位,一个槽可以容纳一个 32 位的数据类型(比如说 int,当然了,《Java 虚拟机规范》中没有明确指出一个槽应该占用的内存空间大小,但我认为这样更容易理解),像 float 和 double 这种明确占用 64 位的数据类型会占用两个紧挨着的槽。
来看下面的代码。
public void solt() { double d = 1.0; int i = 1; }
用 jclasslib 可以查看到,solt() 方法的 Maximum local variables 的值为 4。
为什么等于 4 呢?带上 this 也就 3 个呀?
查看 LocalVaraiableTable 就明白了,变量 i 的下标为 3,也就意味着变量 d 占了两个槽。
2)操作数栈
同局部变量表一样,操作数栈(Operand Stack)的最大深度也在编译的时候就确定了,被写入到了 Code 属性的 maximum stack size 中。当一个方法刚开始执行的时候,操作数栈是空的,在方法执行过程中,会有各种字节码指令往操作数栈中写入和取出数据,也就是入栈和出栈操作。
来看下面这段代码。
public class OperandStack { public void test() { add(1,2); } private int add(int a, int b) { return a + b; } }
OperandStack 类共有 2 个方法,test() 方法中调用了 add() 方法,传递了 2 个参数。用 jclasslib 可以看到,test() 方法的 maximum stack size 的值为 3。
这是因为调用成员方法的时候会将 this 和所有参数压入栈中,调用完毕后 this 和参数都会一一出栈。通过 「Bytecode」 面板可以查看到对应的字节码指令。
再来看一下 add() 方法的字节码指令。
操作数中的数据类型必须与字节码指令匹配,以上面的 iadd 指令为例,该指令只能用于整形数据的加法运算,它在执行的时候,栈顶的两个数据必须是 int 类型的,不能出现一个 long 型和一个 double 型的数据进行 iadd 命令相加的情况。
3)动态链接
每个栈帧都包含了一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态链接(Dynamic Linking)。
来看下面这段代码。
public class DynamicLinking { static abstract class Human { protected abstract void sayHello(); } static class Man extends Human { @Override protected void sayHello() { System.out.println("男人哭吧哭吧不是罪"); } } static class Woman extends Human { @Override protected void sayHello() { System.out.println("山下的女人是老虎"); } } public static void main(String[] args) { Human man = new Man(); Human woman = new Woman(); man.sayHello(); woman.sayHello(); man = new Woman(); man.sayHello(); } }
大家对 Java 重写有了解的话,应该能看懂这段代码的意思。Man 类和 Woman 类继承了 Human 类,并且重写了 sayHello() 方法。来看一下运行结果:
男人哭吧哭吧不是罪 山下的女人是老虎 山下的女人是老虎
这个运行结果很好理解,man 的引用类型为 Human,但指向的是 Man 对象,woman 的引用类型也为 Human,但指向的是 Woman 对象;之后,man 又指向了新的 Woman 对象。
从面向对象编程的角度,从多态的角度,我们对运行结果是很好理解的,但站在 Java 虚拟机的角度,它是如何判断 man 和 woman 该调用哪个方法的呢?
用 jclasslib 看一下 main 方法的字节码指令。
注意,从字节码的角度来看,man.sayHello()(第 10 行)和 woman.sayHello()(第 12 行)的字节码是完全相同的,但我们都知道,这两句指令最终执行的目标方法并不相同。
究竟发生了什么呢?
还得从 invokevirtual 这个指令着手,看它是如何实现多态的。也就是说,invokevirtual 指令在第一步的时候就确定了运行时的实际类型,所以两次调用中的 invokevirtual 指令并不是把常量池中方法的符号引用解析到直接引用上就结束了,还会根据方法接受者的实际类型来选择方法版本,这个过程就是 Java 重写的本质。我们把这种在运行期根据实际类型确定方法执行版本的过程称为动态链接。
4)方法返回地址
当一个方法开始执行后,只有两种方式可以退出这个方法:
无论是哪种方式退出,在方法退出后,都必须返回到方法最初被调用时的位置,程序才能继续执行。一般来说,方法正常退出的时候,PC 计数器的值会作为返回地址,栈帧中很可能会保存这个计数器的值,异常退出时则不会。
方法退出的过程实际上等同于把当前栈帧出栈,因此接下来可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整 PC 计数器的值,找到下一条要执行的指令等。
相关java培训开发技术知识,关注我,有更多精彩内容与您分享!