hao同学的技术博客

  • 首页
  • Java
    • Java
    • JVM教程
    • Java面试
    • Java并发入门
    • Java并发进阶
  • 项目
    • 从零打造项目
  • Python
    • Python
    • Python爬虫
    • 算法
  • Java框架
    • Spring
    • SpringBoot
  • 前端
    • Angular
  • 其他
    • Linux
    • SQL
  • 随笔
分享技术,记录人生
一个痴迷于技术的厨艺爱好者
  1. 首页
  2. JVM教程
  3. 正文

JVM是怎么实现invokedynamic的?

2022年5月8日 159点热度 0人点赞 0条评论

JVM是怎么实现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.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 项目中构建的,首先需要引入 asm 的依赖。

<dependency>
  <groupId>org.ow2.asm</groupId>
  <artifactId>asm</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.asm.ClassReader;
import org.objectweb.asm.ClassVisitor;
import org.objectweb.asm.ClassWriter;
import org.objectweb.asm.Handle;
import org.objectweb.asm.MethodVisitor;
import org.objectweb.asm.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/asm/asm/9.2/asm-9.2.jar:. ASMHelper.java
java -cp /usr/local/apache-maven-3.8.2/repository/org/ow2/asm/asm/9.2/asm-9.2.jar:. ASMHelper
java Circuit

最后得到结果为:

Horse.race()

如果要复现上述的过程,需要注意这样几点:

1、Circuit 和 ASMHelper 都不需要留有包名;

2、asm-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 表达式没有捕获其他变量,而第二个 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 文件:

dumpProxyClasses生成的class文件

这三个 class 文件分别对应代码中的三个 lambda 表达式,比如说 i -> i * 2 对应 Lambda$1.class,文件内容如下:

final class LambdaTest$Lambda1 implements IntUnaryOperator {
  private LambdaTest$Lambda1() {
  }

  @Hidden
  public int applyAsInt(int var1) {
    return LambdaTest.lambdamain0(var1);
  }
}

我们执行 javap -v -private LambdaTest\$\$Lambda\$1 命令解析 class 文件:

  private com.msdn.java.hotspot.invokedynamic.LambdaTest$Lambda1();
    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.lambdamain0:(I)I
         4: ireturn

可以看出,如果该 Lambda 表达式没有捕获其他变量,那么可以认为它是上下文无关的。因此,启动方法将新建一个适配器类的实例,并且生成一个特殊的方法句柄,始终返回该实例。

同理我们查看一下 i -> i * num 对应的 Lambda$2.class 文件:

final class LambdaTest$Lambda2 implements IntUnaryOperator {
  private final int arg1;

  private LambdaTestLambda2(int var1) {
    this.arg1 = var1;
  }

  private static IntUnaryOperator getLambda(int var0) {
    return new LambdaTest$Lambda2(var0);
  }

  @Hidden
  public int applyAsInt(int var1) {
    return LambdaTest.lambdamain1(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.lambdamain0(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 表达式。

参考文献

Unrecognized VM option 'PrintHeapAtGC'

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

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

本作品采用 知识共享署名-非商业性使用 4.0 国际许可协议 进行许可
标签: Java JVM
最后更新:2022年5月8日

hresh

这是一个专注于IT技术学习交流的个人技术博客网站,包括Java学习、Python爬虫、Web开发实践等领域,深耕Java领域,内容涵盖Java基础、Java并发编程、Java虚拟机、Java面试等核心知识点。

点赞
< 上一篇
下一篇 >

文章评论

取消回复

hresh

这是一个专注于IT技术学习交流的个人技术博客网站,包括Java学习、Python爬虫、Web开发实践等领域,深耕Java领域,内容涵盖Java基础、Java并发编程、Java虚拟机、Java面试等核心知识点。

文章目录
  • CallSite
  • 生成 invokedynamic指令
  • Java 8 的 Lambda 表达式
    • bootstrap method
    • Lambda 的性能分析
  • 参考文献
最新 热点 随机
最新 热点 随机
后端必知:遵循Google Java规范并引入checkstyle检查 Spring Security结合Redis实现缓存功能 Spring Security结合JWT实现认证与授权 Spring Security自定义认证逻辑实现图片验证码登录 Spring Security进阶学习 Spring Security入门学习
Java面试准备之数据库基础 Python 计算列表数值乘积结果末尾 0 的个数 Spring IoC之AbstractBeanFactory Python 实现 AES 加密/解密 Python3 和 Python2 区别总结 因接触partition by而对PostgreSQL explain有了一个小小的认识

COPYRIGHT © 2022 hao同学的技术博客. ALL RIGHTS RESERVED.

Theme Kratos Made By Seaton Jiang

鄂ICP备2022007381号

鄂公网安备 42010302002449号