深入理解Java虚拟机—虚拟机字节码执行引擎

上一篇:深入理解Java虚拟机—类加载机制

下一篇:深入理解Java虚拟机—类加载及执行子系统的案例与实战

虚拟机字节码执行引擎

一. 概述

虚拟机的执行引擎是自己实现的,有自己的指令集和执行引擎的结构体系,能够执行那些不被硬件直接支持的指令集格式。(物理机执行引擎是建立在处理器、硬件、指令集和操作系统层面)。
但在不同的虚拟机实现里,执行引擎在执行java代码的时候,可能会解释执行和编译执行,也可两者兼备,但外观看起来都是一致的:输入的是字节码文件,处理过程是字节码解析的等效过程,输出的是执行结果。

2. 运行时的栈帧结构

在这里插入图片描述

栈帧:

  • 每个方法的执行和结束对应着栈帧的入栈和出栈。
  • 入栈表示被调用,出栈表示执行完毕或者返回异常。
  • 一个虚拟机栈对应一个线程,当前CPU调度的那个线程叫做活动线程;一个栈帧对应一个方法,活动线程的虚拟机栈里最顶部的栈帧代表了当前正在执行的方法,而这个栈帧也被叫做‘当前栈帧’。
1. 局部变量表
  • 局部变量表顾名思义,用于存储方法中的局部变量。值得注意的是,它不仅仅存储在方法内部声明的局部变量,方法参数列表中的变量、非static方法隐含的this指针、try-catch块中catch中的异常对象变量都是局部变量表中的变量。局部变量表的大小在运行之前已经确定,原因是在java文件编译后的class文件的方法表的code属性中有一条属性用来表示该方法所需的局部变量表大小。
  • 局部变量表是一片逻辑连续的内存空间,最小单位是Slot(变量槽),用来存放方法参数和方法内部定义的局部变量
  • 虚拟机没有明确指明一个Slot的内存空间大小。但是boolean、byte、char、short、int、float、reference、returnAddress类型的数据都可以用32位空间或更小的内存来存放。这些类型占用一个Slot。Java中的long和double类型是64位,占用两个Slot
  • 局部变量表是有索引的,就像数组一样。从0开始,到表的最大索引,也就是Slot的数量-1。要注意的是,方法参数的个数 + 局部变量的个数 ≠ Slot的数量。因为Slot的空间是可以复用的,当pc计数器的值已经超出了某个变量的作用域时,下一个变量不必使用新的Slot空间,可以去覆盖前面那个空间
  • 局部变量不存在“准备”阶段,如果一个局部变量定义了但没有赋初始值是没法使用的。
  • 赋 null 值的操作在经过虚拟机 JIT 编译器优化之后会被消除掉
2. 操作数栈
  • 每个栈帧都包含一个被叫做操作数栈的后进先出的栈。叫操作栈,或者操作数栈,通常情况下,操作数栈指的就是当前栈桢的操作数栈
  • 操作数栈也称为操作栈,结构为一个后进先出的栈,用来进行方法中的一些运算。和局部变量表类似的,在java文件编译后的class文件中就有规定操作数栈的最大深度。很多书中都用iadd来举例,这是一个简单的例子,iadd字节码指令表示将两个int类型的数据相加,那么在这个字节码执行之前,我们肯定已经通过某些字节码将两个局部变量表中的int类型数据压入操作数栈中,然后通过iadd相加进行操作。需要注意的是在操作数栈中,只有long和double可以占有两个深度。
  • JAVA虚拟机的解释执行引擎称为 “基于栈的执行引擎”,其中所指的“栈”就是操作数栈。而且通过某些优化手法,两个栈帧的一个操作数栈和另一个局部变量表是有重叠的,这样在方法调用的时候就可以省略某些额外的参数传递开销。
  • 当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令向操作数栈中写入和提取内容,也就是入栈和出栈操作。方法 Code 属性的 max_stacks 数据项设定了操作数栈的最大深度
  • 向其他方法传参的参数,也存在操作数栈中,其他方法返回的结果,返回时存在操作数栈中。
