admin管理员组

文章数量:814257

8虚拟机字节码执行引擎

文章目录

      • 8.3 方法调用
        • 8.3.1 解析
        • 8.3.2 分派
        • 8.3.3 动态类型语言支持
      • 8.4 基于栈的字节码解释执行引擎
        • 8.4.1 解释执行
        • 8.4.2 基于栈的指令集与基于寄存器的指令集

8.3 方法调用

8.3.1 解析

8.3.2 分派

  1. 静态分派
Human man = new Man()

上面代码中的"Human"称为变量的静态类型(Static Type),或者叫做外观类型(Apparent Type),后面的“Man”则称为变量的实际类型(Actual Type)。

  1. 动态分派

它与重写有密切的关系。

/*** 方法动态分派演示*/
public class DynamicDispatch {static abstract class Human {protected abstract void sayHello();}static class Man extends Human {@Overrideprotected void sayHello() {System.out.println("man say hello");};}static class Woman extends Human {@Overrideprotected 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

代码中两句(对应于反汇编后的0~15行):

​ Human man = new Man();

​ Human woman = new Woman();

作用是建立manwoman的内存空间、调用ManWoman类型的实例构造器,将这两个实例的引用存放在第1、2个局部变量表Slot中。

以下进行反汇编查看:

16、20两句分别把刚刚创建的两个对象的引用压到栈顶,这两个对象是将要执行的*sayHello()*方法的所有者,称为Receiver

17、21句是方法调用指令,这两条指令单从字节码的角度来说完全一样,但是最终的执行目标方法却不一样。这是因为invokevirtual指令的多态查找过程,如下:

  1. 找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C。
  2. 如果在类型C中找到与常量中的描述符和简单名称相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;如果不通过,则返回java.lana.IllegalAccessError异常。
  3. 否则,继续按照继承关系从下往上依次对C的各个父类进行第2步的搜索和验证过程。
  4. 如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。

由于invokevirtual指令执行的第一步就是在运行期确定接收者的实际类型,所以两次调用中指令都是把常量池中的类方法符号引用解析到了不同的直接引用上,这个过程就是java语言中方法重写的本质。而这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派

  1. 单分派与多分派

方法的接收者与方法的参数统称为方法的宗量,这个定义最早来自于《Java与模式》。根据分派基于多少种宗量,可以将分派划分为单分派和多分派两种。单分派是根据一个宗量对目标方法进行选择,多分派则是根据多于一个宗量对目标方法进行选择。

代码案例如下:

/*** 单分派、多分派演示*/
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)方法的符号引用。**因为是根据两个宗量进行选择,所以Java语言的静态分派属于多分派类型。**

再看看运行阶段虚拟机的选择(动态分派的过程)。在执行"son.hardChoice(new QQ())"所对应的invokevirtual指令时,由于编译期已经决定目标方法的签名必须为hardChoice(QQ),虚拟机此时不会关心传递过来的参数"QQ"到底是“腾讯QQ”还是“奇瑞QQ”,因为这时参数的静态类型、实际类型都对方法的选择不会构成任何影响,唯一可以影响虚拟机选择的因素只有此方法的接受者的实际类型是Father还是Son因为只有一个宗量作为选择依据,所以Java语言的动态分派属于单分派类型。

按照目前Java语言的发展趋势,并没有直接变为动态语言的迹象,而是通过内置动态语言(如JavaScript)执行引擎的方式来满足动态性的需求。但是在Java虚拟机层面上则不是如此,在JDK 1.7中实现的JSR-292里面就已经开始提供对动态语言的支持了,JDK 1.7中新增的__invokedynamic指令__也成为了最复杂的一条方法调用的字节码指令。

  1. 虚拟机动态分派的实现

虚拟机在分派中“具体是如何做到的”?

由于动态分派是非常频繁的动作,而且动态分派的方法版本选择过程需要运行时在类的方法元数据中搜索合适的目标方法,因此虚拟机实际实现中基于性能的考虑,大部分不会真正的进行如此频繁的搜索。

最常用的“稳定优化”手段就是为类在方法区建立一个虚方法表vtable,对应的,在invokeinterface执行时也会用到接口方法表——itable),使用虚方法表索引来代替元数据查找以提高性能。

虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方法表里面的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。如果子类重写了,那么就会替换为指向子类实现版本的入口地址。

图8-3中,Son重写了来自Father的全部方法,因此Son的方法表中没有指向Father类型数据的箭头。但是SonFather都没有重写来自Object的方法,所以它们的方法表中所有从Object继承来的方法都指向了Object的数据类型。

为了实现方便,具有相同签名的方法,在父类、子类的虚方法表中都应当具有一样的索引序号,这样当类型变换时,仅需要变更查找的方法表,就可以从不同的虚方法表中按索引转换出所需的入口地址。

方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始值后,虚拟机会把该类的方法表也初始化完毕。

另外除了方法表这种“稳定优化”手段之外,虚拟机还可能在一些情况下使用内联缓存和基于“类型继承关系的分析(CHA)”技术的守护内联两种非稳定的“激进优化”手段。

