欢迎光临散文网 会员登陆 & 注册

Java ASM详解:MethodVisitor与Opcode(五)invokedynamic、方法句柄、lambda

2021-12-02 16:01 作者:Nickid2018  | 我要投稿

前四篇专栏已经简要的描述了常用的字节码,这篇专栏将讲述Java 7以来最重要的字节码之一:invokedynamic

一.方法句柄(Method Handle)

方法句柄在Java 7时被引入,位于java.lang.invoke包下。它类似于反射,但与反射不同的是,它的检查在创建阶段就已经结束,而反射需要每次运行时检查,所以在理论上方法引用更快。

1. 方法类型(Method Type)

方法句柄包含了一个方法的信息——所在的类、名称、参数列表与返回值。为了描述参数列表与返回值,Java引入了一个类——即MethodType,来描述它们。

它类似于反射使用的getMethod方法,但是它不仅需要参数列表,它还需要返回值。创建一个MethodType可以使用下面的方法:

这些方法中的rtype参数都代表了返回值类型,ptypes代表了参数类型。

下面是一个例子:使用MethodType描述Arrays::binarySearch(Object[], int, int, Object) -> int

2. 从已有的方法中提取方法句柄

为了获取一个方法句柄,最简单的途径就是从一个现成的方法中提取。为了从一个现成的类中提取一个方法句柄,我们需要一个MethodHandles.Lookup对象,这个对象有两种获取方法:

Lookup类提供了以下方法用于查找方法句柄对象:

可以看到,这些find方法都实现了某个字节码的功能:findStatic与invokestatic进行对应、findGetter与getfield对应等。

除了查找方法,从一个反射对象反反射也能获得方法句柄对象:

下面是使用例:

1) 获取System::currentTimeMillis() -> long的方法句柄

2) 获取获得System.out(java.io.PrintStream)字段的方法句柄

3) 获取String::<init>()的方法句柄

4) 获取访问sun.misc.Unsafe.theUnsafe(Unsafe)字段的方法句柄

3. 自定义方法句柄

方法句柄不止可以通过查找获取,还可以通过MethodHandles内置的一些方法获取,下面是一部分内置的方法句柄生成器:

这些生成器不止包括了基本的创建对象与对象操作,还实现了一部分流程结构,也就是说你可以通过MethodHandles“动态”地创建一个方法片段。

4. 使用方法句柄

说了这么多创建方法句柄的方式,我们该怎么使用它呢?MethodHandle提供了两个方法用于执行方法句柄:

这两种调用方式的区别在于参数的类型转换:invokeExact要求参数必须准确对应MethodType定义的参数,而invoke会进行自动转换来尝试对应。

如果无法对应参数,这两个方法都会抛出WrongMethodTypeException。

下面给出一个例子,使用上面2.1创建的MethodHandle:

有些情况下,我们不需要第一个参数变化(实例方法的调用对象/静态方法的第一个参数),这时我们可以用bindTo绑定第一个参数:

下面是使用例:

说完了方法句柄,接下来来看看CallSite。

二. 动态调用点(CallSite)

CallSite是一个为了引导invokedynamic字节码指向调用方法的类,通过它的dynamicInvoker方法可以获取一个方法句柄,这个句柄就代表了inDy的目标。

非常量动态调用点允许重新指定调用目标,这时inDy会对目标进行重新连接。

它有三个子类:ConstantCallSite、MutableCallSite和VolatileCallSite。它们的区别如下:

  • ConstantCallSite指向的方法句柄不能修改,也就是永久性的。连接到它的inDy指令会永远绑定这个方法句柄。

  • MutableCallSite允许修改指向的方法句柄目标,指向目标的行为类似普通字段。它的目标改变是不同步的——当调用目标被另一个线程修改,现在的线程不一定能同步到更新的值。为了强制同步,可以使用MutableCallSite::syncAll。连接到它的inDy指令每次调用都会调用它当前的方法句柄目标。

  • VolatileCallSite类似MutableCallSite,其指向的目标可以修改。它的行为类似volatile字段,另一个线程修改指向目标会立刻反应到现在的线程,因此不需要syncAll之类的方法保持同步。volatile会造成不可避免的性能损失,所以如果不涉及线程问题最好用MutableCallSite。

下面演示了常量动态调用点的使用方法(此处不涉及inDy):


三. 引导方法(BootStrap Method,简称BSM)

在Java类执行中,少不了“动态”的东西。这些动态的东西分为两类:一种是动态计算调用点,一种是动态计算常量。引导方法就是为了它们产生的。

  • 动态计算常量,由ConstantDynamic表示。它们在JVM使用它们之前被解析,解析时调用的就是它内部的引导方法和它们内置的引导方法参数。

  • 动态计算调用点,也就是inDy的实现。inDy的目标在第一次调用它之前解析调用获得CallSite。

