深入理解Java虚拟机-虚拟机字节码执行引擎(八)

来源:互联网 发布:魔剑生死棋刘依依知乎 编辑:程序博客网 时间:2024/05/22 12:08

虚拟机字节码执行引擎

    在不同的虚拟机实现里面,执行引擎在执行Java代码的时候可能会有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择[1],也可能两者兼备,甚至还可能会包含几个不同级别的编译器执行引擎。但从外观上看起来,所有的Java虚拟机的执行引擎都是一致的:输入的是字节码文件,处理过程是字节码解析的等效过程,输出的是执行结果。

    8.2运行是的栈帧结构

    栈帧(stack frame)是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈(Virtual Machine Stack)的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。每一个方法从调用开始到执行完成的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。

   

    8.2.1局部变量表

    局部变量表(Local Variable table)是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。

 

    在方法执行时,虚拟机是使用局部变量表完成参数值到参数变量列表的传递过程的,如果执行的是实例方法(非static的方法),那局部变量表中第0位索引的Slot默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字“this”来访问到这个隐含的参数。其余参数则按照参数表顺序排列,占用从1开始的局部变量Slot,参数表分配完毕后,再根据方法体内部定义的变量顺序和作用域分配其余的Slot。

 

    代码清单8-1 局部变量表Slot复用对垃圾收集的影响之一

public static void main(String[]args)(){byte[]placeholder=new byte[64*1024*1024];System.gc();}

向内存填充了64MB的数据,然后通知虚拟机进行垃圾收集。我们在虚拟机运行参数中加上“-verbose:gc”来看看垃圾收集的过程,发现在System.gc()运行后并没有回收这64MB的内存,下面是运行的结果:

[GC 66846K->65824K(125632K),0.0032678 secs]

[Full GC 65824K->65746K(125632K),0.0064131 secs]

 

没有回收placeholder所占的内存能说得过去,因为在执行System.gc()时,变量placeholder还处于作用域之内,虚拟机自然不敢回收placeholder的内存。那我们把代码修改一下,变成代码清单8-2中的样子。代码清单8-2 局部变量表Slot复用对垃圾收集的影响之二


public static void main(String[]args)(){{byte[]placeholder=new byte[64*1024*1024];}System.gc();}

加入了花括号之后,placeholder的作用域被限制在花括号之内,从代码逻辑上讲,在执行System.gc()的时候,placeholder已经不可能再被访问了,但执行一下这段程序,会发现运行结果如下,还是有64MB的内存没有被回收,这又是为什么呢?

[GC 66846K->65888K(125632K),0.0009397 secs]

[Full GC 65888K->65746K(125632K),0.0051574 secs]

 

在解释为什么之前,我们先对这段代码进行第二次修改,在调用System.gc()之前加入一行“inta=0;”,变成代码清单8-3的样子。代码清单8-3 局部变量表Slot复用对垃圾收集的影响之三

public static void main(String[]args)(){{byte[]placeholder=new byte[64*1024*1024];}int a=0;System.gc();}


这个修改看起来很莫名其妙,但运行一下程序,却发现这次内存真的被正确回收了。

[GC 66401K->65778K(125632K),0.0035471 secs]

[Full GC 65778K->218K(125632K),0.0140596 secs]

 

placeholder能否被回收的根本原因是:局部变量表中的Slot是否还存有关于placeholder数组对象的引用。第一次修改中,代码虽然已经离开了placeholder的作用域,但在此之后,没有任何对局部变量表的读写操作,placeholder原本所占用的Slot还没有被其他变量所复用,所以作为GC Roots一部分的局部变量表仍然保持着对它的关联。

这种关联没有被及时打断,在绝大部分情况下影响都很轻微。但如果遇到一个方法,其后面的代码有一些耗时很长的操作,而前面又定义了占用了大量内存、实际上已经不会再使用的变量,手动将其设置为null值(用来代替那句inta=0,把变量对应的局部变量表Slot清空)便不见得是一个绝对无意义的操作

