前言: 执行引擎是Java虚拟机最核心的组成部分之一。 虚拟机的执行引擎是自己实现的,可以自己制定指令集和执行引擎的结构体系,并且可以执行那些不被硬件直接支持的指令集格式。 在Java规范中制定了虚拟机字节码执行引擎的概念模型,这个概念模型成为各种虚拟机执行引擎的统一外观:输入的是字节码文件,处理过程是字节码解析的等效过程,输出的是执行结果。
一、 运行时栈帧结构
栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构,是虚拟机运行时数据区中的虚拟机栈的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态链接和方法返回地址等信息。每一个方法从调用开始至执行完成过程,都对应这一个栈帧在虚拟机栈里面从入栈到出栈的过程。
在编译程序代码时,栈帧中需要多大的局部变量表,多深的操作数栈都已经确定了,并写入到方法表的COde属性中。因此一个栈帧分配多少内存在编译器就确定了。对于执行引擎来说,在活动线程中只有位于栈顶的栈帧才是有效的。
---- 局部变量表
局部变量表是一组变量值存储空间,用于存放方法参数和方法内的局部变量。Code属性中的mak_locals数据项确定了该方法所需分配的局部变量表的最大容量。
局部变量表的容量以slot为最小单位, slot可以存储32位或更小的数据类型(boolean、byte、char、short、int、float、reference、returnAddress)。reference表示一个对象的引用。 对于64位的数据类型,虚拟机会以高位对齐的方式为其分配两个连续的slot空间。 虚拟机通过索引定位的方式使用局部变量表,索引从0开始,对于64位的索引,不允许访问其中单独一个。
在方法执行时,虚拟机是使用局部变量表完成参数值到参数变量列表的传递过程,如果执行的实例方法,索引第一位是this引用。 局部变量表中的slot是可以重用的,可以尽量的节省栈帧空间。不过会有一些副作用,当当前变量离开此变量的作用域后,如果slot没有变占用,那么局部变量表中仍然对齐保留着关联。这种关联大多数情况下影响很小。
---- 操作数栈
操作数栈是一个后入先出的栈,操作数栈的最大深度也是在编译时写入到Code属性max_stacks中的。 操作数栈的每一个元素可以是任意的java数据类型。 32位数据类型栈一个栈容量,64位占两个。
当方法开始执行时,操作数栈是空,运行过程中,会有各种字节码指令往操作数栈中写入和提取内容即入栈和出栈的过程。 操作数栈中的元素类型必须严格和字节码指令的序列匹配。 还有一点,在虚拟机概念模型中,两个栈帧作为虚拟机栈的元素是完全相互独立的。但大多数的虚拟机实现中,两个栈帧会出现一部分的重叠。这样,在进行方法调用时就可以共用一部分数据,无需进行额外的参数复制传递。
---- 动态链接
每个栈帧都包含一个指向运行时常量池中该栈帧所述方法的引用,持有这个引用是为了支持方法调用过程中的动态链接。 在方法调用进一步解析。
---- 方法返回地址
当方法执行时,有两种方式退出。第一种方式是执行引擎遇到任意一个方法返回的字节码指令,此为正常的完成出口。另一种是方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,此种方式成为异常完成出口,此种方式不会给它的上次调用者产生任何返回值。
二、 方法调用
方法调用阶段唯一的任务就是确定被调用方法的版本(调用哪一个方法),暂时不涉及方法内部的具体运行过程。在程序运行时,方法调用时最频繁、最普遍的操作。 方法在class文件中体现的是符号引用,因此方法是在运行期间才能确定目标方法的直接引用。
---- 解析
方法在程序代码写好、编译器进行编译时就确定下来的方法调用称为解析。这类方法在编译器可知,运行期不变,主要有静态方法和私有方法,这两类方法不能通过集成或别的方式重写,因此适合在类加载阶段进行解析。
java虚拟机提供了5条方法调用字节码指令:
invokestatic: 调用静态方法
invokespecial: 调用实例构造器 <init>方法、私有方法和父类方法
invokevirtual: 调用所有的虚方法
invokeinterface: 调用接口方法,会在运行时在确定一个实现此接口的对象
invokedynamic: 先在运行时动态解析出调用点限定符所引用的方法,然后再执行。
静态方法、实例构造器 、私有方法和父类方法四类,在类加载时会把符号引用解析为该方法的直接引用。这些方法称为非虚方法。final方法也属于非虚方法,虽然final方法使用的是invokevirtual指令来调用的。解析调用一定是静态的,在编译期间完全确定,在类加载阶段就把涉及的符号引用转成直接引用。而分派调用可能是静态的,也可能是动态的。
---- 分派
1) 静态分派
Human man = new Man(); // Human 是 Man 的父类
此时Human称为静态类型,Man称为动态类型。 编译器在方法重载时通过参数的静态类型而不是实际类型作为判定依据。静态类型是编译期可知的。 因此,在编译阶段,编译器会根据参数的静态类型来决定使用哪个重载版本。
静态分派典型的引用是方法重载,发生在编译阶段。由于字面量不需要如上Human man ... 这样的定义,会产生模糊性。 所以静态分派只能进行选择更合适版本的方法进行重载。如:
public static void sayHello(char arg){}
public static void sayHello(int arg){}
public static void sayHello(long arg){}
public static void sayHello(Character arg){}
public static void sayHello(Serializable arg){}
public static void sayHello(Object arg){}
public static void sayHello(char... arg){}
如以上的方法重载,如果调用sayHello('a'), 则会按照以上例子的优先级加载方法。其中涉及到类型的显示转换,自动装箱,自动转型。
2) 动态分派
静态分派和重载关系密切,而动态分派则与重写密切关联。
动态分派是由虚拟机根据实际类型来分派方法的执行版本。主要的原因是invokevirtual指令的多态查找过程:
- 找到操作数栈顶的第一个元素指向的对象的实际类型 C
- 如果在类型C中找到了与常量中描述符和简单名称都相符的方法,则进行访问权限校验,通过则返回这个方法的直接引用。不通过返回异常。
- 否则,按照集成关系从下往上依次对C的各个父类进行第二步的搜索与验证过程
- 如果始终没有找到, 返回异常。
由于invokevirtual指令执行的第一步就是在运行期确定接收者的实际类型,所以调用重写方法时将符号引用解析到了实际类型的直接引用上了, 这个过程就是java语言中的方法重写的本质。 这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。
3) 单分派与多分派
根据分派宗量(方法的接收者和方法的参数统称为宗量)的多少可以将分派分为单分派和多分派。单分派根据一个宗量对目标方法进行选择,多分派则根据多于一个宗量对目标方法进行选择。
java的静态分派过程选择目标方法的依据有两点,一是静态类型,二是方法参数。因为是根据两个宗量进行选择的,所以java语言的静态分派属于多分派。 java动态分派在虚拟机运行阶段,这时虚拟机不关心传递的参数,关心的是此方法的接收者的实际类型,因此只有一个宗量,所以是单分派。
Java语言是静态多分派,动态单分派的语言。
4) 动态分派的实现
由于动态分派的频繁操作,基于性能的考虑,会在类的方法区中建立一个虚方法表,使用虚方法表替代动态分派过程中在类的方法元数据中搜索查找来提高性能。
虚方法表中存放着各个方法的实际入口地址,如果方法在子类中没有被重写,那么子类的虚方法表中的这个方法的入口地址和父类的入口地址一致。
---- 动态类型语言支持
动态类型语言的关键特征是它的类型检查的主体过程是在运行期而不是编译期。JDK1.7引入了invokedynamic指令以及java.lang.invoke包来支持。
-----------------------------------------------------------------------------------------
深入理解Java虚拟机JVM高级特性与最佳实践 读书笔记