读郑雨迪《深入拆解Java虚拟机》 -- 第八讲 JVM是如何实现invokedynamic的

本文转自https://time.geekbang.org/column/article/12564

以前,我们赛马 只能由马参加,但是对于一些年轻人里流行的鸭子类型(duck typing),只要跑起来像马的,它就是一只马,也可以参加赛马比赛。

class Horse {
    public void race() {
        System.out.println("Horse.race()");
    }
}

class Deer {
    public void race() {
        System.out.println("Deer.race()");
    }
}

class Cobra {
    public void race() {
        System.out.println("How do you turn this on? ");
    }
}

(如何用同一种方式调用他们的赛跑方法?)

说到了这里,如果我们将赛跑定义为对赛跑方法(对应上述代码中的race())的调用的话,那么这个故事的关键,就在于能不能在马场中调用非马类型的赛跑方法。

为了解答这个问题,我们先来看一下Java里的方法调用。在Java中,方法调用被编译为invokestatic、invokespecial、invokevirtual以及invokeinterface四种指令。这些指令与包含目标方法类名、方法名以及方法描述符的符号引用捆绑。在实际运行之前,Java虚拟机将根据这个符号引用链接到具体的目标方法。

可以看到,在四种调用指令中,Java虚拟机明确要求调用需要提供目标方法的类名。在这种体系下,我们有两个解决方案。

  • 调用其中一种类型的赛跑方法,比如说马类的赛跑方法。对于非马类型,则给它一套马甲,当成马来赛跑。
  • 通过反射机制,来查找并且调用各个类型中的赛跑方法,以此模拟真正的赛跑。

显然,比起直接调用,这两种方法都相当复杂,执行效率也可想而知。为了解决这个问题,Java7引入了一条新的指令invokedynamic该指令的调用机制抽象出调用点这一个概念,并允许应用程序将调用点连接至任何符合条件的方法上

public static void startRace(java.lang.Object)
    0: aload_0               // 加载一个任意对象
    1: invokedynamic race    // 调用赛跑方法

理想的调用方式

作为invokedynamic的准备工作,Java7引入了更加底层、更加灵活的方法抽象:方法句柄(MethodHandle)

方法句柄的概念

方法句柄是一个强类型的,能够被直接执行的引用。该引用可以指向常规的静态方法或者实例方法,也可以指向构造器或者字段。当指向字段时,方法句柄实则指向包含字段访问字节码的虚构方法,语义上等价于目标字段的getter或者setter方法。

这里需要注意的是,它并不会直接指向目标字段所在类的getter/setter,毕竟无法保证已有的getter/setter方法就是在访问目标字段。

方法句柄的类型(MethodType)是由所指向方法的参数类型以及返回类型组成的。它是用来确定方法句柄是否适配的唯一关键。当使用方法句柄时,我们其实并不关心方法句柄所指向方法的类名或者方法名。

方法句柄的创建是通过 MethodHandles.Lookup 类来完成的。它提供了多个API,既可以使用反射API中的Method来查找,也可以根据类、方法名以及方法句柄来查找。

当使用后者这种查找方式时,用户需要区分具体的调用类型,比如说对于用invokestatic调用的静态方法,我们需要使用 Lookup.findStatic 方法;对于用invokevirtual调用的实例方法,以及用invokeinterface调用的接口方法,我们需要使用findVirtual方法;对于用invokespecial调用的实例方法,我们则需要使用findSpecial方法。

调用方法句柄,和原本对应的调用指令是一致的。也就是说,对于原本用invokevirtual调用的方法句柄,它也会采用动态绑定;而对于原本用invokespecial 调用的方法句柄,它会采用静态绑定。

class Foo {
    private static void bar(Object o) {
        ...
    }
    public static Lookup lookup() {
        return MethodHandles.lookup();
    }
}

//获取方法句柄的不同方式
//1 通过反射
Method,Lookup l = Foo.looup(); //具备 Foo 类的访问权限
Method m = Foo.class.getDeclaredMethod("bar", Object.class);
MethodHandle mh0 = l.unreflect(m);