示例说明了赋null值的操作在某些情况下确实是有用的,但笔者的观点是不应当对赋null值的操作有过多的依赖,更没有必要把它当做一个普遍的编码规则来推广。原因有两点,从编码角度讲,以恰当的变量作用域来控制变量回收时间才是最优雅的解决方法,如代码清单8-3那样的场景并不多见。更关键的是,从执行角度讲,使用赋null值的操作来优化内存回收是建立在对字节码执行引擎概念模型的理解之上的,在第6章介绍完字节码后,笔者专门增加了一个6.5节“公有设计、私有实现”来强调概念模型与实际执行过程是外部看起来等效,内部看上去则可以完全不同。在虚拟机使用解释器执行时,通常与概念模型还比较接近,但经过JIT编译器后,才是虚拟机执行代码的主要方式,赋null值的操作在经过JIT编译优化后就会被消除掉,这时候将变量设置为null就是没有意义的。字节码被编译为本地代码后,对GCRoots的枚举也与解释执行时期有巨大差别,以前面例子来看,代码清单8-2在经过JIT编译后,System.gc()执行时就可以正确地回收掉内存,无须写成代码清单8-3的样子。


8.2.2 操作数栈

    操作数栈(Operand Stack)也常称为操作栈,它是一个后入先出(LastIn FirstOut,LIFO)栈。

 

    当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈/入栈操作。例如,在做算术运算的时候是通过操作数栈来进行的,又或者在调用其他方法的时候是通过操作数栈来进行参数传递的。

 

举个例子,整数加法的字节码指令iadd在运行的时候操作数栈中最接近栈顶的两个元素已经存入了两个int型的数值,当执行这个指令时,会将这两个int值出栈并相加,然后将相加的结果入栈。

 

    Java虚拟机的解释执行引擎称为“基于栈的执行引擎”,其中所指的“栈”就是操作数栈。

 

    8.2.3动态链接

    每个栈帧都包含一个指向运行时常量池[1]中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking)。

    第6章的讲解,Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候就转化为直接引用,这种转换称为静态解析。

    另外一部分将在每一次运行期间转化为直接引用,这部分称为动态链接。

 

    8.2.4方法返回地址

    当一个方法开始执行后,只有两种方式可以退出这个方法。第一种方式是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者(调用当前方法的方法称为调用者),是否有返回值和返回值的类型将根据遇到何种方法返回指令来决定,这种退出方法的方式称为正常完成出口(Normal Method Invocation Completion)。

    另外一种退出方式是,在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,无论是Java虚拟机内部产生的异常,还是代码中使用athrow字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方法的方式称为异常完成出口(Abrupt Method Invocation Completion)。一个方法使用异常完成出口的方式退出,是不会给它的上层调用者产生任何返回值的。

 

    方法退出的过程实际上就等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令等。

 

    8.2.5附加信息

    一般把动态链接、方法返回地址与其他附加信息全部归为一类,称为栈帧信息。

 

    8.3 方法调用

    方法调用并不等同于方法执行,方法调用阶段唯一的任务就是确定被调用方法的版本(即调用哪一个方法),暂时还不涉及方法内部的具体运行过程。

    Class文件的编译过程中不包含传统编译中的连接步骤,一切方法调用在Class文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址(相当于之前说的直接引用)。

 

    8.3.1解析

    方法调用中的目标方法在Class文件里面都是一个常量池中的符号引用,在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用,这种解析能成立的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。换句话说,调用目标在程序代码写好、编译器进行编译时就必须确定下来。这类方法的调用称为解析(Resolution)。

   

    在Java语言中符合“编译期可知,运行期不可变”这个要求的方法,主要包括静态方法和私有方法两大类,前者与类型直接关联,后者在外部不可被访问,这两种方法各自的特点决定了它们都不可能通过继承或别的方式重写其他版本,因此它们都适合在类加载阶段进行解析。

   

    与之相对应的是,在Java虚拟机里面提供了5条方法调用字节码指令,分别如下。

invokestatic:调用静态方法。

invokespecial:调用实例构造器<init>方法、私有方法和父类方法。

invokevirtual:调用所有的虚方法。

invokeinterface:调用接口方法,会在运行时再确定一个实现此接口的对象。

invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法,在此之前的4条调用指令,分派逻辑是固化在Java虚拟机内部的,而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的。

 

