1 运行时栈帧结构
Java虚拟机以方法作为最基本执行单元,“栈帧”则是用于支持虚拟机进行方法调用和方法执行背后的数据结构。栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。
1.1 局部变量表
局部变量表的容量以变量槽为最小单位。
Java虚拟机通过索引定位的方式使用局部变量表。如果访问的是64位数据类型的变量,则说明会同时使用第N和第N+1两个变量槽。虚拟机不允许采用任何方式单独访问其中的某一个。
reference类型表示对一个对象实例的引用,《Java虚拟机规范》既没有说明它的长度,也没明确指出这种引用的结构。但是一般来说,引用类型至少有两项作用:
1)从根据引用直接或间接地查找到对象在Java堆中的数据存放的起始地址或索引。
2)根据引用直接或间接地查找到对象所属数据类型在方法区的存储的类型信息。
1.1.1 实参到形参的传递
当一个方法被调用时,Java虚拟机会使用局部变量表来完成参数值到参数变量列表的传递。如果执行的是实例方法,那局部变量表中第0位索引的变量槽默认是用于传递方法所属对象实例的引用。其余参赛按照参赛表顺序排列。
1.1.2 变量槽重用
局部变量表中的变量槽是可以重用的,方法体中定义的变量,其作用域并不一定会覆盖整个方法体,如果当前字节码PC计数器的值已经超出了某个变量的作用域,那这个变量对应的变量槽就可以交给其他变量来重用。
1.2 操作数栈
操作数栈中元素的数据类型必须与字节码指令的序列严格匹配。
在大多虚拟机的实现里,会进行一些优化处理,令两个栈帧出现一部分重叠。
图 操作数栈重叠实例代码
两个栈帧一部分重叠主要体现在方法中有参数传递的情况。
图 操作数栈重叠代码帧栈重叠部份图示
这样做不仅节约了一些空间,更重要的是在进行方法调用时就可以直接共用一部分数据,无须进行额外的参数复制传递了。
2方法调用
方法调用并不等同于方法中的代码被执行,方法调用阶段唯一的任务就是确定被调用方法的版本(即调用哪一个方法)。程序运行时,进行方法调用是最普遍、最繁琐的操作之一。
2.1 解析
在类加载的解析阶段,会将方法中的一部分符号引用转化为直接引用,这种解析能够成立的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期不可改变。
符合“编译器可知,运行期不可变”这个要求的方法,主要有静态方法和私有方法这两大类。
invokestatic | 调用静态方法 |
invokespecial | 调用实例构造器<init>()方法、私有方法和父类中的方法 |
invokevirtual | 调用所有的虚方法 |
invokeinterface | 调用接口方法,会在运行时再确定一个实现该接口的对象 |
invokedynamic | 先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法。前面4条分派逻辑都固化在Java虚拟机内部,而这条指令分派逻辑是由用户设定的引导方法来决定的。 |
表 Java虚拟机支持的方法调用字节码指令
2.1.1 非虚方法和虚方法
只要能被invokestatic和invokespecial指令调用的方法及被final修饰的方法(尽管它使用invokevirtual指令调用),都可以在解析阶段中确定唯一的调用版本。这些方法被称为“非虚方法”。与之相反的其他方法被称为“虚方法”。
图 方法静态解析演示
2.2 分派
2.1.1 静态分派
静态分派是编译器方法重载的过程。
静态类型(也叫外观类型)在编译器是可知的。而实际类型(也叫运行时类型)要等到运行期才可知。
如:Human man = new Man(); 静态类型为Human,实际类型为Man。
方法重载哪个版本,完全取决于传入参数的数量和数据类型,但虚拟机在重载时是通过参数的静态类型作为判定依据的。
2.1.2 动态分派
动态分派和重写有着很密切的关联。
public class DynamicAllocation {
private static class Human {
void sayHello() {
System.out.println("Hello Human");
}
}
private static class Man extends Human {
@Override
void sayHello() {
System.out.println("Hello Man");
}
}
public static void main(String[] args) {
Human human = new Man();
human.sayHello();
}
}
/*
运行结果:
Hello Man
*/
图 上述代码的部份字节码
标红处为main方法中执行sayHello方法的地方。根据《Java虚拟机规范》,invokevirtual指令的运行时解析过程大致分为以下步骤:
- 找到操作数栈顶顶第一个元素所指的对象的实际类型,记作C。
- 如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行校验,如果通过则返回这个方法的直接引用,查找过程结束;不通过则返回java.lang.IllegalAccessError异常。
- 否则,按照继承关系从下往上依次对C的各个父类进行第二步的搜索和验证过程。
- 如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。
指令的第一步就是在运行期确定接收者的实际类型,这个过程是Java语言中方法重写的本质。
字段不可能是虚的。
2.1.3 单分派与多分派
方法的接收者与方法的参数统称为方法的宗量。根据分派基于多少种宗量,将分派划分为单分派和多分派两种。
至今的Java语言是一门静态(和接收者及方法的参数都有关,重载)多分派、动态单分派(只和接收者有关)的语言。
2.1.4 虚拟机动态分派的实现
使用虚方法索引来代替元数据查找以提高性能。虚方法表存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方法表中的地址入口和父类相同方法的地址入口是一样的。如果重写了这个方法,子类虚方法表中的地址也会被替换为指向子类实现版本的入口地址。
为了程序实现方便,具有相同签名的方法,在父类、子类的虚方法表中都应当具有一样的索引号,这样当类型变换时,仅需变更查找的虚方法表。
虚方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始值后,虚拟机会把该类的虚方法表也一同初始化完毕。