JVM之动态方法调用:invokedynamic

在本文的前面的姊妹篇中,介绍了Java方法调用的5种操作码中的4种。它们是Java 8和Java 9中方法调用的标准字节码形式。

于是第五个操作码invokedynamic便进入了我们的视线。简单来说,Java 7中在语言层面上对invokedynamic是没有直接支持的。事实上,当Java 7的运行时首次引入invokedynamic指令时,javac编译器是不会生成这个字节码的。

而到了Java 8中,invokedynamic则成为了实现高级平台特性的一个首要机制。使用这个操作码的最明确、简单的例子便是lambda表达式。读这篇文章前需要熟悉一下JVM的方法调用,也可以先读一下本文的前一姊妹篇

lambda的实质是对象引用

在开始深入介绍invokedynamic是如何赋能lambda表达式前,我们先简单介绍下什么是lambda表达式。Java只有两种值的类型:基础类型(比如char,int等等)和对象引用。lambda显然并不是基础类型,那它只能是对象引用了。看下这个lambda表达式:

public class LambdaExample {
    private static final String HELLO = "Hello";

    public static void main(String[] args) throws Exception {
        Runnable r = () -> System.out.println(HELLO);
        Thread t = new Thread(r);
        t.start();
        t.join();
    } 
}

5行的lambda表达式被赋值给了Runnable类型的变量。这说明lambda变成了一个兼容Runnable类型的对象的引用。本质上来说,这个对象的类型应该是Object的某个子类,它额外定义了一个方法(并且没有别的字段)。这个额外的方法就是Runnable所期望的run()方法。

Java 8以前,这样的对象只能通过一个具体实现了Runnable接口的匿名类来表示。事实上,Java 8的lambda表达式最初的实现原型也是内部类。

但从JVM的未来长期的roadmap来看,是希望能够支持更复杂的lambda表达式的。如果只能显式地通过内部类来实现lambda表达式,未来版本的实现方式就会比较受限。而这并不是大家想要的,因此Java 8和Java 9采用了一种更复杂的技术,而不是硬编码为内部类。上面那个例子对应的字节码如下:

public static void main(java.lang.String[]) throws java.lang.Exception;
    Code:
       0: invokedynamic #2,  0 // InvokeDynamic
                               // #0:run:()Ljava/lang/Runnable;

       5: astore_1
       6: new           #3       // class java/lang/Thread
       9: dup
       10: aload_1
       11: invokespecial #4      // Method java/lang/Thread."<init>":
                            // (Ljava/lang/Runnable;)V
       14: astore_2
       15: aload_2
       16: invokevirtual #5      // Method java/lang/Thread.start:()V
       19: aload_2
       20: invokevirtual #6      // Method java/lang/Thread.join:()V
       23: return

标记0处的字节码说明正使用invokedynamic来调用某个方法,并将返回值存储到栈上。接下来的字节码则对应着Java方法的剩余部分,这个比较容易理解。

invokedynamic是如何工作的

下面我将要介绍下invokedynamic的内部细节以及这个操作码是如何工作的。当类加载器加载了一个使用了invokedynamic指令的类时,要调用的目标方法是没法提前预知的。这个设计和JVM中其它方法调用的字节码都不一样。

比如说,在前文中提到的invokestatic和invokespecial,具体的实现方法(又叫调用目标)在编译时就已经确定了。而对于 invokevirtual和invokeinterface来说,调用目标在运行时才能确定。然而,选择的目标也是受限于Java语言规范的规则和类型系统的约束的。因此,至少有部分调用信息在编译期是能确定的。

相反,在具体调用哪个方法方面,invokedynamic要更灵活。在使用了invokedynamic的类的常量池中,会有一个特殊的常量,invokedynamic操作码正是通过它来实现这种灵活性的。这个常量包含了动态方法调用所需的额外信息,它又被称为引导方法(Bootstrap Method,BSM)。这是invokedynamic实现的关键,每个invokedynamic的调用点(call site)都对应着一个BSM的常量池项。为了将BSM关联到某个特定的invokedynamic调用点上,Java 7的类文件格式中新增了一个InvokeDynamic类型的新常量池项。