3. 动态链接
  • 一个方法调用另一个方法,或者一个类使用另一个类的成员变量时,总得知道被调用者的名字吧?(你可以不认识它本身,但调用它就需要知道他的名字)。符号引用就相当于名字,这些被调用者的名字就存放在Java字节码文件里。
    • 名字是知道了,但是Java真正运行起来的时候,真的能靠这个名字(符号引用)就能找到相应的类和方法吗?
      需要解析成相应的直接引用,利用直接引用来准确地找到。
    • 举个例子,就相当于我在0X0300H这个地址存入了一个数526,为了方便编程,我把这个给这个地址起了个别名叫A, 以后我编程的时候(运行之前)可以用别名A来暗示访问这个空间的数据,但其实程序运行起来后,实质上还是去寻找0X0300H这片空间来获取526这个数据的。
      这样的符号引用和直接引用在运行时进行解析和链接的过程,叫动态链接
  • 与动态连接相对应的是静态连接。静态方法、私有方法、实例构造器、父类方法和 final 方法统称为虚方法,虚方法的调用没有其他版本,无须对方法接收者进行多态选择,因此它们在类加载的解析阶段就会把涉及到的符号引用全部转变为可确定的直接引用,不会延迟到运行期才去完成。而有些方法,比如重载和重写方法,具有多个版本,无法直接确定调用的是什么版本,这部分符号引用的转换就必须等到运行期来完成。
  • 每一个栈帧内部都要包含一个指向运行时常量池的引用,来支持动态链接的实现
4. 方法返回地址
  • 方法返回地址指的是方法退出后的返回地址
  • 我们说栈帧中包括返回地址,可能这样说有些勉强,毕竟返回地址是一个返回的状态和概念,不是一个具体的结构。当一个方法开始执行后,只有两种方法能够退出这个方法。第一种方法就是在方法执行时遇到了return语句(遇到一个返回字节码指令),这个时候正常退出该方法,并且将相应的方法的返回值传递给该方法的调用者。第二种方法就是在该方法中遇到了一个异常,而且异常没有通过try-catch来捕获(在这里讨论虚拟机内部结构的时候我说try-catch这种java语法上的结构不是很好,但是为了能够方便的表达清楚我这样做了)或者是自己throw抛出一个异常(athrow指令抛出异常)。在这两种情况下,只要在本方法中没有匹配到异常处理器,就会导致方法退出,一个异常退出的方法不会再给该方法的调用者一个返回值
  • 正常的退出情况,退回到该方法的调用者中被调用的位置(这个具体的位置被PC寄存器记录)。而异常的退出情况返回地址要通过异常处理器来确定,栈帧中一般不保存这部分信息
5. 附加信息

附加信息指的是在虚拟机实现中加入了一些规范里没有描述的信息到栈帧之中,例如与调试相关的信息。

3. 方法调用

1. 解析

解析阶段将方法的符号引用转化成直接引用的的方法,必须在方法运行前就确定一个可调用的版本,并且这个版本在运行阶段是不可改变的。就是方法“编译期可知,运行期不可变”,符合这个规则的方法有静态方法和私有方法两大类。前者与所属的类直接有关系,后者在外部不可以被访问。这两种方法都适合在解析调用,也就是把这些方法的符号引用转化成直接引用

在JVM中有5条调用方法的字节码指令:

  • invokestatic:调用静态方法
  • invokespecial:调用实例构造器方法,私有方法和父类方法
  • invokevirtual:调用所有虚方法
  • invokeinterface:调用接口方法,运行时确定一个实现此接口的对象
  • invokedynamic:先在运行时动态解析出调用点限定符所引用的的方法,然后再执行此方法

只要能被invokestatic和invokespecial指令调用的方法都可以在解析阶段中确定唯一的调用版本,符合这个条件的有静态方法、私有方法、实例构造器、父类方法4类,它们在类加载阶段就会把符号引用解析为该方法的直接引用。这些方法称为非虚方法(还包括使用final修饰的方法,虽然final方法使用invokevirtual指令调用,因为final方法注定不会被重写,也就是无法被覆盖,也就无需对其进行多态选择),剩下三个字节码指令调用的方法叫做虚方法(invokevirtual指令调用的final修饰的方法除外)

