第8章 虚拟机字节码执行引擎
“虚拟机”是一个相对于“物理机”的概念,这两种机器都有代码执行能力,其区别是物理机的执行引擎是直接建立在处理器、缓存、指令集和操作系统层面上的,而虚拟机的执行引擎则是由软件自行实现的,因此可以不受物理条件制约地定制指令集与执行引擎的结构体系,能够执行那些不被硬件直接支持的指令集格式。
在《Java虚拟机规范》中制定了Java虚拟机字节码执行引擎的概念模型,这个概念模型成为各大发行商的Java虚拟机执行引擎的统一外观(Facade)。在不同的虚拟机实现中,执行引擎在执行字节码的时候,通常会有解释执行和编译执行两种选择,也可能两者兼备,还可能会有同时包含几个不同级别的即时编译器一起工作的执行引擎。
运行时栈帧结构
Java虚拟机以方法作为最基本的执行单元,“栈帧”(Stack Frame)则是用于支持虚拟机进行方法调用和方法执行背后的数据结构。
栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。每一个方法从调用开始至执行结束的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。
在编译Java程序源码的时候,栈帧中需要多大的局部变量表,需要多深的操作数栈就已经被分析计算出来,并且写入到方法表的Code属性之中。
局部变量表
一组变量值的存储空间,用于存放方法参数和方法内部定义的局部变量。局部变量表的容量以变量槽(Variable Slot)为最小单位。
当一个方法被调用时,Java虚拟机会使用局部变量表来完成参数值到参数变量列表的传递过程,即实参到形参的传递。如果执行的是实例方法(没有被static修饰的方法),那局部变量表中第0位索引的变量槽默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字“this”来访问到这个隐含的参数。其余参数则按照参数表顺序排列,占用从1开始的局部变量槽,参数表分配完毕后,再根据方法体内部定义的变量顺序和作用域分配其余的变量槽。
为了尽可能节省栈帧耗用的内存空间,局部变量表中的变量槽是可以重用的。
局部变量没有准备阶段,如果一个局部变量定义了但没有赋初始值,那它是完全不能使用的。编译器能在编译期间就检查到并提示出这一点。
操作数栈
操作数栈(Operand Stack)也常被称为操作栈,最大深度在编译的时候被写入到Code属性的max_stacks数据项之中。
当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈和入栈操作。
两个不同栈帧作为不同方法的虚拟机栈的元素,是完全相互独立的。但是在大多虚拟机的实现里都会进行一些优化处理,令两个栈帧出现一部分重叠。让下面栈帧的部分操作数栈与上面栈帧的部分局部变量表重叠在一起,这样做不仅节约了一些空间,更重要的是在进行方法调用时就可以直接共用一部分数据,无须进行额外的参数复制传递。
动态连接
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking)。
Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池里指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候就被转化为直接引用,这种转化被称为静态解析。另外一部分将在每一次运行期间都转化为直接引用,这部分就称为动态连接。
方法返回地址
退出方式:
- 正常调用完成:可能会有返回值传递给上层的方法调用者,主调方法的PC计数器的值就可以作为返回地址,栈帧中很可能会保存这个计数器值
- 异常调用完成:不会给它的上层调用者提供任何返回值,返回地址是要通过异常处理器表来确定,栈帧中一般不会保存这部分信息
方法退出:恢复上层方法的局部变量表和操作数栈,把返回值压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令等。
方法调用
方法调用并不等同于方法中的代码被执行,方法调用阶段唯一的任务就是确定被调用方法的版本(即调用哪一个方法),暂时还未涉及方法内部的具体运行过程。
解析
Class文件的编译过程中不包含传统程序语言编译的连接步骤,一切方法调用在Class文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址(直接引用)。
在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用,这种解析能够成立的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。换句话说,调用目标在程序代码写好、编译器进行编译那一刻就已经确定下来。这类方法的调用被称为解析(Resolution)。
调用方法的字节码指令:
- invokestatic:用于调用静态方法。
- invokespecial:用于调用实例构造器<init>()方法、私有方法和父类中的方法。
- invokevirtual:用于调用所有的虚方法。
- invokeinterface:用于调用接口方法,会在运行时再确定一个实现该接口的对象
- invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法。
非虚方法:在类加载的时候就可以把符号引用解析为该方法的直接引用
- 静态方法
- 私有方法
- 实例构造器
- 父类方法
- final修饰的方法
方法调用:
- 解析(Resolution):静态的过程,在编译期间就完全确定,在类加载的解析阶段就会把涉及的符号引用全部转变为明确的直接引用,不必延迟到运行期再去完成。
- 分派(Dispatch):可能是静态的也可能是动态的,按照分派依据的宗量数
可分为单分派和多分派。这两类分派方式两两组合就构成了静态单分派、静态多分派、动态单分派、动态多分派4种分派组合情况。
分派
- 静态分派
示例:
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);
}
}
hello,guy!
hello,guy!
Human man = new Man();
- 静态类型(Static Type):Human是变量man的静态类型,或外观类型(Apparent Type)
- 实际类型(Actual Type):Man是变量man的实际类型,或运行时类型(Runtime Type)
静态类型和实际类型在程序中都可能会发生变化。
静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变。
实际类型变化的结果在运行期才可确定。
// 实际类型变化
Human human = (new Random()).nextBoolean() ? new Man() : new Woman();
// 静态类型变化
sr.sayHello((Man) human)
sr.sayHello((Woman) human)
编译器在 重载 时是通过参数的静态类型而不是实际类型作为判定依据的。
所有依赖静态类型来决定方法执行版本的分派动作,都称为 静态分派。
静态分派的最典型应用表现就是方法重载。
静态分派发生在编译阶段,因此也可以归为解析。
- 动态分派
示例:
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();
}
}
man say hello
woman say hello
woman say hello
Java虚拟机根据实际类型分派方法执行版本的关键在于invokevirtual指令。invokevirtual指令的运行时解析过程:
- 找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C
- 如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;不通过则返回java.lang.IllegalAccessError异常。
- 否则,按照继承关系从下往上依次对C的各个父类进行第二步的搜索和验证过程。
- 如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。
在运行期根据实际类型确定方法执行版本的分派过程称为 动态分派。
重写应用了动态分派。
单分派与多分派
方法的接收者与方法的参数统称为方法的宗量。单分派是根据一个宗量对目标方法进行选择,多分派则是根据多于一个宗量对目标方法进行选择。
- 静态分派:方法的选择依据有2点
- 方法接受者的静态类型
- 方法参数的静态类型
所以Java语言的静态分派属于多分派类型。
- 动态分派:方法的选择依据有1点
- 方法接受者的实际类型
虚拟机动态分派的实现
一种基础而且常见的优化手段是为类型在方法区中建立一个虚方法表(Virtual Method Table,也称为vtable,与此对应的,在invokeinterface执行时也会用到接口方法表——Interface Method Table,简称itable),使用虚方法表索引来代替元数据查找以提高性能。
虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方法表中的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。如果子类中重写了这个方法,子类虚方法表中的地址也会被替换为指向子类实现版本的入口地址。
虚方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始值后,虚拟机会把该类的虚方法表也一同初始化完毕。
动态类型语言支持
Java虚拟机的字节码指令集的数量自从Sun公司的第一款Java虚拟机问世至今,只新增过一条指令,即invokedynamic指令。
这条新增加的指令是JDK 7的项目目标:实现动态类型语言(Dynamically Typed Language)支持而进行的改进之一,也是为JDK 8里可以顺利实现Lambda表达式而做的技术储备。
动态类型语言
动态类型语言的关键特征是它的类型检查的主体过程是在运行期而不是编译期进行的,满足这个特征的语言有很多,常用的包括:APL、Clojure、Erlang、Groovy、JavaScript、Lisp、Lua、PHP、Prolog、Python、Ruby、Smalltalk、Tcl,等等。
java.lang.invoke
主要目的是在之前单纯依靠符号引用来确定调用的目标方法这条路之外,提供一种新的动态确定目标方法的机制,称为“方法句柄”(Method Handle)。(类似于函数指针)
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
public class Main {
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 reveiver) throws Throwable {
// MethodType:代表“方法类型”,包含了方法的返回值(methodType()的第一个参数)和具体参数(methodType()第二个及以后的参数)。
MethodType mt = MethodType.methodType(void.class, String.class);
// lookup()方法来自于MethodHandles.lookup
// 作用是在指定类中查找符合给定的方法名称、方法类型,并且符合调用权限的方法句柄。
// 因为这里调用的是一个虚方法,按照Java语言的规则,方法第一个参数是隐式的,代表该方法的接收者
// 也即this指向的对象,这个参数以前是放在参数列表中进行传递,现在提供了bindTo()方法来完成这件事情。
return MethodHandles.lookup().findVirtual(reveiver.getClass(), "println", mt).bindTo(reveiver);
}
}
方法getPrintlnMH()中实际上是模拟了invokevirtual指令的执行过程,只不过它的分派逻辑并非固化在Class文件的字节码上,而是通过一个由用户设计的Java方法来实现。而这个方法本身的返回值(MethodHandle对象),可以视为对最终调用方法的一个“引用”。
与反射的区别:
- Reflection和MethodHandle机制本质上都是在模拟方法调用
- Reflection模拟Java代码层次的方法调用
- MethodHandle模拟字节码层次的方法调用
在MethodHandles.Lookup上的3个方法findStatic()、findVirtual()、findSpecial()对应于invokestatic、invokevirtual和invokespecial这几条字节码指令的执行权限校验行为,而这些底层细节在使用Reflection API时是不需要关心的。
- Reflection中的java.lang.reflect.Method对象远比MethodHandle机制中的java.lang.invoke.MethodHandle对象所包含的信息来得多。
- Reflection是方法在Java端的全面映像,包含了方法的签名、描述符以及方法属性表中各种属性的Java端表示方式,还包含执行权限等的运行期信息。
- MethodHandle仅包含执行该方法的相关信息。
Reflection是重量级,而MethodHandle是轻量级。
- 由于MethodHandle是对字节码的方法指令调用的模拟,那理论上虚拟机在这方面做的各种优化(如方法内联),在MethodHandle上也应当可以采用类似思路去支持(但目前实现还在继续完善中),而通过反射去调用方法则几乎不可能直接去实施各类调用点优化措施。
- Reflection API的设计目标是只为Java语言服务的,而MethodHandle则设计为可服务于所有Java虚拟机之上的语言,其中也包括了Java语言而已。
invokedynamic
invokedynamic指令与MethodHandle机制的作用是一样的,都是为了解决”指令方法分派规则完全固化在虚拟机之中的问题,把如何查找目标方法的决定权从虚拟机转嫁到具体用户代码之中,让用户有更高的自由度。
而且,它们两者的思路也是可类比的,都是为了达成同一个目的,只是一个用上层代码和API来实现,另一个用字节码和Class中其他属性、常量来完成。
每一处含有invokedynamic指令的位置都被称作“动态调用点(Dynamically-Computed Call Site)”。这条指令的第一个参数不再是代表方法符号引用的CONSTANT_Methodref_info常量,而是变为CONSTANT_InvokeDynamic_info常量,从这个新常量中可以得到3项信息:
- 引导方法(Bootstrap Method):有固定的参数,并且返回值规定是java.lang.invoke.CallSite对象,这个对象代表了真正要执行的目标方法调用。
- 方法类型(MethodType)
- 名称
根据CONSTANT_InvokeDynamic_info常量中提供的信息,虚拟机可以找到并且执行引导方法,从而获得一个CallSite对象,最终调用到要执行的目标方法上。
基于栈的字节码解释执行引擎
解释执行
编译过程:
在Java语言中,Javac编译器完成了程序代码经过词法分析、语法分析到抽象语法树,再遍历语法树生成线性的字节码指令流的过程。
因为这一部分动作是在Java虚拟机之外进行的,而解释器在虚拟机的内部,所以Java程序的编译就是半独立的实现。
基于栈的指令集
Javac编译器输出的字节码指令流,基本上是一种基于栈的指令集架构(Instruction Set Architecture,ISA),字节码指令流里面的指令大部分都是零地址指令,它们依赖操作数栈进行工作。
基于栈的指令集主要优点:
- 可移植
- 代码相对更加紧凑
- 编译器实现更加简单
基于栈的指令集主要缺点:
- 速度稍慢
- 指令数量更多
- 内存访问多
参考资料:《深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)》 周志明 著