写自己的JVM(0x7)-invokedynamic指令

原始博客的地址:

lyldalek.notion.site/JVM-0x7-fa2...

该项目的地址:

github.com/aprz512/wri...

上一篇我们实现了方法调用以及参数传递,主要讨论了四个invoke指令。但是有一条指令我们没有讨论,那就是在JDK 7里面新增的invokedynamic指令。

这条新增加指令的目标:实现动态类型语言(Dynamically Typed Language)支持而进行的改进之一,也是为JDK 8里可以顺利实现Lambda表达式而做的技术储备。

动态类型语言

我们先简单的讨论一下动态类型语言是什么?看一个例子:

go 复制代码
obj.println("hello world");

现在先假设这行代码是在Java语言中,并且变量obj的静态类型为java.io.PrintStream,那变量obj的实际类型就必须是PrintStream的子类(实现了PrintStream接口的类)才是合法的。否则,哪怕obj属于一个确实包含有println(String)方法相同签名方法的类型,但只要它与PrintStream接口没有继承关系,代码依然不可能运行------因为类型检查不合法。

但是相同的代码在ECMAScript(JavaScript)中情况则不一样,无论obj具体是何种类型,无论其继承关系如何,只要这种类型的方法定义中确实包含有println(String)方法,能够找到相同签名的方法,调用便可成功。

产生这种差别产生的根本原因是Java语言在编译期间却已将println(String)方法完整的符号引用(本例中为一项CONSTANT_InterfaceMethodref_info常量)生成出来,并作为方法调用指令的参数存储到Class文件中,例如下面这个样子:

less 复制代码
invokevirtual #4; //Method java/io/PrintStream.println:(Ljava/lang/String;)V

这个符号引用包含了该方法定义在哪个具体类型之中、方法的名字以及参数顺序、参数类型和方法返回值等信息,通过这个符号引用,Java虚拟机就可以翻译出该方法的直接引用。

而ECMAScript等动态类型语言与Java有一个核心的差异就是变量obj本身并没有类型,变量obj的值才具有类型,所以编译器在编译时最多只能确定方法名称、参数、返回值这些信息,而不会去确定方法所在的具体类型。

MethodHandle

JDK 7新加入了一个java.lang.invoke包,这个包的主要目的是在之前单纯依靠符号引用来确定调用的目标方法这条路之外,提供一种新的动态确定目标方法的机制,称为"方法句柄"(Method Handle)。

举个例子,如果我们要实现一个排序函数,在C/C++中的常用做法是用函数指针,像这样:

arduino 复制代码
void sort(int list[], const int size, int (*compare)(int, int))

但在Java语言中做不到这一点,没有办法单独把一个函数作为参数进行传递(但是 kotlin 可以做到,kotlin 也可以运行在JVM上,那么它是如何运行起来的呢?)。普遍的做法是设计一个带有compare()方法的Comparator接口,以实现这个接口的对象作为参数,例如Java类库中的Collections::sort()方法就是这样定义的:

java 复制代码
void sort(List list, Comparator c)

有了 MethodHandle 之后,我们就可以拥有类似于函数指针这样的工具了。看一个例子:

arduino 复制代码
public class MethodHandleTest {

    public static void main(String[] args) throws Throwable {
        Object obj = /*System.currentTimeMillis() % 2 == 0 ? System.out :*/ new ClassA();
        // 无论obj最终是哪个实现类,下面这句都能正确调用到println方法。
        getPrintlnMH(obj).invokeExact("icyfenix");
    }

    private static MethodHandle getPrintlnMH(Object reveiver) throws Throwable {
        // MethodType:代表"方法类型",包含了方法的返回值(methodType()的第一个参数)和具体参数(methodType()第二个及以后的参数)。
        MethodType mt = MethodType.methodType(void.class, String.class);
        // lookup()方法来自于MethodHandles.lookup,这句的作用是在指定类中查找符合给定的方法名称、方法类型,并且符合调用权限的方法句柄。
        // 因为这里调用的是一个虚方法,按照Java语言的规则,方法第一个参数是隐式的,代表该方法的接收者,也即this指向的对象,
        // 这个参数以前是放在参数列表中进行传递,现在提供了bindTo()方法来完成这件事情。
        return lookup().findVirtual(reveiver.getClass(), "println", mt).bindTo(reveiver);
    }

    static class ClassA {

        public void println(String s) {
            System.out.println(s);
        }
        
    }

}

执行 main 方法,可以获得输出:

复制代码
icyfenix

MethodHandle看起来和反射很像,但是实际上它比反射要轻量级一些。因为Reflection是在模拟Java代码层次的方法调用,而MethodHandle是在模拟字节码层次的方法调用。有兴趣的可以去看一些原理分析:

zhuanlan.zhihu.com/p/524591401
www.jianshu.com/p/0cf867a23...

简单理解就是,针对 MethodHandleinvoke 方法,会有特殊的逻辑,最终会模拟出对象上方法调用的效果。

invokedynamic

由于invokedynamic指令面向的主要服务对象并非Java语言,而是其他Java虚拟机之上的其他动态类型语言,因此,光靠Java语言的编译器Javac的话,在JDK 7时甚至还完全没有办法生成带有invokedynamic指令的字节码(曾经有一个java.dyn.InvokeDynamic的语法糖可以实现,但后来被取消了),而到JDK 8引入了Lambda表达式和接口默认方法后,Java语言才算享受到了一点invokedynamic指令的好处。所以我们下面就以 lambda举例,但是可能会比较别扭,因为 lambda 的例子基本与 MethodHandle 看不出来啥关系。但是从某种意义上来说invokedynamic指令与MethodHandle机制的作用是一样的。

看下面的例子,这个例子可以体会一下 invokedynmic 的神奇之处:

ini 复制代码
public static void main(String[] args) throws Throwable {
        long c = System.currentTimeMillis();
        final long b = c;
        Runnable runnable = () -> {
            long x = 10;
            long a = b + 20 * x;
        };
        runnable.run();
    }

通常,我们会认为 runnable 赋值的那一行代码是匿名内部类的一个语法糖写法。但是实际上,我们打开字节码看一看会发现并不是:

可以看到上面并没有匿名内部类相关的字节码。那么 runnable 对象的 run 方法是如何被执行的呢?

在第8个字节的位置,出现了一个 invokedynamic 指令。我们看一下官方文档:

可以看到,它后面跟了4个字节的操作数,前两个字节组成常量池的索引,后两个字节永远为0。

常量池索引指向的是一个 CONSTANT_InvokeDynamic_info 类型的常量。而这个常量里面又指向了 BootstrapMethods的索引:

BootstrapMethods 是类的一个属性:

可以看到,这个BootstrapMethods 属性里面储存了一个 Bootstrap 方法的列表。它指向了 lambda����0 方法,但是我们写的代码中并没有这个方法呀?这是因为这个方法是动态生成的。

我们dump一下生成的 lambda 类,在测试类上添加如下代码:

arduino 复制代码
  static {
        System.setProperty("jdk.internal.lambda.dumpProxyClasses", "./DUMP_CLASS_FILEs");
    }

重新运行 main 方法,发现生成的类如下:

ruby 复制代码
package write.your.own.jvm.test;

import java.lang.invoke.LambdaForm.Hidden;

// $FF: synthetic class
final class InvokeDynamicTest$$Lambda$1 implements Runnable {
    private final long arg$1;

    private InvokeDynamicTest$$Lambda$1(long var1) {
        this.arg$1 = var1;
    }

    private static Runnable get$Lambda(long var0) {
        return new InvokeDynamicTest$$Lambda$1(var0);
    }

    @Hidden
    public void run() {
        InvokeDynamicTest.lambda$main$0(this.arg$1);
    }
}

看到这个类,我们就知道,invokedynamic 指令会返回这个InvokeDynamicTest$$Lambda$1对象,然后 invokeinterface 也是执行的这个对象的方法。打断点发现我们的猜测是对的:

至于,invokedynamic 是如何获取到InvokeDynamicTest$$Lambda$1 对象的,就不深入了。从 BootStrap里面的描述来看,它也是使用了MethodHandle 来做的。

实现 invokedynamic 指令

我们已经对 invokedynamic 指令有了一定的了解,现在我们需要实现该指令,以便让我们的JVM支持一种很简单的 lambda 表达式写法:

arduino 复制代码
  public static void main(String[] args) throws Throwable {
        PTest t = (a, b1) -> {
            System.out.println(b1);
            return "null";
        };
        t.test(1, "te");
    }

    interface PTest {
        String test(int a, String b);
    }

我们需要让上面的代码能够正常运行,这个 lambda 表达式非常的简单,写起来并不难。虽然也可以支持更加复杂的行为,比如外部参数使用等等,但是逻辑写起来就过于复杂,不利于学习了。

首先,我们看看 invokedynamic 指令结构:

它的操作数分为两部分,第一部分是指向常量池的索引,第二部分固定为0。代码解析如下:

java 复制代码
  @Override
    public int readOperand1(CodeReader reader) {
        return reader.readUnsignedShort();
    }

    @Override
    public int readOperand2(CodeReader reader) {
        return reader.readShort();
    }

索引对应的常量池的类型是一个 CONSTANT_InvokeDynamic 类型:

它的结构体里面储存了2个信息,第一个是 lambda 表达式实现的接口方法名和类型。第二个是一个 BootstrapMethods 的索引。

每一个动态调用点(暂时简单理解为 lambda 表达式),都对应着一个 BootstrpMethod:

如果我在程序中写两个 lambda 表达式,BootstrapMethods 就会有两项。BootstrapMethod 的 参数里面的第二项就储存了它真正需要调用的方法的描述信息。有了它,我们就可以实现 lambda 表达式。

思路就是:

  1. 创建一个 MyClass 对象 LambdaClass,给它生成一个规则类名。
  2. 找到真正要调用的方法,将这个方法 copy 一份,放到 LamdaClass 里面并与之关联起来。
  3. 执行 invokedynamic 指令时,返回一个 LambdaClass 类创建的对象。
  4. 完成,后面的流程,就是 invokeinterface,由于我们给类塞了一个方法,所以会自动的寻找到我们塞的方法并执行。

具体逻辑如下:

ini 复制代码
  @Override
    public void execute(StackFrame frame) {
        // 只是简单的支持 lambda 表达式
        MyClass myClass = frame.getMyMethod().getMyClass();
        ConstantPool constantPool = myClass.getConstantPool();

        // invokeDynamic
        ConstantPool.Constant dynamicConstant = constantPool.getConstant(op1);
        InvokeDynamicConstant invokeDynamic = (InvokeDynamicConstant) dynamicConstant.value;

        String[] nameAndType = invokeDynamic.getNameAndType();
        String lambdaInterfaceName = nameAndType[1].replace("()L", "").replace(";", "");

        BootstrapMethodsAttribute bootstrapMethodsAttribute = myClass.getBootstrapMethodsAttribute();
        BootstrapMethodsAttribute.BootstrapMethod[] bootstrapMethods = bootstrapMethodsAttribute.getBootstrapMethods();
        BootstrapMethodsAttribute.BootstrapMethod bootstrapMethod = bootstrapMethods[invokeDynamic.getBootstrapMethodAttrIndex()];

        // 接口描述
        String descriptor = bootstrapMethod.getMethodTypeDescriptor();

        // 方法在常量池中的引用
        int methodHandleRef = bootstrapMethod.getMethodHandleRef();
        ConstantPool.Constant constant = constantPool.getConstant(methodHandleRef);

        // method to be executed
        MethodRef ref = (MethodRef) constant.value;
        MyMethod resolvedMethod = ref.getResolvedMethod();

        // 可以创建一个虚拟的 lambda 类,注意常量池的拷贝等
        MyClass lambdaClass = MyClass.createLambdaClass(
                myClass,
                myClass.getThisClassName(),
                lambdaInterfaceName,
                frame.getMyMethod().getName(),
                op1,
                resolvedMethod);
        MyObject myObject = lambdaClass.newObject();

        frame.getOperandStack().pushRef(myObject);
    }

题外话,这样的写法是非常取巧的,因为我们真正执行的是一个编译器生成的 static 的方法,而 static 的方法在参数传递的时候会少一个 this 参数,但是在 invokeinterface 的时候是需要传递 this 的,所以会导致操作数栈各种问题。

我们没有实现自己的前端编译器,所以没法生成一个 class 文件,从而只能借用现有的信息做一些hack工作,最后达到目的。

最终的目的是让自己熟悉一下 invokedymic 指令,以及 lambda 表达式的奥秘。

测试

配置:

输出:

完美!!!

相关推荐
mldong2 小时前
你的 Vue3 项目也能有钉钉同款审批流设计器:npm 装包,10 分钟画出第一条审批流
前端·vue.js
2分钟速写快排3 小时前
什么是 RAG?如何用 RAG 实现一个用户记忆?
前端·后端·ai编程
passerby60614 小时前
如何自己造一个时间处理库
前端·javascript·github
走到天涯海角5 小时前
react里面的长列表渲染优化
前端·react.js·前端框架
小羊没烦恼!5 小时前
Hello Web API系列教程——Web API与国际化
java·服务器·前端·javascript·php
北岛贰5 小时前
迷茫焦虑期,我做了一个带支付带官网的 AI 聊天虚拟恋人 App
前端·人工智能·后端
mayaairi6 小时前
Vue2 组件通讯(三):全局事件总线、PubSub、插槽与组件实例属性
前端·javascript·vue.js
kyriewen7 小时前
面试官问我:AI 都能写代码了,前端凭什么还值 25K
前端·javascript·人工智能
风骏时光牛马8 小时前
AI源码分析:拆解模型底层实现逻辑
前端
IT_陈寒8 小时前
React子组件莫名其妙重渲染?你可能漏了这个Hook
前端·人工智能·后端