第八章 虚拟字节码执行引擎
8.1 概述
执行引擎是Java虚拟机最核心的组成部分之一。虚拟机是一个相对于物理机的概念,这两种机器都有代码执行能力,其区别是物理机的执行引擎是直接建立在处理器、硬件、指令集和操作系统层面上的,而虚拟机的执行引擎则是由自己实现的,因此可以自行指定指令集与执行引擎的结构体系,并且能够执行那些不被硬件直接支持的指令集格式。
在Java虚拟机规范中制定了虚拟机字节码执行引擎的概念模型,这个概念模型成为各种虚拟机执行引擎的统一外观。从外观上看起来所有Java虚拟机执行引擎都是一致的:输入的是字节码文件,处理过程是字节码解析的等效过程,输出的是执行结果。
8.2 运行时栈帧结构
栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构,他是虚拟机运行时数据区中的虚拟机栈的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。每一个方法从调用开始到执行完成的过程,就对应着一个栈帧在虚拟机里面从入栈到出栈的过程。
每一个栈帧都包含了局部变量表、操作数栈、动态链接、方法返回地址和一些额外的附加信息。在编译程序代码的时候,栈帧中需要多大的局部变量表、多深的操作数栈都已经完全确定了,并且写入到方法表的code属性之中,因此一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。
一个线程中的方法调用链可能会很长,很多方法都同时处于执行状态。对于执行引擎来讲,活动线程中只有栈顶顶栈帧是有效的,成为当前栈帧,这个栈帧所关联的方法称为当前方法。执行引擎所运行的字节码指令都只针对当前栈帧进行操作。
8.2.1 局部变量表
局部变量表是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。局部变量表以变量槽为最小单位。虚拟机规范中并没有明确指明一个slot应占用的空间大小。reference是对象的引用,虚拟机规范没有说明他的长度,也没有明确指出这个引用应有怎样的结构,但一般来说,虚拟机实现至少都应当能从此引用中直接或间接的查找到对象在Java堆中的起始地址索引和方法区中的对象类型数据。对于64位的数据类型,虚拟机会以高位在前的方式为其分配两个连续的slot空间。由于局部变量表建立在线程的堆栈上,是线程私有的数据,无论读写两个连续的slot是否是原子操作,都不会引起数据安全问题。
在方法执行时,虚拟机使用局部变量表来完成参数值到参数变量列表的传递过程,如果是实例方法(非static方法),那么局部变量表中第0位索引的slot默认是用于传递方所属对象实例的引用,在方法中可以通过关键字“this”来访问这个隐含的参数。
8.2.2 操作数栈
操作数栈也常被称为操作栈,他是一个后入先出栈。同局部变量表一样,操作数栈的最大深度也是在编译的时候被写入到code属性max_stacks数据项之中。操作数栈的每一个元素可以是任意的Java数据类型,包括long和double.32位数据类型所占的栈容量为1,64位数据类型所占的栈容量为2.在方法执行的任何时候,操作数栈的深度都不会超过在max_stacks数据项中设定的最大值。
当一个方法开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令指向操作数栈中写入和读取内容,也就是入栈出栈操作。例如,在做算数运算的时候是通过操作数栈来进行的,又或者在调用其他方法的时候是通过操作数栈来进行参数传递的。
Java虚拟机的解释执行引擎称为“基于栈的执行引擎”,其中所指的栈就是操作数栈。
8.2.3 动态连接
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。我们知道Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用为参数。这些符号引用一部分会在类加载阶段或第一次使用的时候转化为直接引用,这种转化成为静态解析。另外一部分将在每一次的运行期间转化为直接引用,这部分成为动态连接。
8.2.4 方法返回地址
当一个方法执行后,有两种方式退出这个方法。第一种方式是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者,是否有返回值和返回值的类型将根据遇到何种方法返回指令来决定,这种退出方法的方式称为正常完成出口。
另外一种退出方式是在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理。无论是Java虚拟机内部产生的异常还是代码使用athrow字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方式称为异常完成出口。一个方法使用异常出口是不会给他的上层调用者产生任何返回值的。
8.3 方法调用
方法调用并不等同于方法执行,方法调用阶段唯一的任务就是确定被调用方法的版本(即调用哪一个方法),暂时还不涉及方法内部的具体执行过程。在程序运行时,进行方法调用是最普遍最频繁的操作,Class文件在编译过程中不包含传统编译的连接步骤,一切方法调用在Class文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址(相当于之前所说的直接引用)。这个特性给Java带来了更强大的动态扩展能力,但也使得Java方法的调用过程变得相对复杂起来,需要在类加载期间甚至到运行期间才能确定目标方法的直接引用。
8.3.1 解析
所有方法的调用中的目标方法在Class文件里面都是一个常量池中的符号引用,在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用。这种解析能成立的前提是:方法在程序运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不改变的。换句话说,调用程序在程序代码写好,编译器进行编译时就必须确定下来。这类方法的调用称为解析。
解析调用一定是一个静态的过程,在编译期间就完全确定,在类装载的解析阶段就会把涉及的符号引用全部转变为可确定的直接引用,不会延迟到运行期再去完成。而分派调用则可能是静态的也可能是动态的,根据分派依据的宗量数可分为单分派和多分派。这两类分派方式两两组合就构成了静态单分派、静态多分派、动态单分派、动态多分派四种分派情况。
8.3.2 分派
分派调用过程会揭示多态性特征的一些最基本的体现(如重载和重写)。
静态分派:所有依赖静态类型来定位方法执行版本的分派动作都称为静态分派。静态分派的最典型应用就是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的。另外,编译器虽然能确定出方法的重载版本,但在很多情况下这个重载版本并不是唯一的,往往只能确定一个更加适合的版本。这种模糊的结论在由0和1构成的计算机世界中是个比较“稀罕”的事件,产生这种模糊结论的主要原因是字面量不需要定义,所以字面量没有显式的静态类型,他的静态类型只能通过语言上的规则去理解和推断。
动态分派与“重写”由很大关联。