8.3.3 动态类型语言支持

invokedynamic指令是JDK 1.7实现“动态类型语言”支持而进行的改进之一,也是为JDK 1.8实现Lambda表达式做技术准备。

  1. 动态类型语言

什么是动态类型语言?

动态语言的关键特征是它的类型检查的主体过程是在运行期而不是编译期,满足的语言包括:Groovy、JavaScript、Jython、PHP、Python、Ruby、Smalltalk。相对的,在编译期就进行类型检查过程的语言(C++、Java)就是最常用的静态类型语言。

如下案例:

public static void main(String[] args) {int[][][] array = new int[1][0][-1];
}

这段代码能够正常编译,但运行时会报NegativeArraySizeException异常。在Java虚拟机规范中明确规定了NegativeArraySizeException是一个运行时异常,运行时异常就是只要代码不运行到这一行就不会有问题。与运行时异常相对应的是连接时异常,例如很常见的NoClassDefFoundError便属于连接时异常。但是在C语言中,含义相同的代码会在编译期报错。

再比如下面这行代码:

obj.println("hello world");

虽然我们能够看懂,但是电脑却无法执行,必须要具体的一个上下文。

假设这行代码在Java中,并且obj的静态类型为java.io.PrintStream,那变量obj的实际累心就必须是PrintStream的子类(实现了PrintStream接口的类)才是合法的。否则,哪怕obj属于一个确实有用println(String)方法,但与PrintStream接口没有继承关系,代码依然不能运行——因为类型检查不合法。

但是相同的代码在ECMAScript(JavaScript)中情况就不一样,无论obj具体是何种类型,只要这种类型的定义中确实包含有*println(String)*方法,那方法调用便可成功。

(动态类型语言中的变量没有静态类型,只是在运行期才根据具体的实际类型确定变量的外观类型)

这种差别产生的原因是Java语言在编译期间已将println(String)方法完整的符号引用(本例中为一个CONSTANT_InterfaceMethodref_info常量)生成出来,作为方法调用指令的参数存储在Class中,例如下面这段代码:

invokevirtual #4;  //Method java/io/PrintStream.println:(Ljava/lang/String;)V

这个符号引用包含了此方法定义在哪个具体类型之中、方法的名字以及参数顺序、参数类型和方法返回值等信息,通过这个符号引用,虚拟机可以翻译出这个方法的直接引用。而在ECMAScript等动态类型语言中,变量obj本身是没有类型的,变量obj的值才具有类型,编译时最多只能确定方法的名称、参数、返回值这些信息,而不会去确定方法所在的具体类型(即方法接收者不固定)。“变量无类型而变量值才有类型”这个特点也是动态类型语言的一个重要特征。

两者的优缺点

静态类型语言在编译期确定类型,最显著的好处是编译器可以提供严谨的类型检查,这样与类型相关的问题能在编码的时候就及时发现,利于稳定性及代码达到更大规模。

动态类型语言在运行期确定类型,这可以为开发人员提供更大的灵活性,某些在静态类型语言中需要大量“臃肿”代码来实现的功能,由动态类型语言来实现可能会更加清晰和简洁,开发效率更高。

  1. JDK 1.7与动态类型

Java虚拟机对动态类型语言支持的欠缺主要表现在方法调用方面:JDK 1.7以前的4条方法调用指令(invokevirtual、invokespecial、invokestatic、invokeinterface)的第一个参数都是被调用的方法的符号引用(CONSTANT_Methodref_info或者CONSTANT_InterfaceMethodref_info常量)。这样,在Java虚拟机上实现动态类型语言就需要其它方式来实现(如编译时留个占位符类型,运行时动态生成字节码实现具体类型到占位符类型的适配)来实现,但这样会使实现复杂度增高,也可能带来额外性能或内存开销。尽管可以使用一些办法(如Call Site Caching)来优化。

  1. java.lang.invoke包

JSR-292中提供了一种新的动态确定目标方法的机制,称为MethodHandle。可以把MethodHandle与C/C++中的Function Pointer类比一下。例如如果我们要实现一个带谓词的排序函数,在C/C++中常用的做法是把谓词定义为函数,用函数指针把谓词传递到排序方法:

void sort(int list[], const int size, int (*compare)(int, int))

Java做不到这一点,即没有办法单独把一个函数作为参数进行传递。普通的做法是设计一个带有*compare()*方法的Comparator接口,以实现了这个接口的对象作为参数,例如Collections.sort()就是这样定义的:

void sort(List list, Comparator c)

在拥有了Method Handle之后,Java也可以拥有类似于函数指针或者委托的方法别名的工具了。以下演示MethodHandle的基本用法:

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 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);}
}

实际上,方法getPrintlnMH()中模拟了invokevirtual指令的执行过程,只不过它的分派逻辑并非固化在Class文件的字节码上,而是通过一个具体的方法来实现。而这个方法本身的返回值(MethodHandle对象),可以视为对最终调用方法的一个“引用”。以此为基础,有了MethodHandle就可以写出类似于下面这样的函数声明:

void sort(List list, MethodHandle compare)

与此类比,MethodHandle的使用方法和效果与Reflection有众多相似之处,不过,他们还是有以下这些区别:

  • 从本质上讲,ReflectionMethodHandle的机制都是模拟方法调用,但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是轻量级
  • 由于MethodHandle是对字节码的方法调用的模拟,所以理论上虚拟机在这方面做的各种优化(如方法内联),在MethodHandle上也应当可以采用类似思路去支持(但目前实现还不完善)。而通过反射去调用方法则不行。

除了以上区别之外,最关键的一点在于去掉前面讨论施加的前提“仅站在Java语言的角度来看”:Reflection API的设计目的只为Java语言服务的,而MethodHandle则设计成可服务于所有Java虚拟机上的语言,其中也包括Java语言。

  1. _invokedynamic_指令

在某种程度上,invokedynamic指令与MethodHandle机制的作用是一样的,都是为了解决原有4条"invoke*"指令方法分派规则固化在虚拟机之中的问题,把如何查找目标方法的决定权从虚拟机转嫁到具体用户代码中。不过,一个是用上层Java代码和API来实现,另一个用字节码和Class中其它属性、常量来完成。

8.4 基于栈的字节码解释执行引擎

许多Java虚拟机的执行引擎在执行Java代码的时候都有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择。本章先来讨论解释执行时,虚拟机执行引擎如何工作。

8.4.1 解释执行

Java语言常被人们认定为“解释执行”的语言,在Java初生的JDK1.0时代,这种定义还算比较准确,但是当主流的虚拟机都包含了即时编译器后,Class文件中的代码到底会被解释执行还是编译执行,就成了只有虚拟机自己才能准确判断的事情。再后来,Java也发展出了可以直接生成本地代码的编译器[如GCJ(GUN Compiler for the Java)],而C/C++语言也出现了通过解释执行的版本(如CINT)。

图8-4中下面那条分支,就是传统编译原理中程序代码到目标机器代码的生成过程,而中间的那条分支,自然就是解释执行的过程。

现代经典编译原理的思路,在执行前先对程序源码进行词法分析和语法处理,把源码转化为抽象语法树(AST)。对于一门具体语言的实现来说,词法分析、语法分析以至后面的优化器和目标代码生成器都可以选择独立于执行引擎,形成一个完整意义的编译器去实现,这类代表是C/C++语言。也可以选择把其中一部分步骤(如生成抽象语法树之前的步骤)实现为一个半独立的编译器,这类代表是Java语言。又或者把这些步骤和执行引擎全部集中封装在一个封闭的黑匣子中,如大多数的JavaScript执行器。

Java语言中,Javac编译器完成了程序代码经过词法分析、语句分析到抽象语法树,再遍历语法树生成线性的字节码指令流的过程。因为这一部分动作是在Java虚拟机之外进行的,而解释器在虚拟机的内部,所以Java程序的编译就是半独立的实现。

8.4.2 基于栈的指令集与基于寄存器的指令集

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寄存器中。

那么这两套指令集各自特点呢?

基于栈的指令集的主要的有点是可移植,寄存器由硬件直接提供,程序直接依赖这些硬件寄存器则不可避免地要受到硬件的约束。例如,现在32位80x86体系的处理器中提供了8个32位的寄存器,而ARM体系的CPU(在当前手机、PDA中相当流行的一种处理器)则提供了16个32位的通用寄存器。如果使用栈架构的指令集,用户程序不会直接使用这些寄存器,就可以由虚拟机实现来自行决定把一些访问最频繁的数据(程序寄存器、栈顶缓存等)放到寄存器中以获取尽量好的性能,这样实现起来也更加简单一些。栈架构的指令集还有一些其他的优点,如代码相对更紧凑(字节码中每一个字节就对应一条指令,而更多地址指令集中还需要存放参数)、编译器实现更加简单(不需要考虑空间分配的问题,所需空间都在栈上操作)等。

栈架构指令集的主要缺点是执行速度相对来说会稍慢一些。所有主流物理机的指令集都是寄存器架构也从侧面印证了这一点。

虽然栈架构指令集的代码非常紧凑,但是完成相同功能所需的指令数量一般会比寄存器架构多,因为出栈、入栈操作本身就产生了相当多的指令数量。更重要的是,栈实现在内存之中,频繁的栈访问也就意味着频繁的内存访问,相对于处理器来说,内存始终是执行速度的瓶颈。尽管虚拟机可以采取栈顶缓存的手段,把最常用的操作映射到寄存器中避免直接内存访问,但这也只能是优化措施而不是解决本质问题的方法。由于指令数量和内存访问的原因,所以导致了栈架构指令集的执行速度会相对较慢。

虚拟机中解析器和即时编译器都会对输入的字节码进行优化。例如,在HotSpot虚拟机中,有很多以“fast_”开头的非标准字节码指令用于合并、替换输入的字节码以提升解释执行性能,而即时编译器的优化手段更加花样繁多。

本文标签: 8虚拟机字节码执行引擎