Java虚拟机的执行引擎输入的是字节码文件,处理过程是字节码解析的等效过程,输出的是执行结果。
1、运行时栈帧结构
栈帧是JVM的虚拟机栈中的结构,存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。每一个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。编译程序代码时,栈帧的局部变量表和操作数栈大小已经确定,写入到方法表的Code属性中。因此一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响,仅仅取决于具体的虚拟机实现。线程中位于虚拟机栈栈顶的栈帧称为当前栈帧,对应线程正在执行的当前方法,执行引擎运行的所有字节码指令都只针对当前栈帧进行操作。栈帧结构:
1.1 局部变量表
局部变量表用于存放方法参数和方法内定义的局部变量。局部变量表的容量以变量槽(Variable Slot)为最小单位,虚拟机规范中没有规定一个Slot应占用的内存空间大小。一个Slot可以存放一个32位以内的数据类型,其中有boolean、byte、char、short、int、float、reference(这个类型可能是32位也可能是64位)和returnAddress8种类型。reference类型表示对一个对象实例的引用,虚拟机规范没有规定它的长度和结构,但虚拟机至少应当通过这个引用做到2点:
1、从此引用汇总直接或间接查找到对象在Java堆中的数据存放的起始地址索引。
2、此引用中直接或间接的查找到对象所属数据类型在方法区中存储的类型信息。
Java语言中明确规定64位的数据类型只有long和double,虚拟机会以高位对齐的方式为其分配2个连续的Slot空间。虚拟机通过索引定位的方式使用局部变量表,索引值的范围是从0开始至局部变量表最大的Slot数量。在方法执行时,虚拟机是使用局部变量表完成参数值到参数变量列表的传递过程的,如果执行的是实例方法,那局部变量表中第0位索引的Slot默认是用于传递方法所属的对象实例的引用,在方法中通过关键字"this"可以访问这个隐含的参数。其余参数按照参数列表顺序排列,占用从1开始的局部变量Slot,参数表分配完后,再根据方法内部定义的变量顺序和作用域分配其余的Slot。
为了尽可能节省栈帧空间,局部变量表中的Slot是可以重用的,如果程序计数器的值已经超出了某个变量的作用域,那这个变量对应的Slot可以交给其他变量使用。局部变量不像类变量有个赋零值的阶段,因此局部变量在使用前必须手动初始化,否则在编译时期会报错。
1.2 操作数栈
操作数栈也称为操作栈,其最大深度在编译时写入到Code属性的max_stacks数据项中,操作数栈中32位数据类型所占的栈容量为1,64位数据类型所占的栈容量为2。当一个方法开始执行时,这个方法的操作数栈是空的,在方法执行过程中,字节码指令会往操作数栈写入和读取数据,例如算术运行时通过操作数计算,调用其他方法的时候通过操作数栈进行参数传递。在概念模型中2个栈帧作为虚拟机的元素是完全独立的,但在大多虚拟机的实现中会做一些优化处理,令2个栈帧出现一部分重叠,让下面栈帧的部分操作数栈与上面栈帧的部*部变量表重叠在一起,在进行方法调用时就可以共用一部分数据,不用进行额外的参数赋值传递:
1.3 动态连接
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令以常量池中指向方法的符号引用作为参数。这些符号引用一部分在类加载阶段或者第一次使用的时候就转化为直接引用,这种称为静态解析,另外一部分在每一次运行时转化为直接引用,这部分称为动态连接。
1.4 方法返回地址
当一个方法开始执行后,只有2种方式可以退出。第一种方式是执行引擎遇到任意一个方法返回的字节码指令,这时可能有返回值传递给上层的方法调用者,这种退出方法的方式称为正常完成出口。另外一种退出方式是,在方法执行中遇到了异常,并且这个异常没有在方法体内处理,这种退出方法的方式称为异常完成出口。异常完成出口的方式退出是不会给上层调用者产生任何返回值的。
无论采用哪种退出方式,在方法退出后,都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层方法的执行状态。一般来说,方法正常退出时,调用者的PC计数器的值可以作为返回地址,栈帧中很可能保存这个计数器值。而方法异常退出时,返回地址要通过异常处理器表来确定,栈帧中一般不保存这部分信息。
方法退出的过程实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整PC计数器执行方法调用指令后面的一条指令等。
1.5 附加信息
虚拟机规范允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧之中。在实际开发中,一般会把动态连接、方法返回地址和其他附加信息全部归为一类,称为栈帧信息。
2、方法调用
方法调用不等同于方法执行,方法调用是确定被调用方法的版本(即调用哪一个方法),暂不涉及方法内部的具体运行过程。
2.1 解析
所有方法调用中的目标方法在Class文件中都是一个常量池的符号引用,在类加载阶段,会将其中的一部分符号引用转化为直接引用,这部分方法需要符合“编译器可知,运行期不可变”,主要包括静态方法和私有方法2种,这类方法的调用称为解析。
Java虚拟机里提供了5条方法调用字节码指令:
1、invokestatic:调用静态方法。
2、invokespecial:调用实例构造器<init>方法、私有方法和父类方法。
3、invokevirtual:调用所有的虚方法。
4、invokeinterface:调用接口方法,会在运行时再确定一个实现此接口的对象。
5、invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法,前面4条调用指令,分派逻辑是固化在Java虚拟机内部的,而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的。
被invokestatic和invokespecial指令调用的方法,有静态方法、私有方法、实例构造器、父类方法4类,在类加载的时候就会把符号引用解析为该方法的直接引用。这些方法方法称为非虚方法,其他的方法称为虚方法(final方法属于非虚方法)。解析调用一定一定是个静态的过程,在编译期间就完全确定,在类加载的解析阶段就会把相应的符号引用全部解析成直接引用。
2.2 分派
虚方法是使用分派调用的,分派调用可能是静态也可能是动态的,根据分派依据的宗量数可分为单分派和多分派。解析和分派不是互斥关系,静态方法在类加载的时候就会解析,但也是可以重载的。
2.2.1 静态分派
变量的类型分为静态类型和实际类型,静态类型和实际类型不一定相同,静态类型是在编译期可知的,实际类型变化的结果在运行期才能确定。编译期在重载时是通过参数的静态类型而不是实际类型作为判定依据的。所有依赖静态类型来定位方法执行版本的分派动作称为静态分派。静态分派的典型应用是方法重载。编译器确定方法的重载版本,是确定一个“更加合适的”版本,而不是“唯一的”版本。
1 package com.liu.test; 2 3 public class Test1 { 4 5 static abstract class Human { 6 } 7 8 static class Man extends Human { 9 } 10 11 static class Woman extends Human { 12 } 13 14 public void sayHello(Human human) { 15 System.out.println("Human"); 16 } 17 18 public void sayHello(Man man) { 19 System.out.println("Man"); 20 } 21 22 public void sayHello(Woman woman) { 23 System.out.println("Woman"); 24 } 25 26 public static void main(String[] args) { 27 Human man = new Man(); 28 Human woman = new Woman(); 29 Test1 test = new Test1(); 30 test.sayHello(man); 31 test.sayHello(woman); 32 } 33 }
重载时根据静态类型判定,所以2个输出的都是"Human"。
1 public class Test1 { 2 3 public static void say(Object arg){ 4 System.out.println("Object"); 5 } 6 7 public static void say(int arg){ 8 System.out.println("int"); 9 } 10 11 public static void say(long arg){ 12 System.out.println("long"); 13 } 14 15 public static void say(Character arg){ 16 System.out.println("Character"); 17 } 18 19 public static void say(char arg){ 20 System.out.println("char"); 21 } 22 23 public static void say(char... arg){ 24 System.out.println("char..."); 25 } 26 27 public static void say(Serializable arg){ 28 System.out.println("Serializable"); 29 } 30 31 public static void main(String[] args) { 32 say('a'); 33 } 34 }
运行后输出"char";如果注释say(char arg)的方法,那么输出"int",这时发生了自动类型转换。
再去除say(int arg)的方法,输出"long",这时发生了2次类型转换,按照char->int->long->float->double的顺序转型进行匹配,但没有byte和short类型的重载,因为char到byte或short的转型是不安全的。
继续注释掉say(long arg),输出"Character",这时发生了一次自动装箱,‘a’被包装为它的封装类型java.lang.Character。
继续注释掉say(Character arg),这时会输出"Serializable",这是因为java.lang.Serializable是java.lang.Character类实现的一个接口,当自动装箱后发现还是找不到装箱类的时候,再次发生了一次自动转型。char可以转型成int,但是Character不会转型为Integer,只能安全转型为它实现的接口或父类。
继续注释掉say(Serializable arg),这时输出"Object",这时char装箱后转型为父类,根据继承关系从下往上搜索父类。
当say(Object arg)注释后,输出为"char...",因此变长参数的重载优先级是最低的。
2.2.2 动态分派
多态中的重载与静态分派相关,而重写则与动态分派关联。动态分派是在运行期根据实际类型确当方法版本的分派过程。
1 public class Test1 { 2 3 static class Human { 4 public void say() { 5 System.out.println("Human"); 6 }; 7 } 8 9 static class Man extends Human { 10 @Override 11 public void say() { 12 System.out.println("Man"); 13 } 14 } 15 16 static class Woman extends Human { 17 @Override 18 public void say() { 19 System.out.println("Woman"); 20 } 21 } 22 23 public static void main(String[] args) { 24 Human man = new Man(); 25 Human woman = new Woman(); 26 man.say(); 27 woman.say(); 28 man = new Woman(); 29 man.say(); 30 } 31 }
输出:
Man
Woman
Woman
调用invokevirtua指令时进行动态分派,指令的运行时解析过程为:
1、找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C.
2、如果在类型C中找到与常量中的描述符和简单名称都相符的方法,进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;如果不通过,返回java.lang.IllegalAccessError异常。
3、否则,按照继承关系从下往上依次对C的各个父类进行第2步的搜索和验证过程。
4、如果始终没找到合适的方法,抛出java.lang.AbstractMethodError异常。
invokevirtual指令执行的第1步就是在运行期确定接收者的实际类型,这个过程就是Java语言中方法重写的本质。
2.2.3 单分派和多分派
方法的接收者和方法的参数统称为方法的宗量,根据这个可以把分派划分为单分派和多分派2种。
2.2.4 虚拟机动态分派的实现
动态分派是非常频繁的动作,而且动态分派的方法版本选择需要运行时在类的方法元数据中搜索合适的目标方法,因此虚拟机实际实现中基于性能的考虑,需要采用优化策略,最常用的是为类在方法区中建立一个虚方法表,使用虚方法表索引来代替元数据查找。虚方法表中存放着各个方法的实际入口地址,如果方法没有被重写,那子类的虚方法表里的地址入口和父类相同方法的地址入口是一致的,指向父类的实现入口。如果重写了则子类方法表中的地址会替换为指向子类实现版本的入口地址。