解析调用一定是一个静态的过程,在编译期间就可以完全确定,在类装载的解析阶段就会把涉及的符号引用全部转化为可确定的直接引用,不会延迟到运行期去完成。而分派调用可能是静态的也可能是动态的,根据分派一句的宗量数可分为单分派和多分派。因此分派可分为:静态单分派、静态多分派、动态单分派、动态多分派

2. 分派
a). 静态分派

根据方法参数的静态类型确定调用方法的版本,在编译期间确定,典型的应用就是重载
分派调用可能是静态的也可能是动态的,是实现多态性的体现
测试代码:

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

运行结果:

human is saying hello
human is saying hello

在上述的程序示例中,有三个重载方法,根据传入的对象类型的不同而不同。

sd.sayHello(man);
sd.sayHello(woman);

方法调用的语句可以看出,方法调用的对象是sd,然后具体执行哪个方法取决与方法的参数

参数man创建语句:Human man=new Man();是一个父类引用指向子类的对象(多态),对于变量man来说,Human是她的静态类型,Man是它的动态类型。在编译阶段可以确定的是变量的静态类型,也就是编译器根据静态类型选择了sayHello(Human)为调用目标,就把这个方法的符号引用写入了main方法的两条invokevirtual的指令参数中

测试代码2:

public class Overload {
	public static void sayHello(Object arg){
		System.out.println("hello object");
	}
	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 object");
	}
	public static void sayHello(char arg){
		System.out.println("hello char");
	}
	public static void sayHello(char...arg){
		System.out.println("hello char...");
	}
	public static void sayHello(Serializable arg){
		System.out.println("hello serializable");
	}
	public static void main(String[] args) {
		sayHello('a');
	}
}

运行结果:

hello char

当调用sayHello(‘a’),自然调用的char类型参数的方法,输出hello char

当注释掉char的重载方法,输出hello int,发生一次自动转型,'a’可以代表字符,也可以代表数字97,所以调用int类型的重载方法

再注释掉int的方法,输出hello long,发生两次类型转化,会按照char->int->long->float->double的顺序匹配。但不会向下转型调用方法,不安全。还找不到就会调用char的自动装箱类型,输出hello Character。继续注释掉Character的重载方法,输出"hello serializable",因为char的包装类Character实现了接口Serializable.继续注释掉,输出hello object。Object是所有类的父类,如果有多个父类,那将从下到上开始搜索,找到可以调用的重载方法。继续注释掉,才会输出长参数的结果hello char…
上述例子展示了在编译期间选择静态分派目标的过程,这就是java实现方法重载的本质
解析调用和静态分派是不同层次的筛选,确定目标方法的过程,静态调用是在编译期间将静态方法,构造方法等符号引用转化为直接引用,但是静态方法和构造方法也有重载方法,所以在编译期间也会对这些方法进行静态分派

b). 动态分派

只有在运行期间根据对象的实际类型能确定调用方法的版本,典型的应用就是方法的重写


public class DynamicDispatch {
	static abstract class Human{
		protected abstract void sayHello();
	}
	static class Man extends Human{
		@Override
		protected void sayHello() {
			System.out.println("man is say hello");		
		}
	}
	static class Woman extends Human{
		@Override
		protected void sayHello() {
			System.out.println("woman is 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 is say hello
woman is say hello
woman is say hello

在调用方法的语句中man.sayHello();woman.sayHello();显然不是根据对象的静态类型来确定调用方法的版本。这次确定调用哪个方法取决与man和woman两个对象的类型。这两个方法的字节码指令都是invokevirtual,invokevirtual指令的运行时解析如下步骤:

  1. 找到操作数栈栈顶的第一个元素所指向的对象的实际类型。(确定这个方法属于哪个类)
  2. 如果在该类型中找到与常量描述符和简单名称相符的方法,就进行访问权限,通过则返回方法的直接引用,没有通过则抛出异常
  3. 如果在类型中没有找到对应的方法,则按照继承关系从下往上依此查找方法
  4. 如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常

invokevirtual指令执行的时候先确定方法调用的对象的实际类型,所以会把两次方法调用的符号引用解析到不同的直接引用上,这个过程叫做动态分派

c). 单分派与多分派

方法的接收者和方法的参数统称为方法的宗量。根据分派基于多少种宗量,可以将分派划分为单分派和多分派。单分派是根据一个宗量对目标方法进行选择,多分派则是根据多于一个宗量对目标方法进行选择

测试代码:

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");
        }
    }
    @Test
    public void test(){
        Father father = new Father();
        Father son = new Son();
        father.hardChoice(new _360());
        son.hardChoice(new QQ());
    }
}