引导方法的声明有一定规则,和它们的使用方式有关:

  • 如果引导方法用于动态计算常量,则引导方法的前三个参数分别是MethodHandles.Lookup、String、Class对象,分别代表了调用方、名称和常量类型,后面的参数是其他静态参数,返回值需要与Class对象代表的类型保持一致(或者写为Object,只需要运行时返回值可以被强制转换到指定类型就可以)。

  • 如果引导方法用于动态计算调用点,则引导方法的前三个参数分别是MethodHandles.Lookup、String、MethodType对象,分别代表调用方、名称和调用点方法类型,后面的参数是其他静态参数,它的返回值要求是CallSite(通常是ConstantCallSite,当然写成Object也可以,只要保证能被强制类型转换成CallSite就不报错)

下面是一些正确的用于动态计算调用点的引导方法声明:

注意:静态参数允许了动态计算常量传入。

四. invokedynamic字节码

经过前面一系列的铺垫,终于我们要讲inDy该怎么写入了。

写入inDy字节码需要使用MethodVisitor的方法,visitInvokeDynamicInsn:

它的四个参数分别是名称、方法描述符、引导函数的句柄和传入引导方法的静态参数。名称和描述符都分别对应了引导方法的参数:name(第二个参数)、type(第三个参数)。

这里面的Handle句柄不等于MethodHandle方法句柄,但是它们也是紧密相关的,它的定义如下:

可以看到这里的参数和visitMethodInsn的参数基本一样。第一个参数是调用标签,分为9个,它们与方法句柄差不多:

  • H_GETFIELD,对应findGetter,字节码getfield,要求isInterface是false

  • H_GETSTATIC,对应findStaticGetter,字节码getstatic,要求isInterface是false

  • H_PUTFIELD,对应findSetter,字节码putfield,要求isInterface是false

  • H_PUTSTATIC,对应findStaticSetter,字节码putstatic,要求isInterface是false

  • H_INVOKEVIRTUAL,对应findVirtual,字节码invokevirtual

  • H_INVOKESTATIC,对应findStatic,字节码invokestatic

  • H_INVOKESPECIAL,对应findSpecial,字节码invokespecial

  • H_NEWINVOKESPECIAL,对应findConstuctor,字节码invokespecial

  • H_INVOKEINTERFACE,对应findVirtual,字节码invokeinterface,isInterface是true

下面是个例子,将Arrays::binarySearch(Object[], int, int, Object) -> int用Handle表述:

那么inDy对操作栈做了什么?这就和它的第二个参数,descriptor有关系了。

之前说过,BSM会传入一个MethodType,而这个MethodType是用于描述返回动态调用点目标句柄的。又由于descriptor在字节码中最终会解释成为MethodType,所以能得出一个结论:descriptor决定了BSM返回CallSite内部方法句柄的类型。

而inDy在JVM的操作正是通过CallSite获取dynamicInvoker进行调用——也就是说,inDy相当于间接调用了一个类型为descriptor的方法。这样我们就不难理解inDy对操作栈干了什么:弹出descriptor指定的一部分参数并压回规定的返回值。

JVM调用BSM的逻辑可以在java.lang.invoke.BootstrapMethodInvoker找到。

使用inDy字节码还需要一步操作:你需要让你的类访问MethodHandles.Lookup,因此你需要在类声明时加入一个visitInnerClassInsn(其实不加也不会报错,但是最好加上):

下面,是Java中invokedynamic的用法详解:

五. lambda表达式

匿名函数表达式,简称lambda表达式,它在Java 8被加入。它简化了一部分的匿名类,让代码更加简洁。

为了展示它的用法和字节码表示,我们先定义一个接口和一个方法:

接着,我们使用这个方法:

这时,后面的“() -> "hello"”被解析成了一个StringSupplier的实现类对象。但是,在字节码中无法自动去生成一个这样的类用于适配它。于是,javac在此处写入了inDy字节码要求动态生成。

动态生成lambda调用点的引导方法位于java.lang.invoke.LambdaMetafactory:

通常情况下,javac生成的lambda都是通过第一个BSM的,这6个参数的意义分别是:

  • caller,由JVM提供的查找对象,lambda会使用这个进行动态类创建

  • interfaceMethodName,lambda实现接口内部需要实现的方法名称

  • factoryType,要求BSM返回CallSite内部指向方法句柄的方法类型

  • interfaceMethodType,lambda实现接口内需要实现方法的类型

  • implementation,实现lambda内部代码功能的方法句柄

  • dynamicMethodType,实现lambda内部代码功能方法的类型,和interfaceMethodType相同或者是它的更具体的类型

