在 Java 7 的发布版中包含了多项新的特性,这些特性乍看上去 Java 开发人员对它们的使用非常有限,在我们之前的文章中,曾经对其进行过介绍。
但是,其中有项特性对于实现Java 8 中“头版标题”类型的特性来说至关重要(如lambdas 和默认方法)。在本文中,我们将会深入学习invokedynamic,并阐述它对于Java 平台以及像JRuby 和Nashorn 这样的JVM 语言来讲为何如此重要。
invokedynamic 最初的工作至少始于 2007 年,而第一次成功的动态调用发生在 2008 年 8 月 26 日。这比 Oracle 收购 Sun 还要早,按照大多数开发人员的标准,这个特性的研发已经持续了相当长的时间。
值得注意的是,从 Java 1.0 到现在,invokedynamic 是第一个新加入的 Java 字节码,它与已有的字节码 invokevirtual、invokestatic、invokeinterface 和 invokespecial 组合在了一起。已有的这四个操作码实现了 Java 开发人员所熟知的所有形式的方法分派(dispatch):
- invokevirtual——对实例方法的标准分派
- invokestatic——用于分派静态方法
- invokeinterface——用于通过接口进行方法调用的分派
- invokespecial——当需要进行非虚(也就是“精确”)分派时会用到
有些开发人员可能会好奇平台为何需要这四种操作码,所以我们看一个简单的样例,这个样例会用到不同的调用操作码,以此来阐述它们之间的差异:
public class InvokeExamples { public static void main(String[] args) { InvokeExamples sc = new InvokeExamples(); sc.run(); } private void run() { List ls = new ArrayList(); ls.add("Good Day"); ArrayList als = new ArrayList(); als.add("Dydh Da"); } }
我们可以使用 javap 反汇编从而得到它所产生的字节码:
javap -c InvokeExamples.class public class kathik.InvokeExamples { public kathik.InvokeExamples(); Code: 0: aload_0 1: invokespecial #1 // Method java/lang/Object."":()V 4: return public static void main(java.lang.String[]); Code: 0: new #2 // class kathik/InvokeExamples 3: dup 4: invokespecial #3 // Method "":()V 7: astore_1 8: aload_1 9: invokespecial #4 // Method run:()V 12: return private void run(); Code: 0: new #5 // class java/util/ArrayList 3: dup 4: invokespecial #6 // Method java/util/ArrayList."":()V 7: astore_1 8: aload_1 9: ldc #7 // String Good Day 11: invokeinterface #8, 2 // InterfaceMethod java/util/List.add:(Ljava/lang/Object;)Z 16: pop 17: new #5 // class java/util/ArrayList 20: dup 21: invokespecial #6 // Method java/util/ArrayList."":()V 24: astore_2 25: aload_2 26: ldc #9 // String Dydh Da 28: invokevirtual #10 // Method java/util/ArrayList.add:(Ljava/lang/Object;)Z 31: pop 32: return }
在这个示例中,展现了四个调用操作码中的三个(剩下的一个也就是 invokestatic,是一个非常简单的扩展)。作为开始,我们可以看一下如下的两个调用(在 run 方法的字节 11 和 28):
ls.add("Good Day")
和
als.add("Dydh Da")
在 Java 源码中它们看起来非常相似,但它们实际上却代表两种不同的字节码。
对于 javac 来说,变量 ls 具有的静态类型是List<String>
,而 List 是一个接口。所以,在运行时方法表(通常称为“vtable”)中,add() 方法的精确位置还没有在编译时确定。因此,源码编译器会生成一个 invokeinterface 指令,将实际的方法查找推迟到运行期,也就是当 ls 的实际 vtable 能够探查到并且 add() 方法的位置能够找到的时候。
与之相反,对als.add("Dydh Da")
的调用是通过 als 来执行的,这里的静态类型是类类型(class type)——ArrayList<String>
。这意味着在 vtable 中,方法的位置在编译期是可知的。因此,javac 会针对这个精确的 vtable 条目生成一个 invokevirtual 指令。不过,最终的方法选择依然是在运行期确定的,因为这里还有方法重写(overriding)的可能性,但是 vtable slot 在编译期就已经确定了。
除此之外,这个样例还展现了 invokespecial 的两个使用场景。这个操作码用于在运行时确定如何分派的场景之中,具体来讲,在这里没有方法重写的需求,另外这也不可能实现。样例中所阐述的场景是 _private methods_ 和 _super calls_,这些方法在编译期是可知的,并且无法进行重写。
细心的读者可能已经发现,对 Java 方法的所有调用都编译成了四个操作码中的某一个,那么问题就来了——invokedynamic 是做什么的,它对于 Java 开发人员有什么用处呢?
这个特性的主要目标在于创建一个字节码,用于处理新型的方法分派——它的本质是允许应用级别的代码来确定执行哪一个方法调用,只有在调用要执行的时候,才会进行这种判断。这样的话,相对于 Java 平台之前所提供的编程风格,允许语言和框架的编写人员支持更加动态的编码风格。
它的目的在于由用户代码通过方法句柄 API(method handles API)在运行时确定如何分派,同时避免反射带来的性能惩罚和安全问题。实际上,invokedynamic 所宣称的目标就是一旦该特性足够成熟,它的速度要像常规的方法分派(invokevirtual)一样快。
当 Java 7 发布的时候,JVM 就已经支持执行新的字节码了,但是不管提交什么样的 Java 代码,javac 都不会产生包含 invokedynamic 的字节码。这项特性用来支持 JRuby 和其他运行在 JVM 上的动态语言。
在 Java 8 中,这发生了变化,在实现 lambda 表达式和默认方法时,底层会生成和使用 invokedynamic,它同时还会作为 Nashorn 的首选分派机制。但是,对于 Java 应用的开发人员来说,依然没有直接的方式实现完全的动态方法处理(resolution)。也就是说,Java 语言并没有提供关键字或库来创建通用的 invokedynamic 调用点(call site)。这意味着,尽管这种机制的功能非常强大,但它对于大多数的 Java 开发人员来说依然有些陌生。接下来,我们看一下如何在自己的代码中使用这项技术。
方法句柄简介
要让 invokedynamic 正常运行,一个核心的概念就是方法句柄(method handle)。它代表了一个可以从 invokedynamic 调用点进行调用的方法。这里的基本理念就是每个 invokedynamic 指令都会与一个特定的方法关联(也就是引导方法或 BSM)。当解释器(interpreter)遇到 invokedynamic 指令的时候,BSM 会被调用。它会返回一个对象(包含了一个方法句柄),这个对象表明了调用点要实际执行哪个方法。
在一定程度上,这与反射有些类似,但是反射有它的局限性,这些局限性使它不适合与 invokedynamic 协作使用。Java 7 API 中加入了 java.lang.invoke.MethodHandle(及其子类),通过它们来代表 invokedynamic 指向的方法。为了实现操作的正确性,MethodHandle 会得到 JVM 的一些特殊处理。
理解方法句柄的一种方式就是将其视为以安全、现代的方式来实现反射的核心功能,在这个过程会尽可能地保证类型的安全。invokedynamic 需要方法句柄,另外它们也可以单独使用。
方法类型
一个 Java 方法可以视为由四个基本内容所构成:
- 名称
- 签名(包含返回类型)
- 定义它的类
- 实现方法的字节码
这意味着如果要引用某个方法,我们需要有一种有效的方式来表示方法签名(而不是反射中强制使用的令人讨厌的 Class<?>[] hack 方式)。
接下来我们采用另外的方式,方法句柄首先需要的一个构建块就是表达方法签名的方式,以便于查找。在 Java 7 引入的 Method Handles API 中,这个角色是由 java.lang.invoke.MethodType 类来完成的,它使用一个不可变的实例来代表签名。要获取 MethodType,我们可以使用 methodType() 工厂方法。这是一个参数可变(variadic)的方法,以 class 对象作为参数。
第一个参数所使用的 class 对象,对应着签名的返回类型;剩余参数中所使用的 class 对象,对应着签名中方法参数的类型。例如:
//toString() 的签名 MethodType mtToString = MethodType.methodType(String.class); // setter 方法的签名 MethodType mtSetter = MethodType.methodType(void.class, Object.class); // Comparator 中 compare() 方法的签名 MethodType mtStringComparator = MethodType.methodType(int.class, String.class, String.class);
现在我们就可以使用 MethodType,再组合方法名称以及定义方法的类来查找方法句柄。要实现这一点,我们需要调用静态的 MethodHandles.lookup() 方法。这样的话,会给我们一个“查找上下文(lookup context)”,这个上下文基于当前正在执行的方法(也就是调用 lookup() 的方法)的访问权限。
查找上下文对象有一些以“find”开头的方法,例如,findVirtual()、findConstructor()、findStatic() 等。这些方法将会返回实际的方法句柄,需要注意的是,只有在创建查找上下文的方法能够访问(调用)被请求方法的情况下,才会返回句柄。这与反射不同,我们没有办法绕过访问控制。换句话说,方法句柄中并没有与 setAccessible() 对应的方法。例如:
public MethodHandle getToStringMH() { MethodHandle mh = null; MethodType mt = MethodType.methodType(String.class); MethodHandles.Lookup lk = MethodHandles.lookup(); try { mh = lk.findVirtual(getClass(), "toString", mt); } catch (NoSuchMethodException | IllegalAccessException mhx) { throw (AssertionError)new AssertionError().initCause(mhx); } {1} return mh; } {1}
MethodHandle 中有两个方法能够触发对方法句柄的调用,那就是 invoke() 和 invokeExact()。这两个方法都是以接收者(receiver)和调用变量作为参数,所以它们的签名为:
public final Object invoke(Object... args) throws Throwable; public final Object invokeExact(Object... args) throws Throwable;
两者的区别在于,invokeExact() 在调用方法句柄时会试图严格地直接匹配所提供的变量。而 invoke() 与之不同,在需要的时候,invoke() 能够稍微调整一下方法的变量。invoke() 会执行一个 asType() 转换,它会根据如下的这组规则来进行变量的转换:
- 如果需要的话,原始类型会进行装箱操作
- 如果需要的话,装箱后的原始类型会进行拆箱操作
- 如果必要的话,原始类型会进行扩展
- void 返回类型会转换为 0(对于返回原始类型的情况),而对于预期得到引用类型的返回值的地方,将会转换为 null
- null 值会被视为正确的,不管静态类型是什么都可以进行传递
接下来,我们看一下考虑上述规则的简单调用样例:
Object rcvr = "a"; try { MethodType mt = MethodType.methodType(int.class); MethodHandles.Lookup l = MethodHandles.lookup(); MethodHandle mh = l.findVirtual(rcvr.getClass(), "hashCode", mt); int ret; try { ret = (int)mh.invoke(rcvr); System.out.println(ret); } catch (Throwable t) { t.printStackTrace(); } } catch (IllegalArgumentException | NoSuchMethodException | SecurityException e) { e.printStackTrace(); } catch (IllegalAccessException x) { x.printStackTrace(); } {1}
在更为复杂的样例中,方法句柄能够以更清晰的方式来执行与核心反射功能相同的动态编程任务。除此之外,在设计之初,方法句柄就与 JVM 底层的执行模型协作地更好,并且可能会提供更好的性能(尽管性能的问题还没有展开叙述)。
方法句柄与 invokedynamic
invokedynamic 指令通过引导方法(bootstrap method,BSM)机制来使用方法句柄。与 invokevirtual 指令不同,invokedynamic 指令没有接收者对象。相反,它们的行为类似于 invokestatic,会使用 BSM 来返回一个 CallSite 类型的对象。这个对象包含一个方法句柄(称之为“target”),它代表了当前 invokedynamic 指令要执行的方法。
当包含 invokedynamic 的类加载时,调用点会处于“unlaced”状态,在 BSM 返回之后,得到的 CallSite 和方法句柄会让调用点处于“laced”状态。
BSM 的签名大致会如下所示(注意,BSM 的名称是任意的):
static CallSite bootstrap(MethodHandles.Lookup caller, String name, MethodType type);
如果你希望创建包含 invokedynamic 的代码,那么我们需要使用一个字节码操纵库(因为 Java 语言本身并不包含我们所需的构造)。在本文剩余的内容中,我们将会使用 ASM 库来生成包含 invokedynamic 指令的字节码。从 Java 应用程序的角度来看,它们看起来就像是常规的类文件(当然,它们没有相关的 Java 源码表述)。Java 代码会将其视为“黑盒”,不过我们可以调用方法并使用 invokedynamic 及其相关的功能。
下面,我们来看一下基于 ASM 的类,它会使用 invokedynamic 指令来生成“Hello World”。
public class InvokeDynamicCreator { public static void main(final String[] args) throws Exception { final String outputClassName = "kathik/Dynamic"; try (FileOutputStream fos = new FileOutputStream(new File("target/classes/" + outputClassName + ".class"))) { fos.write(dump(outputClassName, "bootstrap", "()V")); } } public static byte[] dump(String outputClassName, String bsmName, String targetMethodDescriptor) throws Exception { final ClassWriter cw = new ClassWriter(0); MethodVisitor mv; // 为引导类搭建基本的元数据 cw.visit(V1_7, ACC_PUBLIC + ACC_SUPER, outputClassName, null, "java/lang/Object", null); // 创建标准的 void 构造器 mv = cw.visitMethod(ACC_PUBLIC, "", "()V", null, null); mv.visitCode(); mv.visitVarInsn(ALOAD, 0); mv.visitMethodInsn(INVOKESPECIAL, "java/lang/Object", "", "()V"); mv.visitInsn(RETURN); mv.visitMaxs(1, 1); mv.visitEnd(); // 创建标准的 main 方法 mv = cw.visitMethod(ACC_PUBLIC + ACC_STATIC, "main", "([Ljava/lang/String;)V", null, null); mv.visitCode(); MethodType mt = MethodType.methodType(CallSite.class, MethodHandles.Lookup.class, String.class, MethodType.class); Handle bootstrap = new Handle(Opcodes.H_INVOKESTATIC, "kathik/InvokeDynamicCreator", bsmName, mt.toMethodDescriptorString()); mv.visitInvokeDynamicInsn("runDynamic", targetMethodDescriptor, bootstrap); mv.visitInsn(RETURN); mv.visitMaxs(0, 1); mv.visitEnd(); cw.visitEnd(); return cw.toByteArray(); } private static void targetMethod() { System.out.println("Hello World!"); } public static CallSite bootstrap(MethodHandles.Lookup caller, String name, MethodType type) throws NoSuchMethodException, IllegalAccessException { final MethodHandles.Lookup lookup = MethodHandles.lookup(); // 需要使用 lookupClass(),因为这个方法是静态的 final Class currentClass = lookup.lookupClass(); final MethodType targetSignature = MethodType.methodType(void.class); final MethodHandle targetMH = lookup.findStatic(currentClass, "targetMethod", targetSignature); return new ConstantCallSite(targetMH.asType(type)); } }
这个代码分为两部分,第一部分使用 ASM Visitor API 来创建名为 kathik.Dynamic 的类文件。注意,核心的调用是 visitInvokeDynamicInsn()。第二部分包含了要捆绑到调用点中的目标方法,并且还包括 invokedynamic 指令所需的 BSM。
注意,上述的方法是位于 InvokeDynamicCreator 类中的,而不是所生成的 kathik.Dynamic 类的一部分。这意味着,在运行时,InvokeDynamicCreator 必须也要和 kathik.Dynamic 一起位于类路径中,否则的话,就会无法找到方法。
当 InvokeDynamicCreator 运行时,它会创建一个新的类文件 Dynamic.class,这个文件中包含了 invokedynamic 指令,通过在这个类上执行 javap,我们可以看到这一点:
public static void main(java.lang.String[]); descriptor: ([Ljava/lang/String;)V flags: ACC_PUBLIC, ACC_STATIC Code: stack=0, locals=1, args_size=1 0: invokedynamic #20, 0 // InvokeDynamic #0:runDynamic:()V 5: return
这个样例阐述了 invokedynamic 最简单的使用场景,它会使用一个特定的常量 CallSite 对象。这意味着 BSM(和 lookup)只会执行一次,所以后续的调用会很快。
但是,针对 invokedynamic 的高级用法很快就会变得非常复杂,当调用点和目标方法在程序生命周期中会发生变化时更是如此。
在后续的文章中,我们将会探讨一些高级的使用场景并构建一些样例,深入研究 invokedynamic 的细节。
关于作者
Ben Evans是 Java/JVM 性能分析初创公司 jClarity 的 CEO。在业余时间,他是伦敦 Java 社区的领导者之一并且是 Java 社区进程执行委员会 (Java Community Process Executive Committee) 的成员。他之前的项目经验包括 Google IPO 的性能测试、金融交易系统并且还为 90 年代一些最大的电影编写备受好评的网站等等。
评论