运行结果:

father choose 360
son choose qq

上述有关于hardChoice方法的两次调用,涉及了静态分派和动态分派的过程:

  • 首先看看编译阶段编译器的选择,也就是静态分派的过程(关于重载)。此时选择目标方法的依据有两点:一是静态类型是Father还是Son,而是方法参数是QQ还是_360。此处选择结果最终的产物是产生了两条invokevirtual指令,两条指令的参数分别是指向Father.hardChoice(_360)和Father.hardChoice(QQ)方法的符号引用。因为是根据两个宗量进行分派,所以java语言的静态分派属于多分派类型
  • 再看看运行阶段虚拟机的选择,也就是动态分派的过程(关于重写),在执行“son.hardChoice(new QQ());”这句代码时,更准确的说,是在执行invokevirtual指令时,由于编译器已经确定了目标方法的签名必须是hardChoice(QQ),虚拟机此时不会关心传过来的参数类型,也就是此时传过来的实际类型、静态类型都不会对产生任何影响。唯一可以对虚拟机的选择产生影响的就是此方法的接收者的实际类型是Father还是Son。因为只有一个宗量作为依据,所以java语言的动态分派属于单分派
d). 虚拟机动态分派的实现

由于动态分派是非常频繁的动作,而且动态分派的方法版本选择过程需要运行时在类的方法元数据中搜索合适的目标方法,因此在虚拟机的实际实现中基于性能的考虑,大部分实现都不会真正的进行如此频繁的搜索。面对这种情况,最常用的“稳定优化”手段就是为类在方法区中建立一个虚方法表(vtable,熟悉C++的肯定很熟悉。于此对应的,在invokeinterface执行时也会用到接口方法表—itable),使用虚方法表索引来代替元数据查找以提高性能。具体如下图所示:
在这里插入图片描述

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

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

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

4. 动态类型语言支持

JDK 7 增加了对 JSR 292 的支持,在 JVM 中动态类型语言的运行速度将变得更快。这一支持的关键在于增加了新的 Java 字节码 invokedynamic,它用于方法调用,还有新的连接机制,其中包含了一个新的构造:方法句柄(method handle)

1. 动态类型语言和 JVM

什么是动态类型语言?动态类型语言的关键特征是他的类型检查的主体过程是在运行期而不是编译期,满足这个特征的语言有好呢多,常用的包括:APL、Clojure、Erlang、Groovy、JavaScript、Jython、Lisp、Lua、PHP、Prolog、Python、Ruby、Smalltalk和Tcl等。相对的,在编译期就进行类型检查过程的语言(C++和Java等)就是最常用的静态类型语言。

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

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


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

由此看来,一门语言的哪一种检查行为要在运行期进行,哪一种检查要在编译器进行并灭有必然的因果逻辑关系,关键是语言规范中人为规定的。再举一个例子来解释“类型检查”,例如下面这一句非常简单的代码:

obj.println("hello world");