只要能被invokestatic和invokespecial指令调用的方法,都可以在解析阶段中确定唯一的调用版本,符合这个条件的有静态方法、私有方法、实例构造器、父类方法4类,它们在类加载的时候就会把符号引用解析为该方法的直接引用。这些方法可以称为非虚方法,与之相反,其他方法称为虚方法(除去final方法,后文会提到)。

 

    Java中的非虚方法除了使用invokestatic、invokespecial调用的方法之外还有一种,就是被final修饰的方法。虽然final方法是使用invokevirtual指令来调用的,但是由于它无法被覆盖,没有其他版本,所以也无须对方法接收者进行多态选择,又或者说多态选择的结果肯定是唯一的。在Java语言规范中明确说明了final方法是一种非虚方法。

 

    8.3.2分派

    1)静态分派,先看一个类的方法重载的代码:


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

这段代码是考察对重载的理解程度。

看下定义:Humanman= newMan();Human成为变量的静态类型(statictype),后面的Man成为变量的实际类型(Actual Type),静态类型的变化仅仅在使用时发生,变量本身的静态类型不会改变,并且最终的静态类型是编译器可知的;实际类型变化的结果要在运行期才可确定,编译器在编译程序时并不知道一个对象的实际类型是什么。

 

在看上面代码中两次调用sayHello()方法,在方法接收者已经确定是对象“sr”的前提下,使用哪个重载版本,取决于传入参数的数量和数据类型。Main中定义了两个静态类型相同但实际类型不同的变量,虚拟机(准确说是编译器)在重载时是通过参数的静态类型而不是实际类型做判断依据的。因此,在编译阶段,Javac编译器根据参数的静态类型决定使用哪个重载版本,所以选择了sayHello(Humanguy)作为调用目标,并把这个方法的符号引用写到main()方法里的两条invokevirtual指令参数中。

 

    所有依赖静态类型来定位方法执行版本的分派动作称为静态分派。静态分派的典型应用是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的。

    编译器虽然能确定出方法的重载版本,但在很多情况下这个重载版本并不是“唯一”的,往往只能确定一个更加合适的版本。产生这种模糊结论的主要原因是字面量不需要定义,所以字面量没有显示的静态类型,它的静态类型只能通过语言上的规则去理解和推断。

    重载方法匹配优先级:

public class OverridePriority {public static void sayHello(char arg){System.out.println("hello char");}public static void sayHello(int arg){System.out.println("hello int");}public static void sayHello(long arg){System.out.println("hello long");}//发生了一次自动装箱。public static void sayHello(Character arg){System.out.println("hello Character");}public static void sayHello(Serializable arg){System.out.println("hello Character");}public static void sayHello(Object arg){System.out.println("hello Character");}//可变长参数。public static void sayHello(char...arg){System.out.println("hello Character");}public static void main(String[] args) {sayHello('a');}}


从上到到下,依次注释掉重载方法,会发生自动转型,可变长参数的重载优先级是最低的,这时候把字符’a’当做了一个数组元素。

 

动态分派,动态分派的过程和多态性的另外一个体现-重写(Overrider)有密切关联。

public class StaticDispatch {static abstract class Human{public abstract void sayHello();}static class Man extends Human{@Overridepublic void sayHello(){System.out.println("Man hello guy");}}static class Woman extends Human{@Overridepublic void sayHello(){System.out.println("Woman hello guy");}}public static void main(String[] args) {Human man = new Man();Human woman = new Woman();man.sayHello();woman.sayHello();man = new Woman();man.sayHello();}}

运行结果:

Man hello guy

Woman hello guy

Woman hello guy

这个结果不难知道,但虚拟机是如何知道要调用那个方法的?

显然这里不可能再根据静态类型来决定,因为静态类型同样都是Human的两个变量man和woman在调用sayHello()方法时执行了不同的行为,并且变量man在两次调用中执行了不同的方法。导致这个现象的原因很明显,是这两个变量的实际类型不同,Java虚拟机是如何根据实际类型来分派方法执行版本的呢?使用javap命令输出这段的字节码,来寻找答案:


Compiled from "StaticDispatch.java"

public class StaticDispatch {