//2 通过findstatic
MethodType t = MethodType.methodType(void.class, Object.class);
MethodHandle mh1 = l.findStatic(Foo.class, "bar", t);

方法句柄同样也有权限问题。但它与反射API不同,其权限检查是在句柄的创建阶段完成的。在实际调用过程中,Java虚拟机并不会检查方法句柄的权限。如果该句柄被多次调用的话,那么与反射调用相比,它将省下重复权限检查的开销。

需要注意的是,方法句柄的访问权限不取决于方法句柄的创建位置,而是取决于 Lookup 对象的创建位置。

举个例子, 对于一个私有字段,如果Lookup对象是在私有字段所在类中获取的,那么这个Lookup对象便拥有对该私有字段的访问权限,即使是在所在类的外边,也能够通过该 Lookup 对象创建该私有字段的 getter 或者 setter。

由于方法句柄没有运行时权限检查, 因此,应用程序需要负责方法句柄的管理。 一旦它发布了某些指向私有方法的方法句柄,那么这些私有方法便被暴露出去了。

方法句柄的操作

方法句柄的调用可分为两种

  • 需要严格匹配参数类型的invokeExact。假设一个方法句柄将接受一个Object 类型的参数, 如果你直接传入String 作为实际参数,那么方法句柄的调用会在运行时抛出方法类型不匹配的异常。正确的调用方式是将该String 显式转化为 Object 类型。

在普通Java方法调用中, 我们只有在选择重载方法时, 才会用到这种显式转化。这是因为经过显式转化后,参数的声明发生了改变,因此有可能匹配到不同的方法描述符,从而选取不同的目标方法。调用方法句柄也是利用同样的原理,并且涉及了一个签名多样性(signature polymorphism) 的概念。(在这里我们暂且认为签名等同于方法描述符。)

public final native @PolymorphicSignature Object invokeExact(Object... args) throws Throwable;

方法句柄API有一个特殊的注解类

@PolymorphicSignature。在碰到被它注解的方法调用时,Java编译器会根据所传入参数的声明类型来生成方法描述符,而不是采用目标方法所声明的描述符。

在刚才的例子中,当传入的参数是String时,对应的方法描述符包含String类;而当我们转化为Object时,对应的方法描述符则包含Object类。

public void test(MethodHandle mh, String s) throws Throwable {
    mh.invokeExact(s);
    mh.invokeExact((Object) s);
}

//对应的 Java 字节码
public void test(MethodHandle, String) throws java.lang.Throwable;
    Code:
        0: aload_1
        1: aload_2
        2: invokevirtual MethodHandle.invokeExact:(Ljava/lang/String;)V
        5: aload_1
        6: aload_2
        7: invokevirtual MethodHandle.invokeExact:(Ljava/lang/Object;)V
       10: return

invokeExact 会确认该invokevirtual 指令对应的方法描述符,和该方法句柄的类型是否严格匹配。在不匹配的情况下, 便会在运行时抛出异常。

  • 如果需要自动适配参数类型,那么可以选取方法句柄的第二种调用方式invoke。它同样是一个签名多态性的方法。invoke会调用MethodHandle.asType 方法,生成一个适配器方法句柄,对传入的参数进行适配,在调用原方法句柄,对传入的参数进行适配,再调用原原方法句柄。调用原方法句柄的返回值同样也会先进行适配,然后再返回给调用者。

方法句柄还支持增删改参数的操作,这些操作都是通过生成另一个方法句柄来实现的。这其中,改操作就是刚刚介绍的 MethodHandle.asType 方法。删操作指的是将传入的部分参数就地抛弃,再调用另一个方法句柄。它对应的API是MethodHandles.dropArguments 方法。

增操作则会往传入的参数中插入额外的参数,再调用另一个方法句柄,它对应的API是MethodHandle.bindTo 方法。Java8中捕获类型的 Lambda 表达式便是用这种操作来实现的。

增操作还可以用来实现方法的柯里化。举个例子,有一个指向f(x, y)的方法句柄, 我们可以将 x 绑定为 4,生成另一个方法句柄 g(y) = f(4, y)。 在执行过程中, 每当调用g(y) 的方法句柄, 它会在参数列宗最前面参入一个4,在调用指向f(x, y) 的方法句柄。