虽然每个人都能看懂这行代码要做什么,但对于计算机来说,这一行代码“没头没尾”是无法执行的,他需要一个具体的上下文才有讨论的意义。
现在假设这行代码是在Java语言中,并且变量obj的静态类型为java.io.PrintStream,那变量obj的实际类型就必须是PrintStream的子类(实现了PrintStream接口的类)才是合法的。否则,哪怕obj属于一个确实有用println(String)方法,但与PrintStream接口没有继承关系,代码依然不可能运行——因为类型检查不合法。
但是相同的代码在ECMAScript(JavaScript)中情况则不一样,无论Object具体是何种类型,只要这种类型的定义中确实包含有println(String)方法,那方法调用便可成功。
这种差别产生的原因是Java语言在编译期间已将println(String)方法完整的符号引用(本例中为一个CONSTANT_InterfaceMethodref_info)生成出来,作为方法调用指令的参数存储到CLass文件中,例如下面这段代码:

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

这个符号引用包含了此方法定义在哪个具体类型之中、方法的名字以及参数顺序、参数类型和方法返回值等信息,通过这个符号引用,虚拟机可以翻译出这个方法的直接引用。而在ECMAScript等动态类型语言中,变量obj本身是没有类型的,变量obj的值才具有类型,编译时最多只能确定方法名称、参数、返回值这些信息,而不会去确定方法所在的具体类型(即方法接收者不固定)。“变量无类型而变量值才有类型”这个特点也是动态类型语言的一个重要特征。
了解了动态和静态类型语言的区别后,也许读者的下一个问题就是动态、静态类型语言两者谁更好,或者谁更加先进?这种比较不会有确切答案,因为他们都有自己的优点,选择哪种语言是需要经过权衡的。静态类型语言在编译器确定类型,最显著的好处是编译器可以提供严谨的类型检查,这样与类型相关的问题能在编码的时候就及时发现,利于稳定性及代码达到更大规模。而动态类型语言子啊运行期确定类型,这可以为开发人员提供更大的灵活性,某些在静态类型语言中需要大量“臃肿”代码来实现的功能,由动态类型语言来实现可能会更加清晰和简洁,清晰和简洁通常也意味着开发效率的提升。

2. JDK 1.7与动态类型

回到本文的主题,来看看Java语言、虚拟机与动态类型语言之间有什么关系。Java虚拟机毫无疑问是Java语言的运行平台,但他的使命并不仅限于此,早在1997年出版的《虚拟机规范》中就规划了这样一个愿景:“在未来,我们会对Java虚拟机进行适当的扩展,以便更好地支持其他语言运行于Java虚拟机之上”。而目前确实已经有许多动态类型语言运行于Java虚拟机之上了,如Clojure、Groovy、Jython和JRuby等,能够在同一个虚拟机上可以达到静态类型语言的严谨性与动态类型语言的灵活性,这是一件很美妙的事情。
但遗憾的是:Java虚拟机层面对动态类型语言的支持一直都有所欠缺,主要表现在方法调用方面:JDK 1.7以前的字节码指令集中,4条方法调用指令(invokevirtual、invokespecial、invokestatic、invokeinterface)的第一个参数都是被调用的方法的符号引用(CONSTANT_Methodref_info或者CONSTANT_InterfaceMethodref_info常量),前面已经提到过,方法的符号引用在编译时产生,而动态类型语言只有在运行期才能确定接收者类型。这样,在Java虚拟机上实现的动态类型语言就不得不使用其他方式(如编译时留个占位符类型,运行时动态生成字节码实现具体类型到占位符类型的适配)来实现,这样势必让动态类型语言实现的复杂度增加,也可能带来额外的性能或者内存开销。尽管可以利用一些办法(如Call Site Caching)让这些开销尽量变小,但这种底层问题终归是应当在虚拟机层面上去解决才最合适,因此在Java虚拟机层面上提供动态类型的直接支持就成为了java平台的发展趋势之一,这就是JDK1.7(JSR)中invokedynamic指令以及java.lang.invoke包出现的技术背景。

3. java.lang.invoke包

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

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

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

void sort(List list, Comparator c)

不过,在拥有Method Handle之后,Java语言也可以拥有类似于函数指针或者委托的方法别名的工具了。下面代码演示了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 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)

