第五章 虚拟机字节码执行引擎
1. 概述
“虚拟机”是一个相对于“物理机”的概念,物理机的执行引擎是直接建立在处理器、缓存、指令集和操作系统层面上的,而虚拟机的执行引擎则是由软件自行实现的,可以不受物理条件制约地定制指令集与执行引擎的结构体系,能够执行那些不被硬件直接支持的指令集格式。
在不同的虚拟机实现中,执行引擎在执行字节码的时候,通常会有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择,也可能两者兼备,还可能会有同时包含几个不同级别的即时编译器一起工作的执行引擎。
2. 运行时栈帧结构
Java虚拟机以方法作为最基本的执行单元,“栈帧”(stack frame)则是用于支持虚拟机进行方法调用和方法执行背后的数据结构。每一个方法从调用开始到执行结束的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。
一个线程中的方法调用链可能会很长,以Java程序的角度来看,同一时刻、同一条线程里面,在调用堆栈的所有方法都同时处于执行状态。而对于执行引擎来讲,在活动线程中,只有位于栈顶的方法才是在运行的, 只有位于栈顶的栈帧才是生效的, 其被称为“当前栈帧”(Current Stack Frame) ,与这个栈帧所关联的方法被称为“当前方法”(Current Method) 。执行引擎所运行的所有字节码指令都只针对当前栈帧进行操作,在概念模型上,典型的栈帧结构如图8-1所示。
2.1 局部变量表
局部变量表(Local Variables Table)是一组变量值的存储空间,用于存放方法参数和方法内部定义的局部变量。
由于局部变量表是建立在线程堆栈中的,属于线程私有的数据,无论读写两个连续的变量槽是否为原子操作,都不会引起数据竞争和线程安全问题。
当一个方法被调用时,Java虚拟机会使用局部变量表来完成参数值到参数变量列表的传递过程,即实参到形参的传递。
为了尽可能节省栈帧耗用的内存空间,局部变量表中的变量槽是可以重用的,方法体中定义的变量,其作用域并不一定会覆盖整个方法体,如果当前字节码PC计数器的值已经超出了某个变量的作用域,那这个变量对应的变量槽就可以交给其他变量来重用。
public static void main(String[] args)() { byte[] placeholder = new byte[64 * 1024 * 1024]; System.gc(); }
public static void main(String[] args)() { { byte[] placeholder = new byte[64 * 1024 * 1024]; } System.gc(); }
执行第一段代码,没有回收掉placeholder所占的内存是能说得过去,因为在执行System.gc()时,变量placeholder还处于作用域之内。
加入了花括号之后,placeholder的作用域被限制在花括号以内,从代码逻辑上讲,在执行System.gc()的时候,placeholder已经不可能再被访问了,但执行这段程序,还是有64MB的内存没有被回收掉。
public static void main(String[] args)() { { byte[] placeholder = new byte[64 * 1024 * 1024]; }
int a = 0; System.gc(); }
placeholder能否被回收的根本原因就是:局部变量表中的变量槽是否还存有关于placeholder数组对象的引用。第一次修改中,代码虽然已经离开了placeholder的作用域,但在此之后,再没有发生过任何对局部变量表的读写操作,placeholder原本所占用的变量槽还没有被其他变量所复用,所以作为GC Roots一部分的局部变量表仍然保持着对它的关联。
2.2 操作数栈
操作数栈(Operand Stack)是一个后入先出(Last In First Out,LIFO)栈。
当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈和入栈操作。例如,整数加法的字节码指令iadd,在运行的时候要求操作数栈中最接近栈顶的两个元素已经存入了两个int型的数值,当执行这个指令时,会把这两个int值出栈并相加,然后将相加的结果重新入栈。
另外在概念模型中,两个不同栈帧作为不同方法的虚拟机栈的元素,是完全相互独立的。但是在大多虚拟机的实现里都会进行一些优化处理,令两个栈帧出现一部分重叠。让下面栈帧的部分操作数栈与上面栈帧的部分局部变量表重叠在一起,这样做不仅节约了一些空间,更重要的是在进行方法调用时就可以直接共用一部分数据,无须进行额外的参数复制传递了,重叠的过程如图8-2所示。
2.3 动态连接
Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池里指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候就被转化为直接引用,这种转化被称为静态解析。另外一部分将在每一次运行期间都转化为直接引用,这部分就称为动态连接。关于转化的具体过程,将在第三节介绍。
2.4 方法返回地址
当一个方法开始执行后,只有两种方式退出这个方法。
第一种方式是执行引擎遇到任意一个方法返回的字节码指令,这种退出方法的方式称为“正常调用完成”(Normal Method Invocation Completion) 。
另外一种退出方式是在方法执行的过程中遇到了异常,并且这个异常没有在方法体内得到妥善处理。无论是Java虚拟机内部产生的异常,还是代码中使用athrow字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方法的方式称为“异常调用完成(Abrupt Method Invocation Completion) ”。 一个方法使用异常完成出口的方式退出,是不会给它的上层调用者提供任何返回值的。
方法退出的过程实际上等同于把当前栈帧出栈,需要恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话) 压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令等。
3. 方法调用
方法调用用来确定调用方法的版本,不涉及方法内部的具体运行过程。一切方法调用在Class文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址(也就是之前说的直接引用) 。这个特性给Java带来了更强大的动态扩展能力,但也使得Java方法调用过程变得相对复杂。
3.1 解析
在类加载的解析阶段,会将其中一部分符号引用转化为直接引用,但解析的前提条件是调用目标在程序代码写好、编译器进行编译那一刻就已经确定下来。
在Java语言中符合“编译期可知,运行期不可变”这个要求的方法,主要有静态方法和私有方法两大类,前者与类型直接关联,后者在外部不可被访问,这两种方法各自的特点决定了它们都不可能通过继承或别的方式重写出其他版本。
在Java虚拟机支持以下5条方法调用字节码指令:
- invokestatic。 用于调用静态方法。
- invokespecial。 用于调用实例构造器<init>()方法、私有方法和父类中的方法。
- invokevirtual。 用于调用所有的虚方法。
- invokeinterface。 用于调用接口方法,会在运行时再确定一个实现该接口的对象。
- invokedynamic。 先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法。前面4条调用指令,分派逻辑都固化在Java虚拟机内部,而invokedynamic指令的分派逻辑是由用户设定的引导方法来决定的。
只要能被invokestatic和invokespecial指令调用的方法,都可以在解析阶段中确定唯一的调用版本。
在类加载的时候就可以把符号引用解析为该方法的直接引用。这些方法统称为“非虚方法”(Non-Virtual Method),与之相反,其他方法就被称为“虚方法”(Virtual Method) 。
3.2 分派
3.2.1 静态分派
下面代码中的“Human”称为变量的“静态类型”(Static Type),或者叫“外观类型”(Apparent Type),后面的“Man”则被称为变量的“实际类型”(Actual Type) 或者叫“运行时类型”(Runtime Type)。静态类型是在编译期可知的;而实际类型变化的结果在运行期才可确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么。
package org.fenixsoft.polymorphic; /** * 方法静态分派演示 * @author zzm */ 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!
虚拟机(或者准确地说是编译器)在重载时是通过参数的静态类型而不是实际类型作为判定依据的。由于静态类型在编译期可知,所以在编译阶段,Javac编译器就根据参数的静态类型决定了会使用哪个重载版本,因此选择了sayHello(Human)作为调用目标,把这个方法的符号引用写到main()方法里的两条invokevirtual指令的参数中。
所有依赖静态类型来决定方法执行版本的分派动作,都称为静态分派。
3.2.2 动态分派
package org.fenixsoft.polymorphic; /** * 方法动态分派演示 * @author zzm */ 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
在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。通过invokevirtual指令实现。
3.2.3 单分派与多分派
方法的接收者与方法的参数统称为方法的宗量,单分派是根据一个宗量对目标方法进行选择,多分派则是根据多于一个宗量对目标方法进行选择。
/** * 单分派、 多分派演示 * @author zzm */ public class Dispatch { static class QQ {} static class _360 {} public static class Father { public void hardChoice(QQ arg) { System.out.println("father choose qq"); } public void hardChoice(_360 arg) { System.out.println("father choose 360"); } } public static class Son extends Father { public void hardChoice(QQ arg) { System.out.println("son choose qq"); } public void hardChoice(_360 arg) { System.out.println("son choose 360"); } } public static void main(String[] args) { Father father = new Father(); Father son = new Son(); father.hardChoice(new _360()); son.hardChoice(new QQ()); } }
运行结果
father choose 360
son choose qq
编译阶段编译器选择目标方法时(静态分派),有两点依据:一是静态类型是Father还是Son,二是方法参数是QQ还是360。最终会产生两条invokevirtual指令,两条指令的参数分别为常量池中指向Father::hardChoice(360)及Father::hardChoice(QQ)方法的符号引用。
动态分派时,在执行“son.hardChoice(new QQ())”这行代码时,由于编译期已经决定目标方法的签名必须为hardChoice(QQ),虚拟机此时不会关心传递过来的参数“QQ”到底是“腾讯QQ”还是“奇瑞QQ”,因为这时候参数的静态类型、实际类型都对方法的选择不会构成任何影响,唯一可以影响虚拟机选择的因素只有该方法的接受者的实际类型是Father还是Son。
故而,Java语言是一门静态多分派、动态单分派的语言。
3.2.4 虚拟机动态分派实现
动态分派是执行非常频繁的动作,而且动态分派的方法版本选择过程需要运行时在接收者类型的方法元数据中搜索合适的目标方法, 一种基础而且常见的优化手段是为类型在方法区中建立一个虚方法表(Virtual Method Table,也称为vtable,与此对应的,在invokeinterface执行时也会用到接口方法表——Interface Method Table,简称itable), 用虚方法表索引来代替元数据查找以提高性能。
虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方法表中的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。如果子类中重写了这个方法,子类虚方法表中的地址也会被替换为指向子类实现版本的入口地址。为了程序实现方便,具有相同签名的方法,在父类、子类的虚方法表中都应当具有一样的索引序号,这样当类型变换时,仅需要变更查找的虚方法表,就可以从不同的虚方法表中按索引转换出所需的入口地址。虚方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始值后,虚拟机会把该类的虚方法表也一同初始化完毕。
4. 动态类型语言支持
动态类型语言的关键特征是它的类型检查的主体过程是在运行期而不是编译期进行的,满足这个特征的语言有很多,常用的包括:APL、Clojure、Erlang、Groovy、JavaScript、 Lisp、 Lua、 PHP、 Prolog、 Python、 Ruby、 Smalltalk、 Tcl, 等等。 那相对地, 在编译期就进行类型检查过程的语言, 譬如C++和Java等就是最常用的静态类型语言。
4.1 Java与动态类型
JDK 7以前的字节码指令集中,4条方法调用指令(invokevirtual、 invokespecial、 invokestatic、invokeinterface) 的第一个参数都是被调用的方法的符号引用(CONSTANT_Methodref_info或者CONSTANT_InterfaceMethodref_info常量),方法的符号引用在编译时产生,而动态类型语言只有在运行期才能确定方法的接收者。这样,在Java虚拟机上实现的动态类型语言就不得不使用“曲线救国”的方式(如编译时留个占位符类型,运行时动态生成字节码实现具体类型到占位符类型的适配)来实现,但这样势必会让动态类型语言实现的复杂度增加,也会带来额外的性能和内存开销。
比如有如下代码:
var arrays = {"abc", new ObjectX(), 123, Dog, Cat, Car..} for(item in arrays){ item.sayHello(); }
在动态类型语言下这样的代码是没有问题,但由于在运行时arrays中的元素可以是任意类型,即使它们的类型中都有sayHello()方法,也肯定无法在编译优化的时候就确定具体sayHello()的代码在哪里,编译器只能不停编译它所遇见的每一个sayHello()方法,并缓存起来供执行时选择、调用和内联,如果arrays数组中不同类型的对象很多,就势必会对内联缓存产生很大的压力,缓存的大小总是有限的,类型信息的不确定性导致了缓存内容不断被失效和更新,先前优化过的方法也可能被不断替换而无法重复使用。所以这种动态类型方法调用的底层问题终归是应当在Java虚拟机层次上去解决才最合适。因此,在Java虚拟机层面上提供动态类型的直接支持就成为Java平台发展必须解决的问题,这也是invokedynamic指令以及java.lang.invoke包出现的技术背景。
4.2 java.lang.invoke 包
JDK 7时新加入的java.lang.invoke包主要目的是在之前单纯依靠符号引用来确定调用的目标方法这条路之外,提供一种新的动态确定目标方法的机制,称为“方法句柄”(Method Handle) 。
举个例子,如果我们要实现一个带谓词(谓词就是由外部传入的排序时比较大小的动作)的排序函数,在C/C++中的常用做法是把谓词定义为函数,用函数指针来把谓词传递到排序方法,像这样:
void sort(int list[], const int size, int (*compare)(int, int))
有了MethodHandle就可以写出类似于C/C++那样的函数声明了:
void sort(List list, MethodHandle compare)
如下为方法句柄实例代码:
import static java.lang.invoke.MethodHandles.lookup; import java.lang.invoke.MethodHandle; import java.lang.invoke.MethodType; /** * JSR 292 MethodHandle基础用法演示 * @author zzm */ 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 reveiver) throws Throwable { // MethodType: 代表“方法类型”, 包含了方法的返回值(methodType()的第一个参数) 和 具体参数(methodType()第二个及以后的参数) 。 MethodType mt = MethodType.methodType(void.class, String.class); // lookup()方法来自于MethodHandles.lookup, 这句的作用是在指定类中查找符合给定的方法 名称、 方法类型, 并且符合调用权限的方法句柄。// 因为这里调用的是一个虚方法, 按照Java语言的规则, 方法第一个参数是隐式的, 代表该方法的接 收者, 也即this指向的对象, 这个参数以前是放在参数列表中进行传递, 现在提供了bindTo() 方法来完成这件事情。 return lookup().findVirtual(reveiver.getClass(), "println", mt).bindTo(reveiver); } }
MethodHandle与Reflection的区别:
- Reflection和MethodHandle机制本质上都是在模拟方法调用, 但是Reflection是在模拟Java代码层次的方法调用, 而MethodHandle是在模拟字节码层次的方法调用。在MethodHandles.Lookup上的3个方法findStatic()、findVirtual()、findSpecial()正是为了对应于invokestatic、 invokevirtual(以及invokeinterface) 和invokespecial这几条字节码指令的执行权限校验行为,而这些底层细节在使用Reflection API时是不需要关心的。
- Reflection中的java.lang.reflect.Method对象远比MethodHandle机制中的java.lang.invoke.MethodHandle对象所包含的信息来得多。 前者是方法在Java端的全面映像,包含了方法的签名、描述符以及方法属性表中各种属性的Java端表示方式,还包含执行权限等的运行期信息。而后者仅包含执行该方法的相关信息。用开发人员通俗的话来讲,Reflection是重量级,而MethodHandle是轻量级。
- Reflection API的设计目标是只为Java语言服务的, 而MethodHandle则设计为可服务于所有Java虚拟机之上的语言