方法句柄的实现

下面我们来看看 HotSpot 虚拟机中方法句柄调用的具体实现。(由于篇幅原因, 这里只讨论DirectMethodHandle。)

前面提到,调用方法句柄所使用的 invokeExact 或者 invoke 方法具备签名多态性的特性。它们会根据具体的传入参数来生成方法描述符。那么,拥有这个描述符的方法实际存在吗?对 invokeExact 或者 invoke 的调用具体会进入哪个方法呢?

import java.lang.invoke.*;

public class Foo {
    public static void bar(Object o) {
        new Exception().printStackTrace();
    }

    public static void main(String[] args) throws Throwable{
        MethodHandles.Lookup l = MethodHandles.lookup();
        MethodType t = MethodType.methodType(void.class, Object.class);
        MethodHandle mh = l.findStatic(Foo.class, "bar", t);
        mh.invokeExact(new Object());
    }
}

和查阅反射调用的方式一样,我们可以通过新建异常实例来查看栈轨迹。打印出来的占轨迹如下所示:

javac Foo.java
java Foo
java.lang.Exception
	at Foo.bar(Foo.java:5)
	at Foo.main(Foo.java:12)

也就是说, invokeExact 的目标方法竟然就是方法句柄指向的方法。

前面说到,invokeExact会对参数的类型进行校验, 并在不匹配的情况下抛出异常。如果它直接调用了方法句柄所指向的方法,那么这部分参数类型校验的逻辑将无处安放。因此,唯一的可能便是Java虚拟机隐藏了部分栈信息。

当我们启用了 -XX:+ShowHiddenFrames 这个参数来打印被Java虚拟机隐藏了的栈信息时,我们就会发现mian方法和目标方法中隔着两个貌似是生成的方法。

java -XX:+UnlockDiagnosticVMOptions -XX:+ShowHiddenFrames Foo
java.lang.Exception
	at Foo.bar(Foo.java:5)
	at java.lang.invoke.LambdaForm$DMH/1173230247.invokeStatic_L_V(LambdaForm$DMH:1000010)
	at java.lang.invoke.LambdaForm$MH/1414644648.invokeExact_MT(LambdaForm$MH:1000016)
	at Foo.main(Foo.java:12)

实际上,Java虚拟机会对invokeExact调用做特殊处理,调用至一个共享的、与方法句柄类型相关的特殊适配器中。这个适配器是一个LambdaForm,我们可以通过添加虚拟机参数将之到处成class文件 (-Djava.lang.invoke.MethodHandle.DUMP_CLASS_FILES=true)。