invokedynamic指令的调用点在类加载时是“未链接的(unlaced)”。调用BSM后才能确定具体要调用的方法,返回的这个CallSite对象会被关联到调用点上。

最简单的调用点是ConstantCallSite,一旦完成查找便无需重复执行。后续的调用都会直接唤起这个调用点的目标对象,不再需要任何额外的工作。就是说调用点是稳定的,也即是对诸如JIT编译器的JVM子系统是友好的。

这套机制要想高效运行,JDK就必须要有合适的类型来表示调用点、BSM,以及其它的实现部分。Java最早核心的反射类型是包括方法及类型的。然而,这套API已经是非常古老了,有许多原因表明它并不是理想的选择。

举个例子,反射诞生之时还没有集合和泛型。因此对应的方法签名在反射API中只能通过Class[]来表示。这显得很笨重并且很容易出问题,并且Java数组的冗长语法也让它举步维艰。更不用说还要手工处理基础类型的装箱及拆箱操作,以及可能会出现的void返回值。

方法句柄(Method Handle)

为了不强制开发人员去处理这些问题,Java 7引入了一套新的API,叫方法句柄(Method Handle),用来提供必要的抽象。这套API的核心都在java.lang.invoke包中,尤其是MethodHandle这个类。这个类型的实例是可执行的,可以用它来调用某个方法。从参数和返回值可以看出,它们是动态类型的,这样就尽可能地保障了类型安全,可以动态去进行使用。这套API是invokedynamic的基础,但也可以单独使用,你可以把它看作是更现代、更安全的反射API。

方法句柄需要查找上下文才能获取到。常用的获取上下文的方式是调到静态方法MethodHandles.lookup()。它会返回一个基于当前执行方法的查询上下文。通过调用一系列的find*()(比如说,findVirtual()或findConstructor())方法,可以从这个上下文中获取到方法句柄。

方法句柄和反射的重要区别是,查询上下文只会返回查询对象创建时所在的域能访问的方法。这个是无法绕过的,也没有类似于反射里setAccessible()这样的后门。也就是说方法句柄始终是可以安全使用的,哪怕是和security manager一起使用。

但也还要注意,因为访问控制移到了方法查询阶段。就是说查询上下文可以返回查询时可见的私有方法,但不能保证这些方法句柄在调用时仍然可见。

为了解决方法签名如何表示的问题,MethodHandles还引入了MethodType类,这是一个简单的不可变类型,它有许多非常有用的特性。它可以:

  • 用来表示方法的类型签名
  • 包括返回值类型以及参数类型
  • 不包含接收者类型和方法名
  • 是设计来解决反射中的Class[]问题的

不光如此,它的实例还是不可变的。

有了这套API,方法签名便可以通过MethodType的实例来表示了,也不再需要为了每一个可能的签名去创建一个新的类型了。一个很简单的工厂方法便能够创建出新的实例:

// toString()
MethodType mtToString =
    MethodType.methodType(String.class);
// A setter method
MethodType mtSetter =
    MethodType.methodType(void.class, Object.class);
// compare() from Comparator<String>
MethodType mtStringComparator =
    MethodType.methodType(int.class, String.class, String.class);

一旦创建好了签名对象,便可以用它来查找方法句柄(还得加上方法名),正如下面的例子那样,查找toString()方法的句柄。

public MethodHandle getToStringHandle() {
    MethodHandle mh = null;
    MethodType mt = MethodType.methodType(String.class);
    MethodHandles.Lookup lk = MethodHandles.lookup();

    try {
        mh = lk.findVirtual(getClass(), "toString", mt);
         
    } catch (NoSuchMethodException | IllegalAccessException mhx) {
        throw new AssertionError().initCause(mhx);
    }

    return mh; 
}

可以类似调用反射API那样去invoke这个句柄。如果是实例方法则必须传入接收者对象,调用方代码也必须要处理可能出现的各种异常。

MethodHandle mh = getToStringMH();

try {
    mh.invoke(this, null);
} catch (Throwable e) {
    e.printStackTrace();
}