 public StaticDispatch();

   Code:

      0: aload_0

      1: invokespecial #8                 // Method java/lang/Object."<init>":()V

      4: return

 

 public static void main(java.lang.String[]);

   Code:

      0: new           #16                 // class StaticDispatch$Man

      3: dup

       4: invokespecial #18                 // MethodStaticDispatch$Man."<init>":()V

      7: astore_1

      8: new           #19                 // class StaticDispatch$Woman

     11: dup

     12: invokespecial #21                // Method StaticDispatch$Woman."<init>":()V

     15: astore_2

     16: aload_1

     17: invokevirtual #22                // Method StaticDispatch$Human.sayHello:()V

     20: aload_2

     21: invokevirtual #22                // Method StaticDispatch$Human.sayHello:()V

      24: new           #19                 // class StaticDispatch$Woman

     27: dup

     28: invokespecial #21                // Method StaticDispatch$Woman."<init>":()V

     31: astore_1

     32: aload_1

     33: invokevirtual #22                // Method StaticDispatch$Human.sayHello:()V

     36: return

}


0~15行的字节码是准备动作,作用是建立man和woman的内存空间、调用Man和Woman类型的实例构造器,将这两个实例的引用存放在第1、2个局部变量表Slot之中,这个动作也就对应了代码中的这两句:

        Humanman= newMan();

        Humanwoman= newWoman();

接下来的16~21句是关键部分,16、20两句分别把刚刚创建的两个对象的引用压到栈顶,这两个对象是将要执行的sayHello()方法的所有者,称为接收者(Receiver);17和21句是方法调用指令,这两条调用指令单从字节码角度来看,无论是指令(都是invokevirtual)还是参数(都是常量池中第22项的常量,注释显示了这个常量是Human.sayHello()的符号引用)完全一样的,但是这两句指令最终执行的目标方法并不相同。原因就需要从invokevirtual指令的多态查找过程开始说起,invokevirtual指令的运行时解析过程大致分为以下几个步骤:

1) 找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C。

2) 如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,   

如果通过则返回这个方法的直接引用,查找结束;如果不通过,则返回java.lang.IllegalAccessError异常。

3) 否则,按继承关系从下往上一次对C的个父类进行第2步的搜索和验证过程。

4) 如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。

 

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

 

4,虚拟机动态分派的实现

    由于动态分派是非常频繁的动作,而且动态分派的方法版本选择过程需要运行时在类的方法元数据中搜索合适的目标方法,因此在虚拟机的实际实现中基于性能的考虑,大部分实现都不会真正地进行如此频繁的搜索。面对这种情况,最常用的“稳定优化”手段就是为类在方法区中建立一个虚方法表(Vritual Method Table,也称为vtable,与此对应的,在invokeinterface执行时也会用到接口方法表——Inteface MethodTable,简称itable),使用虚方法表索引来代替元数据查找以提高性能。





虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方法表里面的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。如果子类中重写了这个方法,子类方法表中的地址将会替换为指向子类实现版本的入口地址。图8-3中,Son重写了来自Father的全部方法,因此Son的方法表没有指向Father类型数据的箭头。但是Son和Father都没有重写来自Object的方法,所以它们的方法表中所有从Object继承来的方法都指向了Object的数据类型。

 

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

    上文中笔者说方法表是分派调用的“稳定优化”手段,虚拟机除了使用方法表之外,在条件允许的情况下,还会使用内联缓存(Inline Cache)和基于“类型继承关系分析”(ClassHierarchy Analysis,CHA)技术的守护内联(Guarded Inlining)两种非稳定的“激进优化”手段来获得更高的性能。

 

    8.3.3动态类型语言支持。

    JDK7的发布,字节码指令集迎来了新成员——invokedynamic指令。这条新增加的指令是JDK 7实现“动态类型语言”(Dynamically TypedLanguage)支持而进行的改进之一,也是为JDK8可以顺利实现Lambda表达式做技术准备。

   

1, 动态类型语言

动态类型语言的关键特征是它的类型检查的主体过程是在运行期而不是编译期,满足这个

