执行引擎Java虚拟机的核心组成之一,它是由软件自行实现的,能够执行那些不被硬件直接支持的指令集格式。
对于不同的虚拟机实现,执行引擎可能会有解释执行和编译执行或者两种兼备,但是所有执行引擎的输入输出都是一样的,输入的是字节码二进制流,输出的是执行结果
Java虚拟机以方法为最基本的执行单元,栈帧则是虚拟机用于方法调用和方法执行背后的数据结构,它是虚拟机运行时数据区中的虚拟机栈的基本元素。栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。一个栈帧需要分配多少内存具体虚拟机实现的栈内存布局形式。
以Java程序的角度来看,同一时刻,同一线程里面在调用堆栈的所有方法都处于执行状态。以执行引擎的角度来看,只有位于栈顶的栈帧才是生效的,其被称为当前栈帧,对应的方法被称为当前方法。栈帧结构如图8-1所示。
(一)局部变量表
局部变量表用于存放方法参数和方法内的局部变量,在Java程序被编译为class文件时,就在方法的Code属性的max_locals数据项中确定了该方法所需分配的局部变量表的最大容量。
局部变量表的容量以变量槽为最小单位,变量槽的大小并没有明确规定,只是说每个变量槽都需要可以存放boolean、 byte、char、short、int、float、reference或returnAddress类型的变量,这8种数据类型最大是32位。所以变量槽需要大于32位,但不一定是32位。
对于提到的8种数据类型,前6种和Java中的差不多,reference表示对一个对象实例的引用,returnAddress指向了一条字节码指令的地址,这种类型已经很少见了。像long和double这样64位的数据类型,Java虚拟机会以高位对齐的方法为其分配两个连续的变量槽空间。
Java虚拟机通过索引定位的方式使用局部变量表,若是32位的数据类型,索引N就对应着第N个变量槽,若是64位的数据类型,索引N就对应着第N和第N+1个变量槽。对于两个相邻的共同存放64位数据类型变量的变量槽,不允许单独访问某一个变量槽。
当一个方法被调用时,就会使用局部变量表来完成参数值到参数变量列表的传递过程。
为了节省栈帧所占用的内存空间,局部变量表中的变量槽是可以重用的。若当前字节码PC计数器已经超过了方法体内的某个变量计数范围,那么这个变量的变量槽就可以被其它变量重用。但有时候变量槽的复用会影响到垃圾收集。
代码8-1和8-2中的placeholder占用的空间都没有被回收,只有代码8-3的palceholder占用的空间被回收了。这是因为判断是否被回收的根本原因是:局部变量表中的变量槽是否还存在对placeholder的引用。8-1是因为执行System.gc的时候还在placeholder的作用域中;8-2是因为placeholder原本所占用的变量槽还没有被其它变量复用。8-3中,已经不在placeholder的作用域中了,并且int a=0复用了placeholder原本占用的变量槽。在这里如果手动给placeholder赋null值也是一样的,但是赋null值在经过即使编译优化后是会被当作无效操作消除掉的。
局部变量不像前面提到的类变量存在准备阶段,前文的类变量会在准备阶段被赋一个系统初始值,然后在初始化阶段被赋一个定义的初始值,所以即使代码中没有给类变量赋初始值也是可以的。但是局部变量如果定义了但没赋初始值,它就是完全不能使用的。
(二)操作数栈
操作数栈和局部变量表类似,也是在编译期间最大深度就写入了Code属性的max_stacks数据项之中。32位数据类型占一个栈帧,64位数据类型占两个栈帧。
在方法执行时,是先把运算涉及的操作数压入到栈中,然后调用运算指令,使对应的数出栈进行运算,再把结果入栈。栈中的元素类型与字节码指令指令需要严格匹配,如iadd指令,栈顶的两个元素必须是两个int类型的。
在概念模型中,两个不同栈帧作为不同虚拟机栈的元素,是完全独立的,但是大部分虚拟机实现中,栈帧是有重叠部分的。如图所示。
(三)动态连接
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。在类加载阶段或者第一次使用符号引用被转化为直接引用,这是静态解析。在运行时符号引用转化为直接引用,这是动态连接。
(四)方法返回地址
当一个方法开始执行后,只有两种方式退出这个方法。第一种就是遇到一个方法返回的字节码指令,这种方式叫做”正常调用完成”。第二种就是遇到异常,并且还没有处理好遇到的异常,这种方式叫做”异常调用完成”。
无论采用哪种退出方式,方法退出后都必须回到最初方法被调用的位置。
方法退出过程等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值压入调用者的操作数栈中,调整PC计数器的值以指向后一条指令。
(五)附加信息
在讨论概念时,一般会把动态连接、方法返回地址与其他附加信息全部归为一类,称为栈帧信息。
方法调用并不涉及到具体的执行,只是确定方法的版本(调用哪个方法)。一切方法调用在class文件中只是符号引用,而不是实际运行时内存布局中的入口地址(直接引用)。这使得某些类在类加载期间甚至是运行期间才能确定目标方法的直接引用。
(一)解析
在类加载阶段,就有一部分符号引用转化为直接引用,这个前提是方法在程序运行前就有一个确定的版本。这类方法的调用被称为解析。
在Java中符合“编译器可知,运行期不可变”的,主要有静态方法和私有方法。
调用不同类型的方法,字节码指令集中有不同的指令:
invokestatic:调用静态方法
invokespecial:调用实例构造器方法、父类方法和私有方法
invokevirtual:调用虚方法
invokeinterface:调用接口方法
invokedynamic:先动态解析出调用点限定符所引用的方法,再执行该方法。
只要能被invokestatic和invokespecial调用的方法,都可在解析阶段确定唯一版本。有静态方法、私有方法、类的构造方法、父类方法四种,还有被final修饰的方法,不过被final修饰的方法被invokevirtual调用。这五种方法在类加载阶段就把符号引用替换为直接引用,它们被称为“非虚方法”,其它的方法被称为“虚方法”。
如图,静态方法sayHello只属于该类型,没有任何方式覆盖或者隐藏这个方法。
用javap指令查看字节码,发现的确是通过invokestatic方法调用的sayHello()方法
(二)分派
Java是一门面向对象的程序语言,它具备面向对象的3个基本特征:封装、继承、多态。本节的分派调用过程将会揭多态的一些基本体现。
1.静态分派
分派这个词本来就具有动态性,在书中英文是“Method Overload Resolution”,即应该属于8.2中的解析。不过很多中文资料都称这种行为为静态分派。
运行结果为: hello,guy!
hello,guy!
对于变量man和woman来说,Human是静态类型,而对应的Man和Woman是运行时类型(实际类型)。变量最终的静态类型在编译期可知,而实际类型在运行期才可以确定。
虚拟机在重载时是通过参数的静态类型作为依据的,在编译期间就根据参数的静态类型选择了调用的方法。静态分派最典型的就是重载,这发生在编译期间,所以实际上静态分派动作并不是由虚拟机执行的。
重载方法匹配并不一定就是完全对应的,也会自动转换,这个转换是有优先级的。比如字符’a’,它的重载匹配的方法是按照参数类型为char>int>long>float>double的顺序转型进行匹配。如果都没有,就会进行自动装箱,匹配到Character。把Character类型的参数再注释掉,会匹配Character实现的接口Serializable和 Comparable。再没有也有可能会匹配到父类Object。
2.动态分派
静态分派是和重载有着很大的关系,动态分派是和重写有着很大的关系。
运行结果是man say hello
woman say hello
woman say hello
代码中的两个变量静态类型都是Human,实际类型是Man和Woman,变量man的实际类型在之后还变成了Woman。
根据字节码,可以看出两个调用无论指令还是参数都一样,但是最终执行的目标方法不同,那么就是invokevirtual有着一些判断。
invokevirtual的解析过程大致为下面几步:
1)找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C。
2)如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;不通过则返回java.lang.IllegalAccessError异常。
3)否则,按照继承关系从下往上依次对C的各个父类进行第二步的搜索和验证过程。
4)如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。
所以说invokevirtual还会根据方法接收者的实际类型去选择方法版本。这个过程就是Java重写的本质,这种分派过程就是动态分派。
多态性的根源在于虚方法调用invokevirtual,所以字段是没有多态的。当子类有与父类同名字段,虽然内存中两个字段都存在,但实际上子类会覆盖父类的字段。
结果分析:首先子类隐式调用了父类的构造方法,父类构造方法调用的showMeTheMoney是虚方法,虚方法看的是实际类型,所以其实是Son::showMeTheMoney,这个时候Son的money字段还为0,输出了第一行。然后就到了Son的构造方法的调用,输出了第二行。主方法中访问money字段,看的是静态类型,所以是Father中的2。
3.单分派与多分派
方法的接收者与方法的参数统称为方法的宗量,根据宗量的多少可以把方法分为单分派和多分派。
编译阶段编译器选择的过程,也就是静态分派的过程。这时候选择目标的根据:1、静态类型是Father还是Son 2、参数类型是360还是QQ。这根据了两个宗量进行选择,所以静态分派过程是多分派。
运行阶段虚拟机的选择,也就是动态分派的过程。在执行“son.hardChoice(new QQ())”使,已经确定参数类型是QQ了,所以唯一影响选择的就是接收者的实际类型是Father还是Son。只根据了一个宗量进行选择,所以动态分派过程是单分派。
所以Java语言是一门静态多分派,动态单分派的语言。
4.虚拟机动态分派的实现
动态分派是执行非常频繁的动作,所以真正运行时不会如此频繁的去搜索元数据,而是会建立一个虚方法表。
虚方法表中存放着各个方法的实际入口地址,如果某个方法在子类中没有被重写,那么它在子类和父类的地址是一样的,都指向父类的实现入口。如图8-3,Son重写了Father的全部方法,所以没有指向Father的箭头,但是他们俩都没有重写Object的方法,所以都有指向Object的箭头。