final class java.lang.invoke.LambdaForm$MH000 {  static void invokeExact_MT000_LLLLV(jeava.lang.bject, jjava.lang.bject, jjava.lang.bject);
    Code:
        : aload_0
      1 : checkcast      #14                 //Mclass java/lang/invoke/ethodHandle
        : dup
      5 : astore_0
        : aload_32        : checkcast      #16                 //Mclass java/lang/invoke/ethodType
      10: invokestatic  I#22                 // Method java/lang/invoke/nvokers.checkExactType:(MLjava/lang/invoke/ethodHandle,;Ljava/lang/invoke/ethodType);V
      13: aload_0
      14: invokestatic   #26     I           // Method java/lang/invoke/nvokers.checkCustomized:(MLjava/lang/invoke/ethodHandle);V
      17: aload_0
      18: aload_1
      19: ainvakevirtudl #30             2   // Methodijava/lang/nvokev/ethodHandle.invokeBasic:(LLeava/lang/bject;;V
       23 return

可以看到,在这个适配器中,它会调用Invokers.checkType 方法来检查参数类型, 然后调用 Invokers.checkCustomized 方法。后者会在方法句柄的执行次数超过一个阈值(对应参数 -Djava.lang.invoke.MethodHandle.CUSTOMIZE_THRESHOLD,默认值是127)。最后,它会调用方法句柄的invokeBasic 的方法、

Java虚拟机同样会对invokeBasic 调用做特殊处理,这会将调用至方法句柄本身所持有的适配器中。这个适配器同样是一个LamdaForm,你可以通过反射机制将其打印出来。

// 该方法句柄持有的 LambdaForm 实例的 toString() 结果
DMH.invokeStatic_L_V = Lambda(a0:L, a1:L) => {
    t2:L=DirectMethodHandle.internalMemberName(a0:L);
    t3:V=MethodHandle.linkToStatic(a1:L, t2:L);void}

这个适配器将获取方法句柄中的 MemberName 类型的字段, 并且以它为参数调用 linkToStatic 方法。Java 虚拟机也会对 linkToStatic 调用做特殊处理, 它将根据传入的MemberName 参数所存储的方法地址或者方法表索引,直接跳转至目标方法。

final class MemberName implements Member, Cloneable {
...
    // @Injected JVM_Method* vmtarget;
    // @Injected int         vmindex;
...
}

那么前面那个适配器中的优化又是怎么回事?实际上,方法句柄一开始持有的适配器是共享的。当它被多次调用之后。Invoker.checkCustomized 方法会为该方法句柄生成一个特有的适配器。这个特有的适配器会将方法句柄作为常量,直接获取其MemberName 类型的字段,并继续后面的linkToStatic 调用。

final class java.lang.invoke.LambdaForm$DMH000 {
  static void invokeStatic000_LL_V(java.lang.Object, java.lang.Object);
    Code:
       0: ldc           #14                 // String CONSTANT_PLACEHOLDER_1 <<Foo.bar(Object)void/invokeStatic>>
       2: checkcast     #16                 // class java/lang/invoke/MethodHandle
       5: astore_0     // 上面的优化代码覆盖了传入的方法句柄
       6: aload_0      // 从这里开始跟初始版本一致
       7: invokestatic  #22                 // Method java/lang/invoke/DirectMethodHandle.internalMemberName:(Ljava/lang/Object;)Ljava/lang/Object;
      10: astore_2
      11: aload_1
      12: aload_2
      13: checkcast     #24                 // class java/lang/invoke/MemberName
      16: invokestatic  #28                 // Method java/lang/invoke/MethodHandle.linkToStatic:(Ljava/lang/Object;Ljava/lang/invoke/MemberName;)V
      19: return

可以看到,方法句柄的调用和反射调用一样,都是间接调用。因此,它也会面临无法内联的问题。不过,与反射调用不同的是,方法句柄的内联瓶颈在于即时编译器能否将该方法句柄识别为常量。

我们来测量一下方法句柄的性能。可以通过重构代码,将方法句柄编程常量,来提升方法句柄调用的性能。

import java.lang.invoke.*;

public class Foo {
	public void bar(Object o) {
		
	}

	public static void main(String[] args) throws Throwable {
		MethodHandles.Lookup l = MethodHandles.lookup();
		MethodType t = MethodType.methodType(void.class, Object.class);
		MethodHandle mh = l.findVirtual(Foo.class, "bar", t);

		long current = System.currentTimeMillis();
		for(int i = 1; i < 2000000000; i++) {
			if(i % 100000000 == 0) {
				long temp = System.currentTimeMillis();
				System.out.println(temp - current);
				current = temp;
			}
			mh.invokeExact(new Foo(), new Object());
		}
	}

}

得到输出:

1917
1411
1529
1609
1436
1810
1600
1631
1482
1549
1610
1543
1533
1574
1527
1509
1709
1563
1557

关于将方法句柄变成常量来进行优化,我还没有思路,有思路的童鞋可以在讨论区指导一下,谢谢。

此文从极客时间专栏《深入理解Java虚拟机》搬运而来,撰写此文的目的:

  1. 对自己的学习总结归纳

  2. 此篇文章对想深入理解Java虚拟机的人来说是非常不错的文章,希望大家支持一下郑老师。

 

    原文作者:java虚拟机
    原文地址: https://blog.csdn.net/Ti_an_Di/article/details/82725574
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