性能文章>JVM系列之:JVM是怎么实现invokedynamic的?>

JVM系列之:JVM是怎么实现invokedynamic的?原创

2年前
456419

invokedynamic 指令

千呼万唤始出来,上一篇文章介绍了那么久的方法句柄,终于来到 invokedynamic 指令讲解了。

invokedynamic 是 Java 7 引入的一条新指令,用以支持动态语言的方法调用。具体来说,它将调用点(CallSite)抽象成一个 Java 类,并且将原本由 Java 虚拟机控制的方法调用以及方法链接暴露给了应用程序。在运行过程中,每一条 invokedynamic 指令将**一个调用点,并且会调用该调用点所链接的方法句柄。

在第一次执行 invokedynamic 指令时,Java 虚拟机会调用该指令所对应的启动方法(BootStrap Method),来生成前面提到的调用点,并且将之绑定至该 invokedynamic 指令中。在之后的运行过程中,Java 虚拟机则会直接调用绑定的调用点所链接的方法句柄。

invokedynamic 的调用模式简单说就是:

  1. When JVM sees an invokedynamic instruction, it locates the corresponding bootstrap method in the class, and executes the bootstrap method.
  2. After executing the bootstrap method, a CallSite that is linked with a MethodHandle is returned;
  3. 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.ConstantCallSitejava.lang.invoke.MutableCallSitejava.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。

关于 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系列阅读

JVM系列之:你真的了解垃圾回收吗

JVM系列之:JVM是如何创建对象的

JVM系列之:JVM是怎么实现invokedynamic的?

JVM系列之:关于方法句柄的那些事

JVM系列之:关于HSDB的一点心得

JVM系列之:JVM是如何实现反射的

JVM系列之:关于JVM类加载的那些事

JVM系列之:聊一聊Java异常

JVM系列之:JVM如何执行方法调用

JVM系列之:聊聊Java的数据类型

JVM系列之:宏观分析Java代码是如何执行的

参考文献

Unrecognized VM option ‘PrintHeapAtGC’

[Java] 关于OpenJDK对Java 8 lambda表达式的运行时实现的查看方式

极客时间《深入拆解Java虚拟机》 郑雨迪

点赞收藏
hresh

会写代码的厨艺爱好者

请先登录,查看1条精彩评论吧
快去登录吧,你将获得
  • 浏览更多精彩评论
  • 和开发者讨论交流,共同进步

为你推荐

随机一门技术分享之Netty

随机一门技术分享之Netty

MappedByteBuffer VS FileChannel:从内核层面对比两者的性能差异

MappedByteBuffer VS FileChannel:从内核层面对比两者的性能差异

9
1