invokedynamic指令
invokedynamic是Java7引入的一条新指令,用以支持动态语言的方法调用。它将调用点(CallSite)抽象成一个Java类,并且将原本由JVM控制的方法调用以及方法链接暴露给了应用程序。在运行过程中,每一条invokedynamic指令将捆绑一个调用点,并且会调用该调用点所链接的方法句柄。
在第一次执行invokedynamic指令时,JVM会调用该指令所对应的启动方法(BootStrap Method),来生成前面提到的调用点,并且将之绑定至该invokedynamic指令中。在之后的运行过程中,JVM则会直接调用绑定的调用点所链接的方法句柄。
在字节码中,启动方法是用方法句柄来指定的。这个方法句柄指向一个返回类型为调用点的静态方法。该方法必须接收三个固定的参数,分别为Lookup类实例(MethodHandles.Lookup)、一个用来指代目标方法名字的字符串(String)、以及该调用点能够链接的方法句柄的类型(MethodType)。
除了这三个必须参数之外,启动方法还可以接受若干个其他的参数,用来辅助生成调用点,或者定位所要链接的目标方法。
1 | public static CallSite bootstrap(MethodHandles.Lookup l, String name, MethodType callSiteType) throws Throwable { |
ConstantCallSite是一种不可以更改链接对象的调用点。除此之外,Java核心类库还提供多种可以更改链接对象的调用点,比如MutableCallSite和VolatileCallSite。(应用程序还可以自定义调用点类,来满足特定的重链接需求)
invokedynamic指令最终调用的是方法句柄,而方法句柄会将调用者当成第一个参数。
调用点仅要求方法句柄的类型能够匹配,对方法名不做要求。将调用点与目标方法的链接交由应用程序来做,并且依赖于应用程序对目标方法进行验证。如果应用程序将“赛跑方法链接至睡觉方法,那只能怪应用程序自己”。
Java8的Lambda表达式
在Java8中,Lambda表达式也是借助invokedynamic来实现的。
Java编译器利用invokedynamic指令来生成实现了函数式接口(仅包括一个非default接口方法的接口,一般通过@FunctionalInterface注解)的适配器。
在编译过程中,Java编译器会对Lambda表达式进行解语法糖(desugar),生成一个方法来保存Lambda表达式的内容(方法引用,则不会生成额外的方法。Horse::race)。该方法的参数列表不仅包含原本Lambda表达式的参数,还包含它所捕获的变量。所捕获的变量同样也会作为参数传入生成的方法之中。
第一次执行invokedynamic指令时,它所对应的启动方法会通过ASM来生成一个适配器类。这个适配器类实现了对应的函数式接口。启动方法的返回值是一个ContantCallSite,其连接对象为一个返回适配器类实例的方法句柄。
根据Lambda表达式是否捕获其他变量,启动方法生成的适配器类以及所链接的方法句柄皆不同。
- 如果该Lambda表达式没有捕获其他变量,那么可以认为它是上下文无关的。启动方法将新建一个适配器类的实例,并且生成一个特殊的方法句柄,始终返回该实例。
- 如果该Lambda表达式捕获了其他变量,那么每次执行该invokedynamic指令,都要更新这些捕获了的变量,以防止它们发生了变化。
另外为了保证Lambda表达式的线程安全,无法共享同一个适配器类的实例。在每次执行invokedynamic指令时,所调用的方法句柄都需要新建一个适配器类实例。在这种个情况下,启动方法生成的适配器类将包含一个额外的静态方法,来构造适配器类的实例。该方法将接收这些捕获的参数,并且将它们保存为适配器类实例的实例字段。
虚拟机参数-Djdk.internal.lambda.dumpProxyClasses=/DUMP/PATH导出具体的适配器类。
捕获了局部变量的Lambda表达式多出了一个get$Lambda的方法。启动方法便会将所返回的调用点链接指向该方法的方法句柄。也就是说,每次执行invokedynamic指令时,都会调用至这个方法中,并构造一个新的适配器类实例。
Lambda以及方法句柄的性能分析
1 | import java.util.function.IntConsumer; |
未捕获变量
((IntConsumer) j -> Test.target(j)).accept(128)
lambda表达式所使用的invokedynamic将绑定一个ConstantCallSite,其链接的目标方法无法改变。因此,即时编译器会将该目标方法直接内联进来。对于这类没有捕获变量的Lambda表达式而言,目标方法只完成了一个动作,便是加载缓存的适配器类常量。
即时编译器能够将转换Lambda表达式所使用的invokedynamic,以及对IntConsumer.accept方法的调用统统内联进来,最终优化为空操作。与直接调用的性能并无太大区别。
对IntConsumer.accept方法的调用实则是对适配器类的accept方法的调用。从字节码看是调用了Java编译器在解Lambda语法糖时生成的方法。该方法内容便是Lambda表达式的内容,也就是直接调用目标方法Test.target。将这个方法调用内联进来之后,原本对accept方法的调用则会被优化为空操作。
捕获变量
((IntConsumer) j -> Test.target(x + j)).accept(128);
针对带捕获变量的版本,理论上每次调用invokedynamic指令,JVM都会新建一个适配器类的实例。该例中实际运行结果还是与直接调用的性能一致。
即使编译器的逃逸分析就将该新建实例给优化掉了。
虚拟机参数-XX:-DoEscapeAnalysis关闭逃逸分析。
逃逸分析能够去除这些额外的新建实例开销,但是不是时时奏效的。需要同时满足两件事:
- invokedynamic指令所执行的方法句柄能够内联
- 接下来的对accept方法的调用也能内联。
这样逃逸分析才能判定该适配器实例不逃逸。否则会在运行过程中不停地生成适配器类实例。应当尽量使用非捕获的Lambda表达式。
总结
invokedynamic指令抽象出调用点的概念,并且将调用该调用点所链接的方法句柄。在第一次执行invokedynamic指令时,JVM将执行它所对应的启动方法,生成并且绑定一个调用点。之后如果再次执行该指令,JVM则直接调用已经绑定了的调用点所链接的方法。
Lambda表达式到函数式接口的转换是通过invokedynamic指令来实现的。该invokedynamic指令对应的启动方法将通过ASM生成一个适配器类。
对于没有捕获其他变量的Lambda表达式,该invokedynamic指令始终返回同一个适配器类的实例。对于捕获了其他变量的Lambda表达式,每次执行invokedynamic指令将新建一个适配器类实例。
不管是捕获型的还是未捕获型的Lambda表达式,它们在性能上皆可达到直接调用的性能。其中,捕获型Lambda表达式借助了即时编译器中的逃逸分析,来边实际的新建适配器类实例的操作。
Invokedynamic 和 MethodHandle的缘由
JSR292: InvokeDynamic和MethodHandle的优化