可以看到,为了提供lambda的功能,javac会让inDy字节码连接到另一个方法上去。这种方法不需要我们自己写,它是编译时自动生成的,名称是“lambda$方法名$序号”。上例中,javac动态生成的lambda方法如下:

这些方法都带有private和synthetic的访问标志,是否拥有static访问标志取决于lambda在的方法是否静态和是否使用this对象。

接下来,我们使用这个方法连接到LambdaMetafactory:

metafactory通过这些参数可以动态创建一个类实现指定的接口获得实现接口的对象。具体而言,它通过asm库(java内置了asm库)在现在的类中动态的生成了内部类,类的名称是“$Lambda$序号”(但是在getClass()获取时名称不是这个,因为这个类被“隐藏”定义,会带上另一个编号)。对于这个例子,生成的内部类像下面这样:

对于这种lambda表达式,生成的类对象永远不变,所以JVM对此进行优化——这种lambda只会生成一个实例,返回的CallSite其实只是返回一个常量(详情可见InnerClassLambdaMetafactory)。

说回到字节码的写入。之前说过visitInvokeDynamicInsn和BSM的参数一一对应,所以我们可以这样写入:

除了这种lambda外,还有另一种lambda:它们需要局部变量传入内部。这些局部变量有要求——它们无法被修改,或者叫“等效终态”。下面是一个例子:

由于传入了局部变量,lambda的实现方法就需要多加一个参数用于传递这个变量。下面是javac生成的lambda代理实现方法:

但是这个str要怎么透过inDy字节码进行传入?JVM为了解决这个问题,在动态生成的委托类上做了一些操作:让传入的变量先用构造函数存储在字段里,在调用时取出字段值:

但是,这种lambda的CallSite不能返回一个常量——因为我们不能保证局部变量是同一个值!因此,这个CallSite内部指向了动态生成内部类的构造函数。

接下来,我们用字节码写入一下:

六. 方法引用

当lambda内只有一行方法调用时,在特定条件下可以简写为方法引用。它分为不同的类型:

1. 静态调用

当方法引用指向一个类中的静态方法时,就是静态调用,类似于:

它的实现类似于lambda,但是不同的是,javac编译时不会生成一个新的方法用于lambda定位,而是选择直接指向这个方法:

2. 对象调用

当方法引用的目标不是静态的,它就需要使用一个对象用于方法的调用,下面是个例子:

这类似于将局部变量传入了lambda内部,因此这里的inDy字节码是这样写的:

对象调用的对象没有特殊要求,只需要能获得这个局部变量就可以。方法引用的目标可以是实例方法,也可以是抽象方法(区别在于Handle的标签)。

由于JVM不能保证传入的局部变量是非空的(例外就是上面的情况:直接新建对象),所以在传入lambda之前,JVM会进行requireNonNull进行检查也就是说,下面这两种方式等价:

上面的方法引用版本的代码可以写为:

除了这两种方式外,我们还能使用超类的实例方法,现在假设Test继承于SuperTest,有一个superTest方法这时javac不会直接引用超类方法,而是生成lambda实现方法在内部调用invokespecial。下面是使用例:

javac生成的lambda实现方法是这样的:

接下来的代码省略(因为和上面一样)。

最后,还有一种对象调用:lambda内部传入了一个对象,我们可以通过这个对象进行调用。这个调用方式和静态调用差不多,只不过Handle的标签是H_INVOKEVIRTUAL,这个也不举例了。

3. 构造函数调用

方法引用允许传递构造函数,下面使用了String的无参构造函数传入test内部:

这时,传入的方法句柄是构造函数,对应了Handle中的H_NEWINVOKESPECIAL:

4. 数组构造调用

除了普通的构造函数,数组也可以通过方法引用创建。它只需要一个int作为参数,因此它实现的方法必须只有一个int形参:

这种方法引用也不是直接指向构造函数的,还是javac生成lambda实现方法并引用的:

到此,所有方法引用的写入方式就都介绍完了。

七. 字符串连接

在学习Java的时候,我们就知道Java的String允许用+进行连接。但是,Java没有符号重载,那么字符串是怎么打破这个限制的呢?答案就是javac编译时做了一些“操作”。

接下来,我们使用这个例子:

在Java 8,字符串的连接被自动识别为StringBuilder的链式调用,那么上面的这句话在javac编译之后就变成了这样:

字节码写入如下:

但是这种方式有两个缺点:一是会生成大量的字节码片段,使类文件膨胀;二是这种调用每次都会生成StringBuilder对象,性能会损失一部分。

所以,从Java 9开始,字符串连接使用inDy字节码动态调用。它使用的引导方法位于StringConcatFactory。