那么现在BSM的概念应该就更明确了:当程序首次执行到invokedynamic的调用点时,会去调用相关联的BSM。BSM会返回一个调用点对象,它包含了一个方法句柄,会指向最终绑定到这个调用点上的方法。在静态类型系统中,这套机制如果要正确运行,BSM就必须返回一个签名正确的方法句柄。

再回到之前Lambda表达式的那个例子中,invokedynamic可以看作是调用了平台的某个lambda表达式的工厂方法。实际的lambda表达式则被转换成了该表达式所在类的一个静态私有方法。

private static void lambda$main$0();
    Code:

       0: getstatic     #7  // Field
                            //  java/lang/System.out:Ljava/io/PrintStream;
       3: ldc           #9  // String Hello
       5: invokevirtual #10 // Method
                            //  java/io/PrintStream.println:
                            //  (Ljava/lang/String;)V
       8: return

lambda工厂方法会返回一个实现了Runnable类型的的实例对象,lambda执行时该类型的run()方法会回调到这个私有方法上。

通过javap -v可以看到这个常量池项:

#2 = InvokeDynamic #0:#40 //
#0:run:()Ljava/lang/Runnable;

class文件中的BSM里也可以找到这个被调用的工厂方法:

BootstrapMethods:
  0: #37 REF_invokeStatic
java/lang/invoke/LambdaMetafactory.metafactory:(Ljava/lang/invoke/MethodH
andles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/i
nvoke/MethodType;Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodT
ype;)Ljava/lang/invoke/CallSite;
    Method arguments:
      #38 ()V
      #39 REF_invokeStatic optjava/LambdaExample.lambda$main$0:()V
      #38 ()V

这里包含一个叫metafactory()的静态工厂方法,它是在java.lang.invoke下的LambdaMetafactory类中的。lambda的静态方法生成之后,这个BSM方法便会在运行时生成连接的字节码。metafactory的入参包括查询对象、用来确保静态类型安全的方法类型(method types),以及指向lambda表达式的静态私有方法的方法句柄。

public static CallSite metafactory(
        MethodHandles.Lookup caller,
        String invokedName,
        MethodType invokedType,
        MethodType samMethodType,
        MethodHandle implMethod,
        MethodType instantiatedMethodType)
            throws LambdaConversionException {
    AbstractValidatingLambdaMetafactory mf;
    mf = new InnerClassLambdaMetafactory(
            caller, invokedType,
            invokedName, samMethodType,
            implMethod, instantiatedMethodType,
            false, EMPTY_CLASS_ARRAY, EMPTY_MT_ARRAY);
    mf.validateMetafactoryArgs();
    return mf.buildCallSite();
}

目前所使用的这个metafactory方法,仍会为每个lambda表达式生成一个内部类,但这些类是动态生成的,也不会回写到磁盘上。这个实现机制在未来的Java版本中可能会发生变化,这样原有的lambda表达式也都能受益于后续新的实现。

在Java 8和Java 9中,InnerClassLambdaMetafactory的实现使用了一个轻微修改过的ASM字节码库,它发布在jdk.internal.org.objectweb.asm包下。

它能动态地生成lambda表达式的实现类,同时还保证了未来的可扩展性和对JIT的友好性。

它的实现方式也是最简单的——调用点一旦返回后就不会再变化了。返回的调用点类型便是之前提到过的ConstantCallSite。invokedynamic还能支持更复杂的场景,比如调用点可以是可变的甚至是实现volatile变量类似的语义。当然这些情况也更复杂、更难以处理,但它们为平台的动态扩展提供了最大的可能性。

前面lambda表达式的例子揭示了invokedynamic操作码是如何在静态类型基础上进行关键性扩展的,它使得灵活的运行时分发成为了可能。

结论

可能大部分开发人员对invokedynamic都不是很熟悉,但它的加入确实让Java生态系统得到了明显的提升。如果没有invokedynamic以及它背后所代表的方法执行过程的重塑,未来Java版本中很多VM的优化技术都无法实现。

原文链接

原创文章,作者:ItWorker,如若转载,请注明出处:https://blog.ytso.com/60367.html

(0)
上一篇 2021年8月10日
下一篇 2021年8月10日

相关推荐

发表回复

登录后才能评论