从上面的例子可以看出,使用Method并没有什么困难,不过看完它的用法之后,读者大概就会产生疑问,相同的事情,用反射不是早就可以实现了吗?
确实,仅站在Java语言的角度来看,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是轻量级。
  • 由于MethodHandle是对字节码的方法指令调用的模拟,所以理论上虚拟机在这方面做的各种优化(如方法内联),在MethodHandle上也应当可以采用类似思路去支持。而通过反射去调用方法则不行。
    MethodHandle与Reflection除了上面列举的区别外,最关键的一点还在于去掉前面讨论施加的前提“仅站在Java语言的角度来看”:Reflection API的设计目标是只为java语言服务的,而MethodHandle则设计成可服务于所有Java虚拟机之上的语言,其中也包括Java语言。
4. invokedynamic指令

在某种程度上,invokedynamic指令与MethodHandle机制的作用是一样的,都是为了解决原有4条“invoke*”指令方法分派规则固化在虚拟机之中的问题,把如何查找目标方法的决定权从虚拟机转嫁到具体用户代码之中,让用户(包含其他语言的设计者)有更高的的自由度。而且,他们两者的思路也是可类比的,可以把他们想象成为了达到同一个目的,一个采用上层Java代码和API来实现,另一个用字节码和Class中其他属性、常量来完成。因此,如果理解了前面的MethodHandle例子,那么理解invokedynamic指令也并不困难。
每一次含有invokedynamic指令的位置都称作“动态调用点”(Dynamic Call Site),这条指令的第一个参数不再是代表方法符号引用的CONSTANT_Methodref_info常量,而是变为JDK 1.7新加入的CONSTANT_InvokeDynamic_info常量,从这个新常量中可以得到3项信息:引导方法(Bootstrap Method,此方法存放在新增的BootstrapMethods属性中)、方法类型(MethodType)和名称。引导方法是固定的参数,并且返回值是java.lang.invoke.CallSite对象,这个代表真正要执行的目标方法调用。根据CONSTANT_InvokeDynamic_info常量中提供的信息,虚拟机可以找到并且执行引导方法,从而获得一个CallSite对象,最终调用要执行的目标方法。我们还是举一个实际的例子来解释这个过程,如下所示:

import static java.lang.invoke.MethodHandles.lookup;
 
import java.lang.invoke.CallSite;
import java.lang.invoke.ConstantCallSite;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
 
public class InvokeDynamicTest {
	public static void main(String[] args) throws Throwable {
		INDY_BootstrapMethod().invokeExact("icyfenix");
	}
 
	public static void testMethod(String s) {
		System.out.println("hello String:" + s);
	}
 
	public static CallSite BootstrapMethod(MethodHandles.Lookup lookup,
			String name, MethodType mt) throws Throwable {
		return new ConstantCallSite(lookup.findStatic(InvokeDynamicTest.class,
				name, mt));
	}
 
	private static MethodType MT_BootstrapMethod() {
		return MethodType
				.fromMethodDescriptorString(
						"(Ljava/lang/invoke/MethodHandles $Lookup ; Ljava/lang/String ; Ljava/lang/invoke/MethodType; )Ljava/lang/invoke/CallSite;",
						null);
	}
 
	private static MethodHandle MH_BootstrapMethod() throws Throwable {
		return lookup().findStatic(InvokeDynamicTest.class, "BootstrapMethod",
				MT_BootstrapMethod());
	}
 
	private static MethodHandle INDY_BootstrapMethod() throws Throwable {
		CallSite cs = (CallSite) MH_BootstrapMethod().invokeWithArguments(
				lookup(),
				"testMethod",
				MethodType.fromMethodDescriptorString("(Ljava/lang/String; )V",
						null));
		return cs.dynamicInvoker();
	}
}

