目录
虚拟机的执行引擎不是直接建立在处理器、硬件、指令集和操作系统层面的,而是由自己实 现的,因此可以自行制定指令集与执行引擎的结构体系,并能够执行哪些不被硬件直接支持 的指令集格式。
在不同的虚拟机实现里面,执行引擎在执行 Java 代码的时候可能会有解释执行和编译执行 (通过即时编译器产生本地代码)两种选择,也可能两者兼备。但从外观上看起来,所有的 Java 虚拟机都是一样的:输入的是字节码文件,处理过程是字节码解析的等效过程,输出 的是执行过程。
1、运行时栈帧结构
Java虚拟机以方法作为最基本的执行单元,栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机 运行时数据区中的虚拟机栈的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态链接、 返回地址等信息。每一个方法从调用开始至执行完成过程,都对应着一个栈帧在虚拟机栈里 面从入栈到出栈的过程。
在编译程序代码时,栈帧中需要多大的局部变量表,多深的操作数栈都已经完全确定了,并 且写入到方法表的 Code 属性之中。因此,一个栈帧需要分配多少内存,不会受到程序运行 期变量数据的影响,而仅仅取决于具体的虚拟机实现。
一个线程中的方法调用链可能会很长,以Java程序的角度看,很多方法同时处于执行状态。对于执行引擎来说,在活动线程中,只有位于栈顶的栈帧才是有效的,称为当前栈帧,与这个栈帧相关联的方法称为当前方法。执行引擎运行的所有字节码指令都只针对当前栈帧进行操作。
1.1、局部变量表
局部变量表(Local Variable Table)是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。方法的 Code 属性的 max_locals 数据项中确定了该方法所需要分配的局部变量表的最大容量。
局部变量表的容量以变量槽(Slot)为最小单位,每个 Slot 都应该能存放一个 boolean、 byte、char、short、Reference 等类型的数据,允许 Slot 的长度可以随着处理器、操作系统或虚拟机的实现不同而发生变化。
一个 Slot 可以存放一个对象实例的引用,虚拟机能够通过这个引用做到两点:
- 根据引用直接或间接地查找对象在 Java 堆中的数据存放的起始地址或索引;
- 根据引用直接或间接地查找到对象所属数据类型在方法区中的存储的类型信息。
局部变量表是线程私有的数据,无论读写两个连续的 Slot(long、double)是否为原子操 作,都不会引起线程安全问题。
对于 64 位的数据类型,虚拟机会以高位对齐的方式为其分配两个连续的 Slot 空间;
虚拟机通过索引定位的方式使用局部变量表,索引值的范围从 0 开始至局部变量表最大的 Slot 数量。对于两个相邻的共同存放一个 64 位数据的两个 Slot,不允许采用任何方式单独访问其中的某一个。
在方法调用时,虚拟机是使用局部变量表完成参数值到参数变量列表的传递过程的,即实参到形参的传递。如果执行的是实例方法(没有被static修饰的方法),那局部变量表的第 0 位索引的 Slot 默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字 this 来访问到这个隐含的参数。其他参数则按照参数表顺序排列,占用从 1 开始的局部变量 Slot,参数分配完毕后,再根据方法体内部定义的变量顺序和作用域分配其余的 Slot。
局部变量表中的 Slot 是可以重用的,方法体中定义的变量,其作用域并不一定会覆盖整个方法体,如果当前字节码 PC 计数器的值已经超出了某个变量的作用域,那整个变量对应的 Slot 就可以交给其他变量使用。Slot 的复用会直接影响到系统的垃圾收集行为。
1.2 操作数栈
操作数栈(Operand Stack)是一个后入先出栈。操作数栈的最大深度也是在编译的时候就写入到 Code 属性的 max_stacks 数据项中。操作数栈的每一个元素可以是任意的 Java 数据类型,包括 long 和 double。32 位数据类型所占的栈容量为 1,64 位数据类型所占的栈容量为 2。在方法执行的任何时候,操作数栈的深入都不会超过 max_stack;
当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,即出栈入栈过程。
操作数栈中的元素类型必须与字节码指令序列严格匹配,在编译程序代码的时候,编译器必须严格保证这一点,在类校验阶段的数据流分析中还要再次验证这一点。
在概念模型中,两个栈帧作为不同方法的虚拟机栈的元素,是完全相互独立的。但在大多数虚拟机的实现里都会做一些优化处理,令两个栈帧出现一部分重叠。让下面栈帧的部分操作数栈与上面 栈帧的部分局部变量表重叠在一起,这样在进行方法调用时就可以共用一部分数据,无须进行额外的参数复制传递。
1.3 动态连接
每个栈帧都包含一个执行运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking)。Class 文件的常量池存有大量的符号引用, 字节码中的方法调用指令就以常量池中指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候就转化为直接引用,这种转化称为静态解析。另外一 部分将在每一次运行期间转化为直接引用,这部分称为动态连接。
1.4 方法返回地址
当一个方法开始执行后,只有两种方式可以退出这个方法。第一种方式是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者,方法是否有返回值和返回值的类型将根据遇到何种方法返回指令来决定,这种退出方法的方式称为“正常调用完成”。
另外一种退出方式是,在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,无论是 Java 虚拟机内部产生的异常,还是代码中使用 athrow 字节码指令产生的异常, 只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方法 的方式称为“异常调用完成”。一个方法使用异常完成出口的方式退出,是不会给它的上层调用者产生任何返回值的。
无论何种退出方式,在方法退出之后,都需要返回到最初方法被调用的位置,程序才能继续执行。 一般来说,方法正常退出时,调用者的 PC 计数器的值可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是通过异常处理器确定的,栈帧中一般不会保存这部分信息。
方法退出的过程实际上就等同于把当前栈帧出栈,因此退出可能的操作有:
- 恢复上层方法的局部变量表和操作数栈;
- 把返回值压入调用者栈帧的操作数栈中;
- 调整 PC 计数器的值以指向方法调用指令后面的一条指令。