在 Java 虚拟机规范中制定了虚拟机字节码执行引擎的概念模型,这个概念模型称为各种虚拟机执行引擎的统一外观(Facade)。在不同的虚拟机实现里面,执行引擎在执行 Java 代码的时候可能会有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择,也可能两者兼备,甚至还可能会包含几个不同级别的编译器执行引擎。但从外观上看起来,所有的 Java 虚拟机的执行引擎都是一致的:输入的是字节码文件,处理过程是字节码解析的等效过程,输出的是执行结果,下面将主要从概念模型的角度来讲解虚拟机的方法调用和字节码执行。
一.栈帧
栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈(Virtual Machine Stack)的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。每一个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。
每一个栈帧都包括了局部变量表、操作数栈、动态连接、方法返回地址和一些额外的附加信息。在编译程序代码的时候,栈帧中需要多大的局部变量表,多深的操作数栈都已经完全确定了,并且写入到方法表的 Code 属性之中,因此一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。
一个线程中的方法调用链可能会很长,很多方法都同时处于执行状态。对于执行引擎来说,在活动线程中,只有位于栈顶的栈帧才是有效的,称为当前栈帧(Current Stack Frame),与这个栈帧相关联的方法称为当前方法(Current Method)。执行引擎运行的所有字节码指令都只针对当前栈帧进行操作,在概念模型上,典型的栈帧结构下图所示。
1.局部变量表
局部变量表的容量以变量槽(Variable Slot,下称 Slot)为最小单位,boolean、byte、char、short、int、float、reference 和 returnAddress 8占用1个Slot,double和long占用两个Slot。虚拟机通过索引定位的方式使用局部变量表,索引值的范围是从 0 开始至局部变量表最大的 Slot 数量,局部变量表中第 0 位索引的 Slot 默认是用于传递方法所属对象实例的引用。如果访问的是 32 位数据类型的变量,索引 n 就代表了使用第 n 个 Slot,如果是 64 位数据类型的变量,则说明会同时使用 n 和 n+1 两个 Slot。对于两个相邻的共同存放一个 64 位数据的两个 Slot,不允许采用任何方式单独访问其中的某一个,Java 虚拟机规范中明确要求了如果遇到进行这种操作的字节码序列,虚拟机应该在类加载的校验阶段抛出异常。
为了尽可能节省栈帧空间,局部变量中的 Slot 是可以重用的,方法体中定义的变量,其作用域并不一定会覆盖整个方法体,如果当前字节码 PC 计数器的值已经超出了某个变量的作用域,那这个变量对应的 Slot 就可以交给其他变量使用。不过,这样的设计除了节省栈帧空间以外,还会伴随一些额外的副作用,例如,在某些情况下,Slot 的复用会直接影响到系统的垃圾收集行为。
2.操作数栈
操作数栈(Operand Stack)也常称为操作栈,它是一个后入先出(Last In First Out,LIFO)栈。同局部变量表一样,操作数栈的最大深度也在编译的时候写入到 Code 属性的 max_stacks 数据项中。操作数栈的每一个元素可以是任意的 Java 数据类型,包括 long 和 double。
当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈 / 入栈操作。例如,在做算术运算的时候是通过操作数栈来进行的,又或者再调用其他方法的时候是通过操作数栈来进行参数传递的。
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking)。通过前面的讲解,我们知道 Class 文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候就转化为直接引用,这种转化成为静态解析。另外一部分将在每一次运行期间转化为直接引用,这部分成为动态连接。
4.方法返回地址
当一个方法开始执行后,只有两种方式可以退出这个方法。第一种方式是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者(调用当前方法的方法称为调用者)。
另外一种退出方式是,在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,无论是 Java 虚拟机内部产生的异常,还是代码中使用 athrow 字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方法的方式称为异常完成出口(Abrupt Method Invocation Completion)。一个方法使用异常完成出口的方式退出,是不会给它的上层调用者产生任何返回值的。
无论采用何种退出方式,在方法退出之后,都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层方法的执行状态。一般来说,方法正常退出时,调用者的 PC 计数器的值可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中一般不会保存这部分信息。
方法退出的过程实际上就等同于把当前栈帧出栈,因此退出时可能执行的操作又:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整 PC 计数器的值以指向方法调用指令后面的一条指令等。
5.附加信息
二.方法的调用
方法调用并不等同于方法执行,方法调用阶段唯一的任务就是确定被调用方法的版本(即调用哪一个方法),暂时还不涉及方法内部的具体运行过程 。在程序运行时,进行方法调用是最普遍、最频繁的操作,但前面已经讲过,Class 文件的编译过程中不包含传统编译中的连接步骤,一切方法调用在 Class 文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址(相当于之前说的直接引用)。这个特性给 Java 带来了更强大的动态扩展能力,但也使得 Java 方法调用过程变得相对复杂起来,需要在类加载期间,甚至到运行期间才能确定目标方法的直接引用。1.解析
2.分派
2.1静态分派
package org.fenixsoft.polymorphic;
/**
* 方法静态分派演示
*
*/
public class StaticDispatch {
static abstract class Human {
}
static class Man extends Human {
}
static class Woman extends Human {
}
public void sayHello(Human guy) {
System.out.println("hello, guy!");
}
public void sayHello(Man guy) {
System.out.println("hello,gentleman");
}
public void sayHello(Woman guy) {
System.out.println("hello,lady!");
}
public static void main(String[] args) {
Human man = new Man();
Human woman = new Woman();
StaticDispatch sr = new StaticDispatch();
sr.sayHello(man);
sr.sayHello(woman);
}
}
我们把上面代码中的 “Human” 称为变量的静态类型(Static Type),或者叫做外观类型(Apparent Type),后面的 “Man” 则称为变量的实际类型(Actual Type),静态类型和实际类型在程序中都可以发生一些变化,区别是静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是在编译期可知的;而实际类型变化的结果在运行期才可确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么。
解释了这两个概念,再回到代码清单 8-6 的样例代码中。main() 里面的两次 sayHello() 方法调用,在方法接收者已经确定是对象 “sr” 的前提下,使用哪个重载版本,就完全取决于传入擦你数的数量和数据类型。代码中刻意地定义了两个静态类型相同但实际类型不同的变量,但虚拟机(准确地说是编译器)在重载时是通过参数的静态类型而不是实际类型作为判定依据的。并且静态类型是编译期可知的,因此,在编译阶段,javac 编译器会根据参数的静态类型决定使用哪个重载版本,所以选择了 sayHello(Human) 作为调用目标,并把这个方法的符号引用写到 main() 方法里的两条 invokevirtual 指令的参数中。
所有依赖静态类型来定位方法执行版本的分派动作称为静态分派。静态分派的典型应用是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的。另外,编译器虽然能确定出方法的重载版本,但在很多情况下这个重载版本并不是 “唯一的”,往往只能确定一个 “更加适合的” 版本。这种模糊的结论在由 0 和 1 构成的计算机世界中算是比较 “稀罕” 的事情,产生这种模糊结论的主要原因是字面量不需要定义,所以字面量没有显式的静态类型,它的静态类型只能通过语言上的规则去理解和推断。
2.2动态分派
package org.fenixsoft.polymorphic;
/**
* 方法动态分派演示
*
*/
public class DynamicDispatch {
static abstract class Human {
protected abstract void sayHello();
}
static class Man extends Human {
@Override
protected void sayHello() {
System.out.println("man say hello");
}
}
static class Woman extends Human {
@Override
protected void sayHello() {
System.out.println("woman say hello");
}
}
public static void main(String[] args) {
Human man = new Man();
Human woman = new Woman();
man.sayHello();
woman.sayHello();
man = new Woman();
man.sayHello();
}
}
public static void main(java.lang.String[]);
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=1
0: new #16 // class org/fenixsoft/polymorphic/DynamicDispatch$Man
3: dup
4: invokespecial #18 // Method org/fenixsoft/polymorphic/DynamicDispatch$Man."<init>":()V
7: astore_1
8: new #19 // class org/fenixsoft/polymorphic/DynamicDispatch$Woman
11: dup
12: invokespecial #21 // Method org/fenixsoft/polymorphic/DynamicDispatch$Woman."<init>":()V
15: astore_2
16: aload_1
17: invokevirtual #22 // Method org/fenixsoft/polymorphic/DynamicDispatch$Human.sayHello:()V
20: aload_2
21: invokevirtual #22 // Method org/fenixsoft/polymorphic/DynamicDispatch$Human.sayHello:()V
24: new #19 // class org/fenixsoft/polymorphic/DynamicDispatch$Woman
27: dup
28: invokespecial #21 // Method org/fenixsoft/polymorphic/DynamicDispatch$Woman."<init>":()V
31: astore_1
32: aload_1
33: invokevirtual #22 // Method org/fenixsoft/polymorphic/DynamicDispatch$Human.sayHello:()V
36: return
0 ~ 15 行的字节码是准备动作,作用是建立 man 和 woman 的内存空间、调用 Man 和 Woman 类型的实例构造器,将这两个实例的引用存放在第 1、2 个局部变量表 Slot 之中,这个动作也就对应了代码中的这两句:
-
Human man = new Man();
-
Human woman = new Woman();
接下来的 16 ~ 21 句是关键部分、16、20 两句分别把刚刚创建好的两个对象的引用压入到栈顶,这两个对象是将要执行的sayHello() 方法的所有者,称为接收者 (Receiver);17 和 21 句是方法调用指令,这两条调用指令但从字节码角度来看,无论是指令(都是 invokevirtual)还是参数(都是常量池中第 22 项的常量,注释显示了这个常量是 Human.sayHello() 的符号引用)完全一样的,但是这两句指令最终执行的目标方法并不相同。原因就需要从 invokevirtual 指令的多态查找过程开始说起,invokevirtual 指令的运行时解析过程大致分为以几个步骤:
- 找到操作数栈顶的第一个元素所执行的对象的实际类型,记作 C。
- 如果在类型 C 中找到与常量中的描述符合简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;如果不通过,则返回 java.lang.IllegalAccessError 异常。
- 否则,按照继承关系从下往上一次对 C 的各个父类进行第 2 步的搜索和验证过程。
- 如果始终没有找到合适的方法,则抛出 java.lang.AbstractMethodError 异常。
由于 invokevirtual 指令执行的第一步就是在运行期确定接收者的实际类型,所以两次调用中的invokevirtual指令把常量池中的类方法符号引用解析到了不同的直接引用上,这个过程就是Java 语言中方法重写的本质。我们把这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。
此外,Java虚拟机的静态分派是多分派,动态分派是单分派(多即表示有选择性)
由于动态分派是非常频繁的动作,而且动态分派的方法版本选择过程需要运行时在类的方法元数据中搜索合适的目标方法,因此在虚拟机的实际实现中基于性能的考虑,大部分实现都不会真正地进行如此频繁的搜索。面对这种情况,最常用的 “稳定优化” 手段就是为类在方法区中建立一个虚方法表(Virtual Method Table,也称为 vtable,与此对应的,在 invokeinterface 执行时也会用到接口方法表——Interface Method Table,简称 itable),使用虚方法表索引来代替元数据查找以提高性能。我们先看看但清单 8-10 所对应的虚方法表结构示例,如图 8-3 所示。
虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方法表里面的地址入口和父类相同方法的地址入口是一致的;都指向父类的实现入口。如果子类中重写了这个方法,子类方法表中的地址将会替换为指向子类实现版本的入口地址。图 8-3 中,Son 重写了来自 Father 的全部方法,因此 Son 的方法表没有指向 Father 类型数据的箭头。但是 Son 和 Father 都没有重写来自 Object 的方法,所以它们的方法表中所有从 Object 继承来的方法都指向了 Object 的数据类型。
三.动态类型语言支持
什么是动态类型语言(注意:动态类型语言与动态语言、弱类型语言并不是一个概念,需要区别对待)?动态类型语言的关键特征是它的类型检查的主体过程是在运行期而不是编译期。1.7以前,不支持。1.7后支持。
在拥有 Method Handle 之后,Java 语言也可以拥有类似于函数指针或者委托的方法别名的工具了。代码清单 8-11 演示了 MethodHandle 的基本用途,无论 obj 是何种类型(临时定义的 ClassA 异或是实现 PrintStream 接口的实现类 System.out),都可以正确地调用到 println() 方法。
import static java.lang.invoke.MethodHandles.lookup;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodType;
/**
* JSR-292 Method Handle 基础用法演示
*
*/
public class MethodHandleTest {
static class ClassA {
public void println(String s) {
System.out.println(s);
}
}
public static void main(String[] args) throws Throwable {
Object obj = System.currentTimeMillis() % 2 == 0 ? System.out : new ClassA();
// 无论 obj 最终是哪个实现类,下面这句都能正确调用到 println 方法
getPrintlnMH(obj).invokeExact("icyfenix");
}
private static MethodHandle getPrintlnMH(Object receiver) throws Throwable {
/*
* MethodType: 代表 “方法类型”, 包含了方法的返回值 (methodType() 的第一个参数)和
* 具体参数 (methodType() 第二个及以后的参数)
*/
MethodType mt = MethodType.methodType(void.class, String.class);
/*
* lookup() 方法来自于 MethodHandles.lookup, 这句的作用是在指定类中查找符合给定的
* 方法名称、方法类型, 并且符合调用权限的方法句柄
*/
/*
* 因为这里调用的是一个虚方法, 按照 Java 语言的规则,方法的第一个参数是隐式的, 代表该
* 方法的接收者,也即是 this 指向的对象, 这个参数以前是放在参数列表中进行传递的, 而现在提供了 bindTo()
* 方法来完成这件事
*/
return lookup().findVirtual(receiver.getClass(), "println", mt).bindTo(receiver);
}
}
在某种程度上,invokedynamic 指令与 MethodHandle 机制的作用是一样的,都是为了解决原有 4 条 “invoke*” 指令方法分派规则固化在虚拟机之中的问题,把如何查找目标方法的决定权从虚拟机转嫁到具体用户代码之中,让用户(包含其他语言的设计者)有更高的自有度。而且,它们两者的思路也是可类比的,可以把它们想象成为了达成同一个目的,一个采用上层 Java 代码和 API 来实现,另一个用字节码和 Class 中其他属性、常量来完成,因此,如果理解了前面的 MethodHandle 例子,那么理解 invokedynamic 指令也并不困难。
四.基于栈的字节码解释执行引擎
解释执行
Java 语言经常被人们定位为 “解释执行” 的语言,在 Java 初生的 JDK 1.0 时代,这种定义还算是比较准确的,但当主流的虚拟机中都包含了即时编译器后,Class 文件中的代码到底会被解释执行还是编译执行,就成了只有虚拟机自己才能准确判断的事情。再后来,Java 也发展处了可以直接生成本地代码的编译器【如 GCJ(GNU Compiler for the Java)】,而 C/C++ 语言也出现了通过解释器执行的版本(如 CINT),这时候再笼统地说 “解释执行”,对于整个 Java 语言来说就成了几乎是没有意义的概念,只有确定了谈论对象是某种具体的 Java 实现版本和执行引擎运行模式时,谈解释执行还是贬义执行才会比较确切。
不论是解释还是编译,也不论是物理机还是虚拟机,对于应用程序,机器都不可能如人那样阅读、理解,然后就获得了执行能力。大部分的程序代码到物理机的目标代码或虚拟机能执行的指令集之前,都需要经过图 8-4 中的各个步骤。如果读者对编译原理的相关课程还有印象的话,很容易就会发现图 8-4 中下面那条分支,就是传统编译原理中程序代码到目标机器代码的生成过程,而中间的那条分支,自然就是解释执行的过程。
如今,基于物理机、Java 虚拟机,或者非 Java 的其他高级语言虚拟机(HLLVM)的语言,大多都会遵循这种基于现代经典编译原理的思路,在执行前先对程序源码进行词法分析和语法分析处理,把源码转化为抽象语法树(Abstract Syntax Tree,AST)。对于一门具体语言的实现来说,词法分析、语法分析以至于后面的优化器和目标代码生成器都可以选择独立于执行引擎,形成一个完整意义的编译器去实现,这类代表是 C/C++ 语言。也可以选择把其中一部分步骤(如生成抽象语法树之前的步骤)实现为一个半独立的编译器,这类代表是 Java 语言。又或者把这些步骤和执行引擎全部集中封装在一个封闭的黑匣子之中,如大多数的 JavaScript 执行器。
Java 语言中,Javac 编译器完成了程序代码经过词法分析、语法分析到抽象语法树,再遍历语法树生成线性的字节码指令流的过程。因为这一部分动作是在 Java 虚拟机之外进行的,而解释器在虚拟机的内部,所以 Java 程序的编译就是半独立的实现。基于栈的指令集与基于寄存器的指令集
Java 编译器输出的指令流,基本上(注:使用 “基本上”,是因为部分字节码指令会带有参数,而纯粹基于栈的指令集架构中应当全部都是零地址指令,也即是都不存在显式的参数。Java 这样实现主要是考虑了代码的可校验性)是一种基于栈的指令集架构(Instruction Set Architecture, ISA),指令流中的指令大部分都是零地址指令,它们依赖操作数栈进行工作。与之相对的另外一套常用的指令集架构是基于寄存器的指令集,最典型的就是 x86 的二地址指令集,说得通俗一些,就是现在我们主流 PC 机中直接支持的指令集架构,这些指令依赖寄存器进行工作。那么,基于栈的指令集与基于寄存器的指令集这两者之间有什么不同呢?
举个最简单的例子,分别使用这两种指令计算 “1+1” 的结果,基于栈的指令集会是这样子的:
-
iconst_1
-
iconst_1
-
iadd
-
istore_0
两条 iconst_1 指令连续把两个常量 1 压入栈后,iadd 指令把栈顶的两个值出栈、相加,然后把结果放回栈顶,最后 istore_0 把栈顶的值放到局部变量表的第 0 个 Slot 中。
如果基于寄存器,那程序可能会是这个样子:
-
mov eax, 1
-
add eax, 1
mov 指令把 EAX 寄存器的值设为 1,然后 add 指令再把这个值加 1,结果就保存在 EAX 寄存器里面。
了解了基于栈的指令集与基于寄存器的指令集的区别后,读者可能会有进一步的疑问,这两套指令集谁更好一些呢?
应该这么说,既然两套指令集会同时并存和发展,那肯定是各有优势的,如果有一套指令集全面优于另外一套的话,就不会存在选择的问题了。
基于栈的指令集主要的有点就是可移植,寄存器由硬件直接提供(注:这里说的是物理机器上的寄存器,也有基于寄存器的虚拟机,如 Google Android 平台的 Dalvik VM。即使是基于寄存器的虚拟机,也希望把虚拟机寄存器尽量映射到物理寄存器上以获取尽可能高的性能),程序直接依赖硬件寄存器则不可避免地要受到硬件的约束。例如,现在 32 位 80x86 体系的处理器中提供了 8 个 32 位的寄存器,而 ARM 体系的 CPU(在当前的手机、PDA 中相当流行的一种处理器)则提供了 16 个 32 位的通用寄存器。如果使用栈架构的指令集,用户程序不会直接使用这些寄存器,就可以由虚拟机实现来自行决定把一些访问最频繁的数据(程序计数器、栈顶缓存等)放到寄存器中以获取尽量好的性能,这样实现起来也更加简单一些。栈架构的指令集还有一些其他的有点,如代码相对更加紧凑(字节码中每个字节就对应一条指令,而多地址指令集中还需要存放参数)、编译器实现更加简单(不需要考虑空间分配的问题,所需空间都在栈上操作)等。
栈架构指令集的主要缺点是执行速度相对来说会稍慢一些。所有主流物理机的指令集都是寄存器架构也从侧面印证了这一点。
虽然栈架构指令集的代码非常紧凑,但是完成相同功能所需的指令数量一般会比寄存器架构多,因为出栈、入栈操作本身就产生了相当多的指令数量。更重要的是,栈实现在内存之中,频繁的栈访问也就意味着频繁的内存访问,相对于处理器来说,内存始终是执行速度的瓶颈。尽管虚拟机可以采取栈顶缓存的手段,把最常用的操作映射到寄存器中避免直接内存访问,但这也只能是优化措施而不是解决本质问题的方法。由于指令数量和内存访问的原因,所以导致了栈架构指令集的执行速度会相对较慢。
基于栈的解释器执行过程
初步的理论知识已经讲解过了,本节准备了一段 Java 代码,看看在虚拟机中实际是如何执行的。前面曾经举过一个计算 “1+1” 的例子,这样的算术题目显然太过简单了,笔者准备了四则运算的例子,请看代码清单 8-16。
代码清单 8-16 一段简单的算术代码
-
public int calc() {
-
int a = 100;
-
int b = 200;
-
int c = 300;
-
return (a + b) * c;
-
}
从 Java 语言的角度来看,这段代码没有任何解释的必要,可以直接使用 javap 命令看看它的字节码指令,如代码清单 8-17 所示。
代码清单 8-17 一段简单的算术代码的字节码表示
-
public int calc();
-
Code:
-
stack=2, locals=4, args_size=1
-
0: bipush 100
-
2: istore_1
-
3: sipush 200
-
6: istore_2
-
7: sipush 300
-
10: istore_3
-
11: iload_1
-
12: iload_2
-
13: iadd
-
14: iload_3
-
15: imul
-
16: ireturn
javap 提示这段代码需要深度为 2 的操作数栈和 4 个 Slot 局部变量空间,笔者根据这些信息画了图 8-5 ~ 图 8-11 共 7 张图,用它们来描述代码清单 8-17 执行过程中的代码、操作数栈和局部变量表的变化情况。
上面的执行过程仅仅是一种概念模型,虚拟机最终会对执行过程做一些优化来提高性能,实际的运作过程不一定完全符合概念模型的描述……更准确地说,实际情况会和上面描述的概念模型差距非常大,这种差距产生的原因是虚拟机中解析器和即时编译器都会对输入的字节码进行优化,例如,在 HotSpot 虚拟机中,有很多以 “fast_” 开头的非标准字节码指令用于合并、替换输入的字节码以提升解释执行性能,而即时编译器的优化手段更加花样繁多。
不过,我们从这段程序的执行中也可以看出栈结构指令集的一般运行过程,整个运算过程的中间变量都以操作数栈的出栈、入栈为信息交换途径,符合我们在前面分析的特点。