invokedynamic 指令
千呼万唤始出来,上一篇文章介绍了那么久的方法句柄,终于来到 invokedynamic 指令讲解了。
invokedynamic 是 Java 7 引入的一条新指令,用以支持动态语言的方法调用。具体来说,它将调用点(CallSite)抽象成一个 Java 类,并且将原本由 Java 虚拟机控制的方法调用以及方法链接暴露给了应用程序。在运行过程中,每一条 invokedynamic 指令将**一个调用点,并且会调用该调用点所链接的方法句柄。
在第一次执行 invokedynamic 指令时,Java 虚拟机会调用该指令所对应的启动方法(BootStrap Method),来生成前面提到的调用点,并且将之绑定至该 invokedynamic 指令中。在之后的运行过程中,Java 虚拟机则会直接调用绑定的调用点所链接的方法句柄。
invokedynamic 的调用模式简单说就是:
- When JVM sees an invokedynamic instruction, it locates the corresponding bootstrap method in the class, and executes the bootstrap method.
- After executing the bootstrap method, a CallSite that is linked with a MethodHandle is returned;
- The invocation on the CallSite later will be transferred to real methods via a number of MethodHandles.
这里的 bootstrap 和 Methodhandle 都是用户提供的,其中 bootstrap 方法就是用户创建一个 CallSite,然后将这个 Callsite 链接到一个 MethodHandle。MethodHandle 所指向的方法可以再应用到其它的 MethodHandle, 直至最终一个方法或者多个方法。
CallSite
当 JVM 执行 invokedynamic
指令时,首先需要链接其对应的动态调用点 。在链接的时候,JVM会先调用一个启动方法(bootstrap method
)。这个启动方法的返回值是 java.lang.invoke.CallSite
类的对象。
在通过启动方法得到了 CallSite 之后,通过这个 CallSite 对象的 getTarget()
可以获取到实际要调用的目标方法句柄。
有了方法句柄之后,对这个动态调用点 的调用,实际上是代理给方法句柄来完成的。也就是说,对 invokedynamic
指令的调用实际上就等价于对方法句柄的调用,具体来说是被转换成对方法句柄的invoke方法的调用。
JDK 中提供了三种类型的动态调用点CallSite的实现:java.lang.invoke.ConstantCallSite
、java.lang.invoke.MutableCallSite
和 java.lang.invoke.VolatileCallSite
。
ConstantCallSite
表示的调用点绑定的是一个固定的方法句柄,一旦链接之后,就无法修改。示例如下:
public class Horse {
public void race() {
System.out.println("Horse.race()");
}
}
public static void constantCallSite() throws Throwable {
MethodType methodType = MethodType.methodType(void.class);
MethodHandles.Lookup lookup = MethodHandles.lookup();
Horse horse = new Horse();
MethodHandle methodHandle = lookup.findVirtual(horse.getClass(), "race", methodType);
ConstantCallSite callSite = new ConstantCallSite(methodHandle);
MethodHandle invoker = callSite.dynamicInvoker();
invoker.invoke(horse);
}
MutableCallSite
表示的调用点则允许在运行时动态修改其目标方法句柄,即可以重新链接到新的方法句柄上。示例如下:
public class Horse {
public static void say() {
System.out.println("say");
}
}
/** * MutableCallSite 允许对其所关联的目标方法句柄通过setTarget方法来进行修改。 * 以下为 创建一个 MutableCallSite,指定了方法句柄的类型,则设置的其他方法也必须是这种类型。 */
public static void useMutableCallSite() throws Throwable {
MethodType type = MethodType.methodType(void.class);
MutableCallSite callSite = new MutableCallSite(type);
MethodHandle invoker = callSite.dynamicInvoker();
MethodHandles.Lookup lookup = MethodHandles.lookup();
// MethodHandle horseMethodHandle = lookup.findVirtual(Horse.class, "race", type);
MethodHandle horseMethodHandle = lookup.findStatic(Horse.class,"say",type);
callSite.setTarget(horseMethodHandle);
invoker.invoke(new Horse());
MethodHandle minHandle = lookup.findStatic(Cobra.class, "race", type);
callSite.setTarget(minHandle);
invoker.invoke();
}
注意:如果使用 findVirtual 方法,得到的 MethodHandle 的 type 为 (Horse)void,与我们初始定义的 MethodType(值为()void)不一致,在 setTarget 方法中因为要对比前后两次的 type,所以会报下面这样的错误:
Exception in thread "main" java.lang.invoke.WrongMethodTypeException: MethodHandle(Horse)void should be of type ()void
MutableCallSite.syncAll()
提供了方法来强制要求各个线程中 MutableCallSite
的使用者立即获取最新的目标方法句柄。
但这个时候也可以选择使用 VolatileCallSite
。
VolatileCallSite
作用与 MutableCallSite 类似,不同的是它适用于多线程情况,用来保证对于目标方法句柄所做的修改能够被其他线程看到。
生成 invokedynamic指令
接下来我们构建这样一段代码,其中包括启动方法 bootstrap,它将接收前面提到的三个固定参数,并且返回一个链接至 Horse.race 方法的 ConstantCallSite。
public class Circuit {
public static void startRace(Object obj) {
}
public static void main(String[] args) {
startRace(new Horse2());
}
public static CallSite bootstrap(MethodHandles.Lookup l, String name, MethodType callSiteType)
throws Throwable {
MethodHandle mh = l.findVirtual(Horse2.class, name, MethodType.methodType(void.class));
return new ConstantCallSite(mh.asType(callSiteType));
}
}
class Horse2 {
public void race() {
System.out.println("Horse.race()");
}
}
invokedynamic 在 Java7 开始提出来,但是实际上 javac 并不支持生成 invokedynamic。接下来借助之前介绍过的字节码工具 ASM 来实现这一目的。
本次实验在 maven 项目中构建的,首先需要引入 a** 的依赖。
<dependency>
<groupId>org.ow2.a**</groupId>
<artifactId>a**</artifactId>
<version>9.2</version>
</dependency>
然后构建一个辅助类 ASMHelper
import java.io.IOException;
import java.lang.invoke.CallSite;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
import java.nio.file.Files;
import java.nio.file.Paths;
import org.objectweb.a**.ClassReader;
import org.objectweb.a**.ClassVisitor;
import org.objectweb.a**.ClassWriter;
import org.objectweb.a**.Handle;
import org.objectweb.a**.MethodVisitor;
import org.objectweb.a**.Opcodes;
public class ASMHelper implements Opcodes {
private static class MyMethodVisitor extends MethodVisitor {
private static final String BOOTSTRAP_CLASS_NAME = Circuit.class.getName().replace('.', '/');
private static final String BOOTSTRAP_METHOD_NAME = "bootstrap";
private static final String BOOTSTRAP_METHOD_DESC = MethodType
.methodType(CallSite.class, MethodHandles.Lookup.class, String.class, MethodType.class)
.toMethodDescriptorString();
private static final String TARGET_METHOD_NAME = "race";
private static final String TARGET_METHOD_DESC = "(Ljava/lang/Object;)V";
public final MethodVisitor mv;
public MyMethodVisitor(int api, MethodVisitor mv) {
super(api);
this.mv = mv;
}
@Override
public void visitCode() {
mv.visitCode();
mv.visitVarInsn(ALOAD, 0);
Handle h = new Handle(H_INVOKESTATIC, BOOTSTRAP_CLASS_NAME, BOOTSTRAP_METHOD_NAME, BOOTSTRAP_METHOD_DESC, false);
mv.visitInvokeDynamicInsn(TARGET_METHOD_NAME, TARGET_METHOD_DESC, h);
mv.visitInsn(RETURN);
mv.visitMaxs(1, 1);
mv.visitEnd();
}
}
public static void main(String[] args) throws IOException {
ClassReader cr = new ClassReader("Circuit");
ClassWriter cw = new ClassWriter(cr, ClassWriter.COMPUTE_FRAMES);
ClassVisitor cv = new ClassVisitor(ASM6, cw) {
@Override
public MethodVisitor visitMethod(int access, String name, String descriptor, String signature,
String[] exceptions) {
MethodVisitor visitor = super.visitMethod(access, name, descriptor, signature, exceptions);
if ("startRace".equals(name)) {
return new MyMethodVisitor(ASM6, visitor);
}
return visitor;
}
};
cr.accept(cv, ClassReader.SKIP_FRAMES);
Files.write(Paths.get("Circuit.class"), cw.toByteArray());
}
}
无需理解上面这段代码的具体含义(我不会,逃),然后在 terminal 端输入如下内容:
javac Circuit.java
javac -cp /usr/local/apache-maven-3.8.2/repository/org/ow2/a**/a**/9.2/a**-9.2.jar:. ASMHelper.java
java -cp /usr/local/apache-maven-3.8.2/repository/org/ow2/a**/a**/9.2/a**-9.2.jar:. ASMHelper
java Circuit
最后得到结果为:
Horse.race()
如果要复现上述的过程,需要注意这样几点:
1、Circuit 和 ASMHelper 都不需要留有包名;
2、a**-xx.jar 包因为是从 maven 仓库中引入的依赖,所以就去 maven 的 repository 包获取 jar 的绝对路径。
我们最后解析查看一下 Circuit 的字节码文件,这里只截取部分:
public static void startRace(java.lang.Object);
descriptor: (Ljava/lang/Object;)V
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokedynamic #65, 0 // InvokeDynamic #0:race:(Ljava/lang/Object;)V
6: return
到目前为止,我们已经可以通过 invokedynamic 调用 Horse2.race 方法了。
Java 8 的 Lambda 表达式
在 Java 8中,Javac 能够生成 invokedynamic 指令, 比如 lambda。
具体来说,Java 编译器利用 invokedynamic 指令来生成实现了函数式接口的适配器。这里的函数式接口指的是仅包括一个非 default 接口方法的接口,一般通过 @FunctionalInterface 注解。不过就算是没有使用该注解,Java 编译器也会将符合条件的接口辨认为函数式接口。
我们还是举个例子,来学习 lambda 表达式。
public class LambdaTest {
public static void main(String[] args) {
int num = 3;
IntStream.of(1, 2, 3).map(i -> i * 2).map(i -> i * num).map(LambdaTest::add);
}
public static int add(int num) {
return num + 3;
}
}
上面这段代码会对 IntStream 中的元素进行三次映射。我们查看源码可知,映射方法 map 所接收的参数是 IntUnaryOperator(这是一个函数式接口)。也就是说,在运行过程中我们需要将 i->i2 、 i->ix LambdaTest::add 这三个 Lambda 表达式转化成 IntUnaryOperator 的实例。这个转化过程便是由 invokedynamic 来实现的。可以在字节码文件中寻找到踪迹。
17: invokestatic #2 // InterfaceMethod java/util/stream/IntStream.of:([I)Ljava/util/stream/IntStream;
20: invokedynamic #3, 0 // InvokeDynamic #0:applyAsInt:()Ljava/util/function/IntUnaryOperator;
25: invokeinterface #4, 2 // InterfaceMethod java/util/stream/IntStream.map:(Ljava/util/function/IntUnaryOperator;)Ljava/util/stream/IntStream;
30: iload_1
31: invokedynamic #5, 0 // InvokeDynamic #1:applyAsInt:(I)Ljava/util/function/IntUnaryOperator;
36: invokeinterface #4, 2 // InterfaceMethod java/util/stream/IntStream.map:(Ljava/util/function/IntUnaryOperator;)Ljava/util/stream/IntStream;
41: invokedynamic #6, 0 // InvokeDynamic #2:applyAsInt:()Ljava/util/function/IntUnaryOperator;
46: invokeinterface #4, 2 // InterfaceMethod java/util/stream/IntStream.map:(Ljava/util/function/IntUnaryOperator;)Ljava/util/stream/IntStream;
51: pop
另外在编译过程中,Java 编译器会对 Lambda 表达式进行解语法糖(desugar),生成一个方法来保存 Lambda 表达式的内容。该方法的参数列表不仅包含原本 Lambda 表达式的参数,还包含它所捕获的变量。(注:方法引用,如 LambdaTest::add,则不会生成生成额外的方法。)
仔细观察可以发现,生成的方法参数列表不一致,第一个 Lambda 表达式没有捕获其他变量,而第二个 Lambda 表达式(也就是 i->i*x)则会捕获局部变量 x,所捕获的变量同样也会作为参数传入生成的方法之中。
bootstrap method
在 Oracle JDK 8 / OpenJDK 8的实现中,javac 在编译 Java源码的时候会看看一个 lambda表达式或 method reference 的目标 SAM(Single Abstract Method)类型是否是 Serializable 的,并为这个 invokedynamic 指令选择相应的 bootstrap method。
- 对普通的不可序列化SAM类型:选择 java.lang.invoke.LambdaMetafactory.metafactory() 作为bootstrap method;
- 对可序列化的SAM类型:选择 java.lang.invoke.LambdaMetafactory.altMetafactory() 作为bootstrap method。
关于 bootstrap method 的讲解可以参考本文。
我们重点看一下这句话“ lambda表达式或 method reference 的目标 SAM(Single Abstract Method)类型是否是 Serializable 的”,经过测试发现 lambda 表达式对应的 bootstrap method 使用的是 metafactory,那么什么时候会使用 altMetafactory 呢? SAM 类型是 Serializable 又是指的是什么呢?我尝试了很多代码,都没有得到想要的结果,最终在 R大的一篇回答中找到了想要的答案,这里我只简单提及一下,详细内容推荐仔细去阅读一下。
在 R大的回答中,里面的测试代码引入了一个新的依赖 org.apache.spark
,具体是用到了里面的 VoidFunction。
@FunctionalInterface
public interface VoidFunction<T> extends Serializable {
void call(T var1) throws Exception;
}
对比 Java Stream 中的函数式接口,可以发现有所不同,这里只列举两个示例。
@FunctionalInterface
public interface Function<T, R>
@FunctionalInterface
public interface IntUnaryOperator
有意思的事情是在 org.apache.spark
中也发现了 Function 接口:
@FunctionalInterface
public interface Function<T1, R> extends Serializable {
R call(T1 var1) throws Exception;
}
继承了序列化接口的函数式接口,应该就是上文提到的可序列化的 SAM 类型,最终 invokedynamic 指令选择altMetafactory 为bootstrap method。
回看上文图片,可以发现,每当第一次处理 invokedynamic 时,都会调用适当的引导方法,lambda 表达式选择的是 metafactory 方法。作为 boostrap 方法执行的结果,创建了一个 CallSite 对象。根据 Lambda 表达式是否捕获其他变量,启动方法生成的适配器类以及所链接的方法句柄皆不同。
我们可以通过虚拟机参数 -Djdk.internal.lambda.dumpProxyClasses=/DUMP/PATH 导出这些具体的适配器类。
具体 JVM 参数可以设置为:
-Djdk.internal.lambda.dumpProxyClasses=/Users/xxx/IdeaProjects/java_deep_learning/DUMP/PATH
执行代码可以得到三个 class 文件:
这三个 class 文件分别对应代码中的三个 lambda 表达式,比如说 i -> i * 2
对应 Lambda$1.class,文件内容如下:
final class LambdaTest$$Lambda$1 implements IntUnaryOperator {
private LambdaTest$$Lambda$1() {
}
@Hidden
public int applyAsInt(int var1) {
return LambdaTest.lambda$main$0(var1);
}
}
我们执行 javap -v -private LambdaTest\$\$Lambda\$1
命令解析 class 文件:
private com.msdn.java.hotspot.invokedynamic.LambdaTest$$Lambda$1();
descriptor: ()V
flags: (0x0002) ACC_PRIVATE
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #10 // Method java/lang/Object."<init>":()V
4: return
public int applyAsInt(int);
descriptor: (I)I
flags: (0x0001) ACC_PUBLIC
Code:
stack=1, locals=2, args_size=2
0: iload_1
1: invokestatic #18 // Method com/msdn/java/hotspot/invokedynamic/LambdaTest.lambda$main$0:(I)I
4: ireturn
可以看出,如果该 Lambda 表达式没有捕获其他变量,那么可以认为它是上下文无关的。因此,启动方法将新建一个适配器类的实例,并且生成一个特殊的方法句柄,始终返回该实例。
同理我们查看一下 i -> i * num
对应的 Lambda$2.class 文件:
final class LambdaTest$$Lambda$2 implements IntUnaryOperator {
private final int arg$1;
private LambdaTest$$Lambda$2(int var1) {
this.arg$1 = var1;
}
private static IntUnaryOperator get$Lambda(int var0) {
return new LambdaTest$$Lambda$2(var0);
}
@Hidden
public int applyAsInt(int var1) {
return LambdaTest.lambda$main$1(this.arg$1, var1);
}
}
可以看出,捕获了局部变量的 Lambda 表达式多出了一个 get$Lambda 的方法。启动方法便会所返回的调用点链接至指向该方法的方法句柄。也就是说,每次执行 invokedynamic 指令时,都会调用至这个方法中,并构造一个新的适配器类实例。
Lambda 的性能分析
通过下述代码来查看 Lambda 的性能。
//JDK9
public class LambdaPerformance {
public static void target(int i) {
}
public static void main(String[] args) {
long current = System.currentTimeMillis();
for (int i = 1; i <= 2_000_000_000; i++) {
if (i % 100_000_000 == 0) {
long temp = System.currentTimeMillis();
System.out.println(temp - current);
current = temp;
}
((IntConsumer) j -> LambdaPerformance.target(j)).accept(128);//v1
// ((IntConsumer) LambdaPerformance::target).accept(128);//v2
// LambdaPerformance.target(128); //v3
}
}
}
测量结果显示,它与直接调用的性能并无太大的区别。也就是说,JDK9 的即时编译器能够将转换 Lambda 表达式所使用的 invokedynamic,以及对 IntConsumer.accept 方法的调用统统内联进来,最终优化为空操作。
这个其实不难理解:Lambda 表达式所使用的 invokedynamic 将绑定一个 ConstantCallSite,其链接的目标方法无法改变。因此,即时编译器会将该目标方法直接内联进来。对于这类没有捕获变量的 Lambda 表达式而言,目标方法只完成了一个动作,便是加载缓存的适配器类常量。
另外查看适配器类中 v1 和 v2 两种代码生成的字节码文件,可知 accept 方法中其实包含了一个方法调用,调用至 Java 编译器在解 Lambda 语法糖时生成的方法。该方法的内容便是 Lambda 表达式的内容,也就是直接调用目标方法 LambdaPerformance.target。
//v1
@Hidden
public void accept(int var1) {
LambdaPerformance.lambda$main$0(var1);
}
//v2
@Hidden
public void accept(int var1) {
LambdaPerformance.target(var1);
}
方法内联其实就是调用 accept 时,直接调用对应的方法。
Lambda 表达式如果捕获变量,性能又将如何呢?
public class LambdaPerformance {
public static void target(int i) {
}
public static void main(String[] args) {
int x = 2;
long current = System.currentTimeMillis();
for (int i = 1; i <= 2_000_000_000; i++) {
if (i % 100_000_000 == 0) {
long temp = System.currentTimeMillis();
System.out.println(temp - current);
current = temp;
}
((IntConsumer) j -> LambdaPerformance.target(x + j)).accept(128); //v1
// LambdaPerformance.target(128 + x);//v2
}
}
}
v1 和 v2 的耗时相差不大。显然,即时编译器的逃逸分析又将该新建实例给优化掉了。我们可以通过虚拟机参数 -XX:-DoEscapeAnalysis 来关闭逃逸分析。果然,这时候测得的值约为直接调用的 3 倍。如果输出 GC 日志,可以发现会频繁的触发 GC。
尽管逃逸分析能够去除这些额外的新建实例开销,但是它也不是时时奏效。它需要同时满足两个条件:invokedynamic 指令所执行的方法句柄能够内联,和接下来的对 accept 方法的调用也能内联。
比如说下面这段代码就没法内联,可以打印出内联结果,发现 target 方法没有内联。
//-XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining
public class LambdaPerformance {
public static void target(int i) {
}
public static void main(String[] args) {
int x = 2;
for (int i = 1; i <= 20000; i++) {
((IntConsumer) j -> {
Integer xx = toInteger(String.valueOf(j));
LambdaPerformance.target(x + xx);
}).accept(128);
}
}
public static Integer toInteger(String value) {
return Integer.valueOf(value);
}
}
只有这样,逃逸分析才能判定该适配器实例不逃逸。否则,我们会在运行过程中不停地生成适配器类实例。所以,我们应当尽量使用非捕获的 Lambda 表达式。
JVM系列阅读
参考文献
Unrecognized VM option ‘PrintHeapAtGC’
[Java] 关于OpenJDK对Java 8 lambda表达式的运行时实现的查看方式
极客时间《深入拆解Java虚拟机》 郑雨迪