下面讲解 Java 虚拟机是如何执行方法里面的字节码指令的。
解释执行
许多 Java 虚拟机的执行引擎在执行 Java 代码的时候都有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择。
无论是解释还是编译,也无论是物理机还是虚拟机,大部分的程序代码转换成物理机的目标代码或虚拟机能执行的指令集之前,都需要经过下图中的各个步骤。
如今,基于物理机、Java 虚拟机或者是非 Java 的其他高级语言虚拟机(HLLVM)的代码执行过程,大体上都会遵循这种符合现代经典编译原理的思路,在执行前先对程序源码进行词法分析和语法分析处理,把源码转化为抽象语法树(Abstract Syntax Tree,AST)。
在 Java 语言中,Javac 编译器完成了程序代码经过词法分析、语法分析到抽象语法树,再遍历语法树生成线性的字节码指令流的过程。因为这一部分动作是在 Java 虚拟机之外进行的,而解释器在虚拟机的内部,所以 Java 程序的编译就是半独立的实现。
基于栈的指令集
Javac 编译器输出的字节码指令流,基本上是一种基于栈的指令集架构,字节码指令流里面的指令大部分都是零地址指令,它们依赖操作数栈进行工作。与之相对的另外一套常用的指令集架构是基于寄存器的指令集,最典型的就是 x86 的二地址指令集,这些指令依赖寄存器进行工作。
那么,基于栈的指令集与基于寄存器的指令集这两者之间有什么不同呢?比如分别使用这两种指令集去计算 1+1 的结果,基于栈的指令集是这样的:
iconst_1
iconst_1
iadd
istore_0
两条 iconst _1 指令连续把两个常量 1 压入栈后,iadd 指令把栈顶的两个值出栈、相加,然后把结果放回栈顶,最后 istore_0 把栈顶的值放到局部变量表的第 0 个变量槽中。这种指令流中的指令通常都是不带参数的,使用操作数栈中的数据作为指令的运算输入,指令的运算结果也存储在操作数栈之中。
而如果用基于寄存器的指令集,那程序可能是这样的:
mov eax, 1
add eax, 1
mov 指令把 EAX 寄存器的值设为 1,然后 add 指令再把这个值加 1,结果就保存在 EAX 寄存器里面。这种二地址指令是 x86 指令集中的主流,每个指令都包含两个单独的输入参数,依赖寄存器来访问和存储数据。
基于栈的指令集的优缺点:
基于栈的指令集主要优点是可移植,因为寄存器由硬件直接提供,程序直接依赖这些硬件寄存器则不可避免地要受到硬件的约束。如果使用栈架构的指令集,用户程序不会直接用到这些寄存器,那就可以由虚拟机实现来自行决定把一些访问最频繁的数据放到寄存器中以获取更好的性能,这样实现起来也更简单一些。栈架构的指令集还有一些其他优点,如代码相对更加紧凑(字节码中每个字节就对应一条指令,而多地址指令集中还需要存放参数)、编译器实现更加简单(不需要考虑空间分配,所需空间都在栈上操作)等。
栈架构指令集的主要缺点是理论上执行速度相对会慢一些,因为在解释执行时,栈架构指令集完成相同功能所需的指令数量一般会比寄存器架构多,并且栈实现在内存中,频繁的栈访问意味着频繁的内存访问,相对于处理器中的寄存器来说,内存始终是执行速度的瓶颈。因此由于指令数量和内存访问的原因,导致了栈架构指令集的执行速度会相对慢一点。不过这主要是局限在解释执行的状态下,如果经过了即时编译器输出成物理机上的汇编指令流,那就与虚拟机采用哪种指令集架构没什么关系了。
方法运行时栈帧结构
Java 虚拟机以方法作为最基本的执行单元,栈帧(Stack Frame)则是用于支持虚拟机进行方法调用和方法执行背后的数据结构,也是虚拟机运行时数据区中的虚拟机栈中的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态连接、方法返回地址等信息,每一个方法从调用开始至执行结束的过程,都对应着一个栈帧在虚拟机栈里从入栈到出栈的过程。
在编译 Java 程序源码的时候,栈帧中需要多大的局部变量表,需要多深的操作数栈就已经被分析计算出来,并且写入到方法表的 Code 属性之中。换言之,一个栈帧需要分配多少内存,并不会受到程序运行期变量数据的影响,而仅仅取决于程序源码和具体的虚拟机实现的栈内存布局形式。
对于执行引擎来讲,在活动线程中,只有位于栈顶的方法才是在运行的,只有位于栈顶的栈帧才是生效的,其被称为当前栈帧,与这个栈帧所关联的方法被称为当前方法。执行引擎所运行的所有字节码指令都只针对当前栈帧进行操作,在概念模型上典型的栈帧结构如下图所示:
1. 局部变量表
局部变量表(Local Variables Table)是一组变量值的存储空间,除了用来存放方法入参以及方法内部定义的局部变量,还包含实例方法的 this 指针。
局部变量表所需的内存空间在编译期间完成分配,当 Java 程序被编译为 Class 文件时,就在方法的 Code 属性的 max_locals 数据项中确定了该方法所需分配的局部变量表的最大容量,局部变量表的容量以 变量槽 为最小单位。在方法运行期间不会改变局部变量表的大小。
在 Java 虚拟机规范中,局部变量表等价于一个数组,并且可以用正整数来索引。除了 long、double 值需要用两个变量槽来存储外,其他基本类型以及引用类型的值均占用一个变量槽。即 boolean、byte、char、short 这四种类型,在栈上占用的空间和 int 是一样的,和引用类型也是一样的。因此,在 32 位的 HotSpot 中,这些类型在栈上将占用 4 个字节;而在 64 位的 HotSpot 中,他们将占 8 个字节。
当然,这种情况仅存在于局部变量。对于 byte、char 及 short 这三种类型的字段或者数组,它们在堆上占用的空间分别为一字节、两字节以及两字节,是跟这些类型的值域相吻合的。
当一个方法被调用时,Java 虚拟机会使用 局部变量表 来完成参数值到参数变量列表的传递过程,即实参到形参的传递。如果是实例方法,那局部变量表中第 0 位索引的变量槽默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字 this 来访问到这个隐含的参数。其余参数则按照参数表顺序排列,参数表分配完毕后,再根据方法体内部定义的变量顺序和作用域分配其余的变量槽。
为了尽可能节省栈帧耗用的内存空间,局部变量表中的变量槽是可以重用的,方法体中定义的变量,其作用域并不一定会覆盖整个方法体,如果当前字节码 PC 计数器的值已经超出了某个变量的作用域,那这个变量对应的变量槽就可以交给其他变量来重用。
关于局部变量表,还有一点可能会对实际开发产生影响,我们知道类的字段变量有两次赋初始值的过程,一次在准备阶段,赋予系统初始值;另外一次在初始化阶段,赋予程序员定义的初始值。因此即使在初始化阶段程序员没有为类变量赋值也没有关系,类变量仍然具有一个确定的初始值,不会产生歧义。但局部变量不像类变量那样存在准备阶段。如果一个局部变量定义了但没有赋初始值,那它是完全不能使用的。所以不要认为 Java 中任何情况下都存在诸如整型变量默认为 0、布尔型变量默认为 false 等这样的默认值规则。
代码示例:
public void foo(long l, float f) {
{
int i = 0;
}
{
String s = "Hello, World";
}
}
以上面这段代码为例,由于它是一个实例方法,因此局部变量表中的第 0 个单元存放着 this 指针。第一个参数为 long 类型,于是第 1、2 两个单元存放着所传入的 long 类型参数的值。第二个参数则是 float 类型,于是第 3 个单元存放着所传入的 float 类型参数的值。
在方法体里的两个代码块中分别定义了两个局部变量 i 和 s。由于这两个局部变量的生命周期没有重合之处,因此 Java 编译器可以将它们编排至同一单元中。也就是说,局部变量数组的第 4 个单元将为 i 或者 s。
存储在局部变量表中的值,通常需要加载至操作数栈中,方能进行计算,得到计算结果后再存储至局部变量数组中。
2. 操作数栈
操作数栈(Operand Stack)是一个后入先出栈,主要用于保存计算的中间结果以及作为计算过程中变量临时的存储空间。同局部变量表一样,操作数栈的最大深度也在编译的时候被写入到 Code 属性的 max_stacks 数据项之中。操作数栈的每一个元素都可以是包括 long 和 double 在内的任意 Java 数据类型。32 位数据类型所占的栈容量为 1,64 位数据类型所占的栈容量为 2。
当一个方法刚开始执行时,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是 出栈 和 入栈 操作。例如整数加法的字节码指令 iadd,这条指令在运行时要求操作数栈中最接近栈顶的两个元素已经存入了两个 int 型的数值,当执行这个指令时,会把这两个 int 值出栈并相加,然后将相加的结果重新入栈。
另外在概念模型中,两个不同栈帧作为不同方法的虚拟机栈的元素,是完全相互独立的。但是在大多虚拟机的实现里都会进行一些优化处理,令两个栈帧出现一部分重叠。让下面栈帧的部分操作数栈与上面栈帧的部分局部变量表重叠在一起,这样做不仅节约了一些空间,更重要的是在进行方法调用时就可以直接共用一部分数据,无须进行额外的参数复制传递了,重叠的过程如下图所示:
3. 动态连接
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的 动态连接(Dynamic Linking)。Class 文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池里指向方法的符号引用作为参数。
这些符号引用一部分会在类加载阶段或者第一次使用的时候就被转化为直接引用,这种转化被称为 静态解析。另外一部分将在每一次运行期间都转化为直接引用,这部分就称为 动态连接。
4. 方法返回地址
当一个方法开始执行后,只有两种方式退出这个方法。第一种方式是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者,方法是否有返回值以及返回值的类型将根据遇到何种方法返回指令来决定,这种退出方法的方式称为 正常调用完成。
另一种退出方式是在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到妥善处理。无论是 Java 虚拟机内部产生的异常,还是代码中使用 athrow 字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方法的方式称为 异常调用完成。一个方法使用异常完成出口的方式退出,是不会给它的上层调用者提供任何返回值的。
无论采用何种退出方式,在方法退出后都必须返回到最初方法被调用时的位置,程序才能继续执行。通常当方法正常退出时,主调方法的 PC 计数器的值就可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址要通过异常处理器表来确定,栈帧中一般就不会保存这部分信息。
基于栈的解释器执行过程
下面通过一段示例代码,来展示在虚拟机里字节码是如何执行的:
public class CountTest {
public int calc() {
int a = 100;
int b = 200;
int c = 300;
return (a + b) * c;
}
}
使用 javap 命令查看它的字节码指令:
从图中可以看到,这段代码需要深度为 2 的操作数栈和 4 个变量槽的局部变量空间。
首先执行偏移地址为 0 的指令,bipush 指令的作用是将单字节的整型常量值(-128~127)推入操作数栈顶,跟随有一个参数,指明推送的常量值,这里是 100。然后执行偏移地址为 2 的指令,istore_1 指令的作用是将操作数栈顶的整型值出栈并存放到第 1 个局部变量槽中。后面 3、6、7、10 类似。
执行偏移地址为 11 的指令,iload_1 指令的作用是将局部变量表第 1 个变量槽中的整型值复制到操作数栈顶。执行偏移地址为 12 的指令,iload_2 指令把第 2 个变量槽的整型值入栈。
执行偏移地址为 13 的指令,iadd 指令的作用是将操作数栈中头两个栈顶元素出栈,做整型加法,然后把结果重新入栈。在 iadd 指令执行完毕后,栈中原有的 100 和 200 被出栈,它们的和 300 被重新入栈。
执行偏移地址为 14 的指令,iload_3 指令把存放在第 3 个局部变量槽中的 300 入栈到操作数栈中,此时操作数栈为两个 300。之后 imul 指令将操作数栈中头两个栈顶元素出栈,做整型乘法,然后把结果重新入栈。执行偏移地址为 16 的指令,ireturn 指令是方法返回指令之一,它将结束方法执行并将操作数栈顶的整型值返回给该方法的调用者。