特征的语言有很多,常用的包括:APL、Clojure、Erlang、Groovy、JavaScript、Jython、

Lisp、Lua、PHP、Prolog、Python、Ruby、Smalltalk和Tcl等。

相对的,在编译期就进行类型检查过程的语言(如C++Java等)就是最常用的静态类型语言

 

    Java代码:


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

这段代码能够正常编译,但运行的时候会报NegativeArraySizeException异常。在Java虚拟机规范中明确规定了NegativeArraySizeException是一个运行时异常,通俗一点来说,运行时异常就是只要代码不运行到这一行就不会有问题。与运行时异常相对应的是连接时异常,例如很常见的NoClassDefFoundError便属于连接时异常,即使会导致连接时异常的代码放在一条无法执行到的分支路径上,类加载时(Java的连接过程不在编译阶段,而在类加载阶段)也照样会抛出异常。

在C语言中,含义相同的代码会在编译期报错:

C语言代码:


int main(void){int i[1][0][-1];//GCC拒绝编译,报“size of array is negative”return 0;}

“变量无类型而变量值才有类型”这个特点也是动态类型语言的一个重要特征。

静态类型语言在编译期确定类型,最显著的好处是编译器可以提供严谨的类型检查,这样与类型相关的问题能在编码的时候就及时发现,利于稳定性及代码达到更大规模。而动态类型语言在运行期确定类型,这可以为开发人员提供更大的灵活性,某些在静态类型语言中需用大量“臃肿”代码来实现的功能,由动态类型语言来实现可能会更加清晰和简洁,清晰和简洁通常也就意味着开发效率的提升。

 

1, Jdk1.7与动态类型

Java虚拟机层面对动态类型语言的支持一直都有所欠缺,主要表现在方法调用方面:JDK

1.7以前的字节码指令集中,4条方法调用指令(invokevirtual、invokespecial、invokestatic、invokeinterface)的第一个参数都是被调用的方法的符号引用(CONSTANT_Methodref_info或者CONSTANT_InterfaceMethodref_info常量),前面已经提到过,方法的符号引用在编译时产生,而动态类型语言只有在运行期才能确定接收者类型。这样,在Java虚拟机上实现的动态类型语言就不得不使用其他方式(如编译时留个占位符类型,运行时动态生成字节码实现具体类型到占位符类型的适配)来实现,这样势必让动态类型语言实现的复杂度增加,也可能带来额外的性能或者内存开销。尽管可以利用一些办法(如Call Site Caching)让这些开销尽量变小,但这种底层问题终归是应当在虚拟机层次上去解决才最合适,因此在Java虚拟机层面上提供动态类型的直接支持就成为了Java平台的发展趋势之一,这就是JDK 1.7(JSR-292)中invokedynamic指令以及java.lang.invoke包出现的技术背景。

 

2, java.lang.invoke包

这个包的主要目的是在之前单纯依靠符号引用来确定调用的目标方法这种方式以外,提供一种新的动态确定目标方法的机制,称为MethodHandle。这种表达方式也许不太好懂?那不妨把MethodHandle与C/C++中的Function Pointer,或者C#里面的Delegate类比一下。举个例子,如果我们要实现一个带谓词的排序函数,在C/C++中常用的做法是把谓词定义为函数,用函数指针把谓词传递到排序方法,如下:

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

 

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

void sort(List list,Comparator c)

 

    在拥有Method Handle之后,Java语言也可以拥有类似于函数指针或者委托的方法别名的工具了。代码清单8-11演示了MethodHandle的基本用途,无论obj是何种类型(临时定义的ClassA抑或是实现PrintStream接口的实现类System.out),都可以正确地调用到println()方法。

    MethodHandle演示:

public class MethodHandleTest {static class ClassA{public void println(String s){System.out.println(s+"-----");}}private static MethodHandle getPrintlnMH(Object receiver) throws Throwable{//MethodType 代表 方法类型,包含了方法的返回值(methodtype()的第一个参数)和具体参数(methodtype()的第二个及以后参数)MethodType mt = MethodType.methodType(void.class, String.class);//lookup这句的作用是在指定类中查找符合给定的方法名称、方法类型、并且符合调用权限的方法句柄,return lookup().findVirtual(receiver.getClass(),"println",mt).bindTo(receiver);}public static void main(String[] args) throws Throwable {Object obj = System.currentTimeMillis()%2 == 0 ? System.out : new ClassA();//无论obj最终是哪个实现类,下面这句都能正确调用到println方法。getPrintlnMH(obj).invokeExact("method handle demo");}}

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

void sort(List list,MethodHandle compare)

   

    仅站在Java语言的角度来看,MethodHandle的使用方法和效果与Reflection有众多相似之处,不过,它们还是有以下这些区别:

从本质上讲,Reflection和MethodHandle机制都是在模拟方法调用,但Reflection是在模拟Java代码层次的方法调用,而MethodHandle是在模拟字节码层次的方法调用。在MethodHandles.lookup中的3个方法——findStatic()、findVirtual()、findSpecial()正是

为了对应于invokestatic、invokevirtual&invokeinterface和invokespecial这几条字节码指令的执行权限校验行为,而这些底层细节在使用ReflectionAPI时是不需要关心的。

 

Reflection中的java.lang.reflect.Method对象远比MethodHandle机制中的java.lang.invoke.MethodHandle对象所包含的信息多。前者是方法在Java一端的全面映像,包含了方法的签名、描述符以及方法属性表中各种属性的Java端表示方式,还包含执行权限等的运行期信息。而后者仅仅包含与执行该方法相关的信息。用通俗的话来讲,Reflection是重量级,而MethodHandle是轻量级。

 

由于MethodHandle是对字节码的方法指令调用的模拟,所以理论上虚拟机在这方面做的

各种优化(如方法内联),在MethodHandle上也应当可以采用类似思路去支持(但目前实现还不完善)。而通过反射去调用方法则不行。

 

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

 

1, invokeynamic指令

JDK1.7为了更好地支持动态类型语言,引入了第5条方法调用的字节码指令invokedynamic,它的应用之处在哪里呢?

 

在某种程度上,invokedynamic指令与MethodHandle机制的作用是一样的,都是为了解决原有4条“invoke*”指令方法分派规则固化在虚拟机之中的问题,把如何查找目标方法的决定权从虚拟机转嫁到具体用户代码之中,让用户(包含其他语言的设计者)有更高的自由度。而且,它们两者的思路也是可类比的,可以把它们想象成为了达成同一个目的,一个采用上层Java代码和API来实现,另一个用字节码和Class中其他属性、常量来完成。因此,如果理解了前面的MethodHandle例子,那么理解invokedynamic指令也并不困难。

 

2, 掌控方法分派规则

invokedynamic指令与前面4条“invoke*”指令的最大差别就是它的分派逻辑不是由虚拟机决定的,而是由程序员决定。程序员在可以掌控方法分派规则之后,能做什么以前无法做到的事情。

 

在Java程序中,可以通过“super”关键字很方便地调用到父类中的方法,但如果要访问祖类的方法呢?

 

 

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

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

 

8.4.1解释执行

Java语言经常被人们定位为“解释执行”的语言,在Java初生的JDK 1.0时代,这种定义还算是比较准确的,但当主流的虚拟机中都包含了即时编译器后,Class文件中的代码到底会被解释执行还是编译执行,就成了只有虚拟机自己才能准确判断的事情。再后来,Java也发展出了可以直接生成本地代码的编译器[如GCJ[1](GNU Compiler for theJava)],而C/C++语言也出现了通过解释器执行的版本(如CINT[2]),这时候再笼统地说“解释执行”,对于整个Java语言来说就成了几乎是没有意义的概念,只有确定了谈论对象是某种具体的Java实现版本和执行引擎运行模式时,谈解释执行还是编译执行才会比较确切。

 

大部分的程序代码到物理机的目标代码或虚拟机能执行的指令集之前,都需要经过图8-4中的各个步骤。如果读者对编译原理的相关课程还有印象的话,很容易就会发现图8-4中下面那条分支,就是传统编译原理中程序代码到目标机器代码的生成过程,而中间的那条分支,自然就是解释执行的过程。



阅读全文
0 0