makeConcat是makeConcatWithConstants的简化版本,如果没有常量,就用第一个方法,但是javac编译时通常使用第二个方法,所以我们对它进行讲解。

首先说说方法参数的意义:

  • lookup,由JVM提供的查找对象

  • name,名称,和最后的连接效果没有任何关系,只要不是null都能传入。程序写入常用“makeConcatWithConstants”

  • concatType,生成CallSite的签名,返回值需要是String,参数列表要和字符串中的变量的数量、类型和位置保持一致

  • recipe,用于连接字符串的模板,只有两种字符:\u0001代表了这里应该写入变量,\u0002代表这里应该写入常量。\u0001的数量、位置需要和变量保持一致;\u0002的数量、位置要与常量保持一致

  • constants,字符串中的常量部分,数量和\u0002一致,可以不是String。

它的原理比lambda要简单——它是动态生成了一个MethodHandle存储到CallSite中,因此在执行一次BSM之后它就成为了常量。

现在我们用它写入字节码:

但是你可能有一个疑问:如果我字符串里面本身有\u0001或者\u0002不就出错了吗?JVM考虑了这个情况,它的解决方案是——提取这一段字符串为常量放到后面。例如下面这个字符串:

它的写入是:

八. 模式匹配

在Java 17,模式匹配进行了预览。下面就是它的使用例:

模式匹配之间必须使用break,否则会被提示为非法。

使用增强型switch可以写成下面形式:

它的写入和其他switch不同,它使用了inDy用于获得序号,再用这个序号进行lookupswitch。

它使用的引导方法位于java.lang.runtime.SwitchBootstraps:

它的参数意义如下:

  • lookup,由JVM提供的查找对象

  • invocationName,名称,和最后的效果没有任何关系,只要不是null都能传入。程序写入常用“typeSwitch”

  • invocationType,要求第一个参数非基本类型、第二个参数是int、返回值是int的方法类型,也就是说它强制要求传入一个对象和一个int。对象用于检查模式,int用于确定lookupswitch的起始位置(通常是0)。返回值是从第二个参数开始的连续数列中的一个值。

  • labels,模式匹配目标。可以是Class、Integer或String对象,但是实际上编译时只使用了Class对象。它规定了返回的CallSite的内容:如果输入的对象是这个Class的对象,返回对应的位置。如果输入的对象是null,返回-1。如果输入的对象不存在匹配项,返回labels数组的长度。

通过这个引导方法,上面的代码可以变为:

使用字节码写入:

* 通过SwitchBootstrap可以看出switch以后可能将尽可能使用invokedynamic:typeSwitch支持String输入,也许之后会将String的switch语句修改为这种实现;现在的类内部还有一个enumSwitch但是javac并不能编译出这个引导方法。在下个版本也许会进一步增加细节。

九. 自定义引导方法

注意:自定义一个引导方法可能导致你的程序不稳定、出现奇奇怪怪的问题、编译变得极度麻烦。如果不是特殊用途(比如说真正的让一个反编译器完全失效)不要用这个!

根据上面引导方法的定义和inDy的实现,我们自己也能创造出一个引导方法——只需要满足要求就好。下面就是一个简单的引导方法:

接着,我们在我们的方法里面用字节码指向它:

接下来就是执行,输出结果是:

这样,我们就成功让字节码指向我们自定义的引导方法。

全部的代码:https://paste.ubuntu.com/p/d82FNcP6jS/

十. 动态常量(Constant Dynamic)

之前在BSM那里简单提到了动态计算常量,这是JEP 309(Java 11)引入的,在这里我们再进一步深入讲解。

首先,它的BSM定义和动态调用点的BSM定义方式不同,详情可以看上面。在写入ASM时,它使用的是visitLdcInsn,和普通常量一样。

创建一个动态常量使用ConstantDynamic,它的构造函数如下:

可以看到它和visitInvokeDynamicInsn差不多,唯一的区别是:descriptor是类描述符而不是方法描述符。因此,所有动态常量的BSM都不允许传入变量。

有关于动态常量的BSM都存储到了一个类中:java.lang.invoke.ConstantBootstraps。

使用动态计算常量可以使用其他动态计算常量作为静态参数,这时JVM会倒序一个个计算创建常量。

下面是个例子:

字节码写入如下:

每个ConstantDynamic都可以复用——你可以使用一个对象传入到不同的LDC里面去。这些对象最终和普通常量一样存储到常量池内部。

这篇专栏就这些了,只讲了一个字节码,但是内容很多。加上以前的一共191个。

有错误可以在评论区指出。

这篇专栏也同步到我的博客上,可以去看看)

Java ASM详解:MethodVisitor与Opcode(五)invokedynamic、方法句柄、lambda的评论 (共 条)

分享到微博请遵守国家法律