这段代码与前面MethodHandleTest的作用基本上是一样的,虽然没有加以注释,但是阅读起来应当不困难。由于invokedynamic指令所面向的使用者并非Java语言,而是其他Java虚拟机之上的动态语言,因此仅依靠java语言的编译器Javac没办法生成带有invokedynamic指令的字节码,所以要使用java语言来演示invokedynamic指令只能用一些变通的办法。John Rose(Da Vinci Machine Project的Leader)编写了一个把程序的字节码转换为使用invokedynamic的简单工具INDY来完成这件事情,我们要使用这个工具来产生最终要的字节码,因此这个示例代码中的方法名称不能随意改动,更不能把几个方法合并到一起写,因为他们是要被INDY工具读取的。
把上面代码编译、再使用INDY转换后重新生成的字节码如下所示(结果使用javap输出,因版面原因,精简了许多无关的内容)
在这里插入图片描述
从main()方法的字节码可见,原本的方法调用指令已经替换为invokedynamic,他的参数为第123项常量(第二个值为0的参数在HotSpot中用不到,与invokeinterface指令那个值为0的而参数一样都是占位的)

2:invokedynamic#123 , 0//InvokeDynamic#0 : testMethod : (Ljava/lang/String; )V

从常量池中可见,第123项常量显示“#123=InvokeDynamic#0:#121”说明他是一项CONSTANT_InvokeDynamic_info类型常量,常量值中前面的“#0”代表引导方法取BootstrapMethods属性表的第0项(javap没有列出属性表的具体内容,不过示例中仅有一个引导方法,即BootstrapMethod()),而后面的“#121”代表引用第121项类型为CONSTANT_NameAndType_info的常量,从这个常量中可以获取方法名称和描述符,即后面输出的“testMethod:(Ljava/lang/String;) V”。
再看一下BootstrapMethod(),这个方法Java源码中没有,是INDY产生的,但是他的字节码很容易读懂,所有逻辑就是调用MethodHandles $Lookup的findStatic()方法,产生testMethod()方法的MethodHandle,然后用他创建一个COnstantCallSite对象。最后,这个对象返回给invokedynamic指令实现对testMethod()方法的调用,invokedynamic指令的调用过程到此就宣告完成了

5. 掌控方法分派规则

invokedynamic指令与前面4条“invoke*”指令的最大差别就是他的分派逻辑不是由虚拟机决定的,而是由程序员决定的。在介绍Java虚拟机动态语言支持的最后一个小结中,这里通过一个简单例子(如下所示),帮助读者理解程序员在可以掌控方法分派规则之后,能做什么以前无法做到的事情

class GrandFather {
	void think() {
		System.out.println("i am grandfather");
	}
}
class Father extends GrandFather {
	void think() {
		System.out.println("i am father");
	}
}
class Son extends Father {
	void thinking() {
		// 请读者在这里填入适当的代码(不能修改其他地方的代码)
		// 实现调用祖父类的thinking()方法,打印“i am grandfather”
	}
}

在Java程序中,可以通过“super”关键字很方便的调用到父类中方法,但如果要访问祖类的方法呢?
在JDK 1.7之前,使用纯粹的Java语言很难处理这个问题(直接生成字节码就很缉拿但,如使用ASM等字节码工具),原因是在Son类的thinking()方法中无法获取一个实际类型是GrandFather的对象引用,而invokevirtual指令的分派逻辑就是按照方法接收者的实际类型进行分派,这个逻辑是固化在虚拟机中的,程序员无法改变。在JDk 1.7中,可以使用下面代码的程序来解决这个问题


import static java.lang.invoke.MethodHandles.lookup;
 
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodType;
 
public class Test {
	class GrandFather {
		void think() {
			System.out.println("i am grandfather");
		}
	}
 
	class Father extends GrandFather {
		void think() {
			System.out.println("i am father");
		}
	}
 
	class Son extends Father {
		void thinking() {
			try {
				MethodType mt =MethodType.methodType(void.class);
				MethodHandle mh = lookup().findSpecial(GrandFather.class, "thinking", mt, getClass());
				mh.invoke(this);
			} catch (Throwable e) {
			}
		}
	}
	public static void main(String[] args) {
		(new Test().new Son()).thinking();
	}
}

运行结果:

i am grandfather

猜你喜欢

转载自blog.csdn.net/haiyanghan/article/details/108812229