一. 运行时栈结构
在介绍运行时栈结构之前,我们先回忆一下虚拟机运行时数据区:
本部分所讲述的运行时栈结构就是对程序运行时虚拟机栈中信息的具体描述。
栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。每一个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。
每一个栈帧都包括了局部变量表、操作数栈、动态连接、方法返回地址和一些额外的附加信息。在编译程序代码的时候,栈帧中需要多大的局部表量表,多深的操作数栈都已经完全确定了,并且写入到方法表的Code属性之中,因此一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。
一个线程中的方法调用链可能很长,很多方法都同时处于执行状态。对于执行引擎来说,在活动线程中,只有位于栈顶的栈帧才是有效的,称为当前栈帧,与这个栈帧相关联的方法称为当前方法。执行引擎运行的所有字节码都只针对当前栈帧进行操作。
栈帧的概念结构如下图所示:
下面将详细描述一下栈帧中的局部变量表、操作数栈、动态连接、方法返回地址等各个部分的作用和数据结构。
1. 局部变量表
局部变量表是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。在Java程序编译为Class文件时,就在方法的Code属性的max_locals数据项中确定了该方法所需要分配的局部变量表的最大容量。
局部变量表的容量以变量槽(Slot)为最小单位,虚拟机规范并没有明确指明一个Slot应占用的内存空间大小,只是说到每个Slot都应该能存放一个boolean、byte、char、short、int、float、reference或return_address类型的数据。由于局部变量表建立在线程的堆栈上,是线程私有的数据,无论读写两个连续的Slot是否为原子操作,都不会引起数据安全问题。
虚拟机通过索引定位的方式使用局部变量表,索引值的范围从0开始到局部变量表最大的Slot数量。如果访问的是32位数据类型的变量,索引n就代表了使用第n个Slot,如果是64位数据类型的变量,则说明会同时使用n和n+1两个Slot。对于两个相邻的共同存放一个64位数据的两个Slot,不允许采用任何方式单独访问其中的某一个。
在方法执行时,虚拟机使用局部变量表完成参数值到参数变量列表的传递过程,如果执行的是实例方法(非static的方法),局部变量表中第0位索引的Slot默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字“this”来访问这个隐含的参数。其余参数则按照参数表顺序排列,占用从1开始的局部变量Slot,参数表分配完毕后,再根据方法体内部定义的变量顺序和作用域分配其余的Slot。
局部变量表不存在类变量的“准备阶段”,使得即便不赋初值也仍然具有一个确定的初始值。局部变量不一样,如果一个局部变量定义了但没有赋初始值是不能使用的,在IDE工具中会检查并提示这一点。
2. 操作数栈
操作数栈也称为操作栈,它是一个后入先出栈。同局部变量表一样,操作数栈的最大深度也在编译的时候写入到Code属性的max_stacks数据项中。操作数栈的每一个元素可以是任意的Java数据类型,包括long和double。32位数据类型所占的栈容量为1,64位数据类型所占的栈容量为2。在方法执行的任何时候,操作数栈的深度都不会超过在max_stacks数据项中设定的最大值。
当一个方法刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈/入栈操作。例如,在做算术运算的时候是通过操作数栈来进行的,又或者在调用其他方法的时候是通过操作数栈来进行参数传递的。
举例来讲,整数加法的字节码指令iadd
在运行的时候操作数栈中最接近栈顶的两个元素已经存入了两个int型的数值,当执行这个指令时,会将这两个int值出栈并相加,然后将相加的结果入栈。
操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,在编译程序代码的时候,编译器要严格保证这一点,在类校验阶段的数据流分析中还要再次验证这一点。以iadd
指令为例,这个指令用于整型数加法,它在执行时,最接近栈顶的两个元素的数据类型必须为int类型,不能出现一个long型和一个float使用iadd
命令相加的情况。
Java虚拟机的解释执行引擎称为“基于栈的执行引擎”即是指的操作数栈。
3. 动态连接
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。
Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候就转化为直接引用,这种转化称为静态解析。另外一部分将在每一次运行期间转化为直接引用,这部分称为动态连接。
4. 方法返回地址
当一个方法开始执行后,只有两种方式可以退出这个方法:
- 第一种方式是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者,是否有返回值和返回值的类型将根据遇到何种方法返回指令来决定,这种退出方法的方式称为正常完成出口。
- 另一种退出方式是在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,无论是Java虚拟机内部产生的异常,还是代码中使用athrow字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方法的方式称为异常完成出口。一个方法是用异常完成出口的方式退出,不会给它的上层调用者产生任何返回值。
无论采用何种退出方式,在方法退出之后,都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层方法的执行状态。一般来说,方法正常退出时,调用者的PC计数器的值可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中一般不会保存这部分信息。
方法退出的过程实际上就等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面一条指令等。
5. 附加信息
虚拟机规范允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧之中,例如与调试有关的信息,这部分信息完全取决于具体的虚拟机实现。在实际开发中,一般会把动态连接、方法返回地址与其他附加信息全部归为一类,称为栈帧信息。
二. 方法调用
方法调用并不等同于方法执行,方法调用阶段唯一的任务就是确定被调用方法的版本(即调用哪一个方法),暂时还不涉及方法内部的具体运行过程。在程序运行时,进行方法调用时最普遍、最频繁的操作,但Class文件的编译过程不包含传统编译中的连接步骤,一切方法调用在Class文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址。这个特性给Java带来了更强大的动态扩展能力,但也使得Java方法调用过程变得相对复杂起来,需要在类加载期间,甚至到运行期间才能确定目标方法的直接引用。
1. 解析
所有方法调用中的目标方法在Class文件里面都是一个常量池中的符号引用,在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用,这种解析能成立的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。即是说:调用目标在程序代码写好、编译器进行编译时就必须确定下来。这类方法的调用称为解析。
在Java语言中符合“编译期可知,运行期不可变”这个要求的方法,主要包括静态方法和私有方法两大类,前者与类型直接关联,后者在外部不可被访问,这两种方法各自的特定决定了它们都不可能通过继承或别的方式重写其他版本,因此它们都适合在类加载阶段进行解析。
在Java虚拟机里面提供了5条方法调用字节码指令:
- invokestatic:调用静态方法
- invokespecial:调用实例构造器
方法、私有方法和父类方法 - invokevirtual:调用所有的虚方法
- invokeinterface:调用接口方法,在运行时确定一个实现此接口的对象。
- invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方,其分派逻辑是由用户所设定的引导方法决定的。
只要能被invokestatic和invokespecial指令调用的方法,都可以在解析阶段中确定唯一的调用版本,符合这个条件的有静态方法、私有方法、实例构造器、父类方法4类,它们在类加载的时候就会把符号引用解析为该方法的直接引用。这些方法可以称为非虚方法,与之相反,其他方法称为虚方法(除去final方法)。虽然final方法是使用invokevirtual指令来调用的,但是由于它无法被覆盖,没有其他版本,所以也无须对方法接收者进行多态选择。
解析调用一定是个静态的过程,在编译期间就完全确定,在类装载的解析阶段就会把涉及的符号引用全部转变为可确定的直接引用,不会延迟到运行期间再去完成。而分派调用则可能是静态的也可能是动态的。
2. 分派
Java是一门面向对象的语言,它具备面向对象的3个基本特征:继承、封装、多态。分派调用过程将揭示多态特征的一些最基本的体现,如“重载”和“重写”在Java虚拟机之中是如何实现的--虚拟机如何确定正确的目标方法。
<1> 静态分派
所有依赖静态类型来定位方法执行版本的分派动作称为静态分派。静态分派的典型应用是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作不是由虚拟机来执行的。另外,编译器虽然能确定出方法的重载版本,但在很多情况下这个重载版本并不是“唯一的”,往往只能确定一个“更加合适的”版本。
如下面的代码:
//重载方法匹配优先级
public class Overload {
public static void sayHello(int arg) {
System.out.println("hello int");
}
public static void sayHello(long arg) {
System.out.println("hello long");
}
public static void sayHello(char arg) {
System.out.println("hello char");
}
}
<2> 动态分派
静态分派与重载(overload)有关,动态分派与重写(override)有密切的关联。
invokevirtual指令的运行时解析过程大致分为如下步骤:
(1) 找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C。
(2) 如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;如果不通过,则返回java.lang.IllegalAccessError
异常。
(3) 否则,按照继承关系从下往上依次对C的各个父类进行第2步的搜索和验证过程。
(4) 如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError
异常。
由于invokevirtual指令执行的第一步就是在运行期确定接收者的实际类型,所以两次调用中的invokevirtual指令把常量池中的类方法符号引用解析到了不同的直接引用上,这个过程就是Java语言中方法重写的本质。这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。
三. 基于栈的字节码解释执行引擎
本节的内容是探讨虚拟机是如何执行方法中的字节码指令的。
1. 解释执行
不论是解释执行还是编译执行,也不论是物理机还是虚拟机,对于应用程序,机器都不可能如人那样阅读、理解,然后就自动获得了执行能力。大部分的程序代码到物理机的目标代码或虚拟机能执行的指令集之前,都要经过如下的几个步骤:
基于物理机、Java虚拟机,或者非Java的其他高级语言虚拟机的语言,大多都会遵循这种基于现代经典编译原理的思路,在执行前先对程序源码进行词法分析和语法分析处理,把源码转化为抽象语法树。
Java语言中,Javac编译器完成了程序代码经过词法分析、语法分析到抽象语法树,再遍历语法树生成线性字节码指令流的过程。Javac完成的这一部分动作是在虚拟机之外进行的,而解释器是在虚拟机内部的。
2. 基于栈的指令集与基于寄存器的指令集
Java编译器输出的指令流,基本上是一种基于栈的指令集架构,指令流中的指令大部分都是零地址指令,它们依赖操作数栈进行工作。相对应的另一套常用的指令集架构是基于寄存器的指令集,最典型的就是x86的二进制指令集,这些指令集依赖寄存器进行工作。
//基于栈的指令集执行计算 1 + 1
iconst_1
iconst_1
iadd
istore_0
//基于寄存器的指令集执行计算 1 + 1
mov eax, 1
add eax, 1
基于栈的指令集主要的优点是可移植,而基于寄存器的指令集由于受寄存器硬件的影响,不同的架构具有不同的设计实现。
基于栈的指令集的主要缺点是执行速度相对来说会慢一些。虽然栈架构指令集的代码非常紧凑,但是完成相同功能所需的指令数据一般会比寄存器架构多,因为出栈、入栈操作本身就产生了相当多的指令数量。更重要的是,栈实现在内存之中,频繁的栈访问也就意味着频繁的内存访问,相对于处理器中的寄存器而言,内存始终是执行速度的瓶颈。
3. 基于栈的解释器执行过程
在基于栈结构指令集的运行过程中,整个运算过程的中间变量都以操作数栈的出栈、入栈作为信息交换途径。