BT

如何利用碎片时间提升技术认知与能力? 点击获取答案

解析JDK 7的动态类型语言支持

| 作者 周志明 关注 12 他的粉丝 发布于 2012年2月4日. 估计阅读时间: 33 分钟 | QCon上海2018 关注大数据平台技术选型、搭建、系统迁移和优化的经验。

Java虚拟机的字节码指令集的数量自从Sun公司的第一款Java虚拟机问世至JDK 7来临之前的十余年时间里,一直没有发生任何变化[1]。随着JDK 7的发布,字节码指令集终于迎来了第一位新成员——invokedynamic指令。这条新增加的指令是JDK 7实现“动态类型语言(Dynamically Typed Language)”支持而进行的改进之一,也是为JDK 8可以顺利实现Lambda表达式做技术准备。在这篇文章中,我们将去了解JDK 7这项新特性的出现前因后果和它的意义。

动态类型语言

在介绍JDK 7提供的动态类型语言支持之前,我们要先弄明白动态类型语言是什么?它与Java语言、Java虚拟机有什么关系?了解JDK 7提供动态类型语言支持的技术背景,对理解这个语言特性是很必要的。

何谓动态类型语言[2]?动态类型语言的关键特征是它的类型检查的主体过程是在运行期而不是编译期进行的,满足这个特征的语言有很多,常用的包括:APL、Clojure、Erlang、Groovy、JavaScript、Jython、Lisp、Lua、PHP、Prolog、Python、Ruby、Smalltalk和Tcl等等。那相对地,在编译期就进行类型检查过程的语言,如C++和Java等就是最常用的静态类型语言。

觉得上面定义过于概念化?那我们不妨通过两个例子以最浅显的方式来说明什么是“在编译期/运行期进行”和什么是“类型检查”。首先看这段简单的Java代码,它是否能正常编译和运行?

public static void main(String[] args) { 
    int[][][] array = new int[1][0][-1]; 
} 

这段代码能够正常编译,但运行的时候会报NegativeArraySizeException异常。在《Java虚拟机规范》中明确规定了NegativeArraySizeException是一个运行时异常,通俗一点说,运行时异常就是只要代码不运行到这一行就不会有问题。与运行时异常相对应的是连接时异常,例如很常见的NoClassDefFoundError便属于连接时异常,即使会导致连接时异常的代码放在一条无法执行到的分支路径上,类加载时(Java的连接过程不在编译阶段,而在类加载阶段)也照样会抛出异常。

不过,在C语言里,含义相同的代码的代码就会在编译期报错:

int main(void) {
    int i[1][0][-1]; // GCC拒绝编译,报“size of array is negative” 
    return 0;
}

由此看来,一门语言的哪一种检查行为要在运行期进行,哪一种检查要在编译期进行并没有必然的因果逻辑关系,关键是在语言规范中人为规定的,再举一个例子来解释“类型检查”,例如下面这一句再普通不过的代码:

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文件中,例如下面这个样子:

invokevirtual #4; //Method java/io/PrintStream.println:(Ljava/lang/String;)V 

这个符号引用包含了此方法定义在哪个具体类型之中、方法的名字以及参数顺序、参数类型和方法返回值等信息,通过这个符号引用,虚拟机就可以翻译出这个方法的直接引用(譬如方法内存地址或者其他实现形式)。而在ECMAScript等动态类型语言中,变量obj本身是没有类型的,变量obj的值才具有的类型,编译时候最多只能确定方法名称、参数、返回值这些信息,而不会去确定方法所在的具体类型(方法接收者不固定)。“变量无类型而变量值才有类型”这个特点也是动态类型语言的一个重要特征。

了解了动态和静态类型语言的区别后,也许读者的下一个问题就是动态、静态类型语言两者谁更好,或者谁更加先进?这种比较不会有确切答案,它们都有自己的优点,选择哪种语言是需要权衡的事情。静态类型语言在编译期确定类型,最显著的好处是编译器可以提供严谨的类型检查,这样与类型相关的问题能在编码的时候就及时发现,利于稳定性及代码达到更大的规模。而动态类型语言在运行期确定类型,这可以为开发人员提供更大的灵活性,某些在静态类型语言中要花大量臃肿代码来实现的功能,由动态类型语言来实现可能会很清晰简洁,清晰简洁通常也就意味着开发效率的提升。

另外,动态、静态类型语言的界限并非泾渭分明,如C#是一门静态类型语言,但是在.NET 4.0中新增加的dynamic关键字(它作用就是用来描述一个“无类型”的变量)以及相应的DLR支持后,C# 4.0便拥有了动态语言的特性。

JDK 7与动态类型

现在,我们回到本专栏的主题,来看看Java语言、虚拟机与动态类型语言之间有什么关系。Java虚拟机毫无疑问是Java语言的运行平台,但它的使命并不仅限于此,早在1997年出版的《Java虚拟机规范》第一版中就规划了这样一个愿景:“在未来,我们会对Java虚拟机进行适当的扩展,以便更好的支持其他语言运行于Java虚拟机之上”。而目前确实已经有许多动态类型语言运行于Java虚拟机之上了,如Clojure、Groovy、Jython和JRuby等等,能够在同一个虚拟机之上可以实现静态类型语言的严谨与动态类型语言的灵活,这是一件很美妙的事情。

但遗憾的是Java虚拟机层面对动态类型语言的支持一直都有所欠缺,主要表现在方法调用方面:JDK 7以前字节码指令集中,四条方法调用指令(invokevirtual、invokespecial、invokestatic、invokeinterface)的第一个参数都是被调用的方法的符号引用(CONSTANT_Methodref_info或者CONSTANT_InterfaceMethodref_info常量),前面已经提到过,方法的符号引用在编译时产生,而动态类型语言只有在运行期才能确定接收者类型。这样,在Java虚拟机上实现的动态类型语言就不得不使用“曲线救国”的方式(如编译时留个占位符类型,运行时动态生成字节码实现具体类型到占位符类型的适配)来实现,这样势必让动态类型语言实现的复杂度增加,也可能带来额外的性能或者内存开销。尽管可以想一些办法(如Call Site Caching)让这些开销尽量变小,但这种底层问题终归是应当在虚拟机层次上去解决才最合适,因此在Java虚拟机层面上提供动态类型的直接支持就成为了Java平台的发展趋势之一,这就是JDK 7(JSR-292)中invokedynamic指令以及java.lang.invoke包出现的技术背景。

java.lang.invoke包

JDK 7实现了JSR 292 《Supporting Dynamically Typed Languages on the Java Platform》,新加入的java.lang.invoke包[3]是就是JSR 292的一个重要组成部分,这个包的主要目的是在之前单纯依靠符号引用来确定调用的目标方法这条路之外,提供一种新的动态确定目标方法的机制,称为Method Handle。这个表达也不好懂?那不妨把Method Handle与C/C++中的Function Pointer,或者C#里面的Delegate类比一下。举个例子,如果我们要实现一个带谓词的排序函数,在C/C++中常用做法是把谓词定义为函数,用函数指针来把谓词传递到排序方法,像这样:

void sort(int list[], const int size, int (*compare)(int, int)) 

但Java语言中做不到这一点,没有办法单独把一个函数作为参数进行传递。普遍的做法是设计一个带有compare()方法的Comparator接口,以实现了这个接口的对象作为参数,例如Collections.sort()就是这样定义的:

void sort(List list, Comparator c)

不过,在拥有Method Handle之后,Java语言也可以拥有类似于函数指针或者委托的方法别名的工具了。下面代码演示了MethodHandle的基本用途,无论obj是何种类型(临时定义的ClassA抑或是实现PrintStream接口的实现类System.out),都可以正确调用到println()方法。

import static java.lang.invoke.MethodHandles.lookup;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodType;
/** 
 * JSR 292 MethodHandle基础用法演示
 * @author IcyFenix
 */
public class MethodHandleTest {
    static class ClassA {
        public void println(String s) {
            System.out.println(s);
        }
    }
    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);
    }
}

方法getPrintlnMH()中实际上是模拟了invokevirtual指令的执行过程,只不过它的分派逻辑并非固化在Class文件的字节码上的,而是通过一个具体方法来实现。而这个方法本身的返回值(MethodHandle对象),可以视为对最终调用方法的一个“引用”。以此为基础,有了MethodHandle就可以写出类似于这样的函数声明了:

void sort(List list, MethodHandle compare) 

从上面的例子看来,使用MethodHandle并没有多少困难,不过看完它的用法之后,读者大概就会疑问到,相同的事情,用反射不是早就可以实现了吗?

确实,仅站在Java语言的角度看,MethodHandle的使用方法和效果上与Reflection都有众多相似之处。不过,它们也有以下这些区别:

  • Reflection和MethodHandle机制本质上都是在模拟方法调用,但是Reflection是在模拟Java代码层次的方法调用,而MethodHandle是在模拟字节码层次的方法调用。在MethodHandles.Lookup上的三个方法findStatic()、findVirtual()、findSpecial()正是为了对应于invokestatic、invokevirtual & invokeinterface和invokespecial这几条字节码指令的执行权限校验行为,而这些底层细节在使用Reflection API时是不需要关心的。
  • Reflection中的java.lang.reflect.Method对象远比MethodHandle机制中的java.lang.invoke.MethodHandle对象所包含的信息来得多。前者是方法在Java一端的全面映像,包含了方法的签名、描述符以及方法属性表中各种属性的Java端表示方式,还包含有执行权限等的运行期信息。而后者仅仅包含着与执行该方法相关的信息。用开发人员通俗的话来讲,Reflection是重量级,而MethodHandle是轻量级。
  • 由于MethodHandle是对字节码的方法指令调用的模拟,那理论上虚拟机在这方面做的各种优化(如方法内联),在MethodHandle上也应当可以采用类似思路去支持(但目前实现还不完善)。而通过反射去调用方法则不行。

MethodHandle与Reflection除了上面列举的区别外,最关键的一点还在于去掉前面讨论施加的前提“仅站在Java语言的角度看”之后:Reflection API的设计目标是只为Java语言服务的,而MethodHandle则设计为可服务于所有Java虚拟机之上的语言,其中也包括了Java语言而已。

invokedynamic指令

本文一开始就提到了JDK 7为了更好地支持动态类型语言,引入了第五条方法调用的字节码指令invokedynamic,但前面一直没有再提到它,甚至把之前使用MethodHandle的示例代码反编译后也不会看见invokedynamic的身影,它到底有什么应用呢?

某种程度上可以说invokedynamic指令与MethodHandle机制的作用是一样的,都是为了解决原有四条invoke*指令方法分派规则固化在虚拟机之中的问题,把如何查找目标方法的决定权从虚拟机转嫁到具体用户代码之中,让用户(包含其他语言的设计者)有更高的自由度。而且,它们两者的思路也是可类比的,可以想象作为了达成同一个目的,一个用上层代码和API来实现,另一个是用字节码和Class中其他属性和常量来完成。因此,如果前面MethodHandle的例子看懂了,理解invokedynamic指令并不困难。

每一处含有invokedynamic指令的位置都被称作“动态调用点(Dynamic Call Site)”,这条指令的第一个参数不再是代表方法符号引用的CONSTANT_Methodref_info常量,而是变为JDK 7新加入的CONSTANT_InvokeDynamic_info常量,从这个新常量中可以得到3项信息:引导方法(Bootstrap Method,此方法存放在新增的BootstrapMethods属性中)、方法类型(MethodType)和名称。引导方法是有固定的参数,并且返回值是java.lang.invoke.CallSite对象,这个代表真正要执行的目标方法调用。根据CONSTANT_InvokeDynamic_info常量中提供的信息,虚拟机可以找到并且执行引导方法,从而获得一个CallSite对象,最终调用要执行的目标方法上。我们还是照例拿一个实际例子来解释这个过程吧。如下面代码清单所示:

import static java.lang.invoke.MethodHandles.lookup;
import java.lang.invoke.CallSite;
import java.lang.invoke.ConstantCallSite;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
public class InvokeDynamicTest {
    public static void main(String[] args) throws Throwable {
        INDY_BootstrapMethod().invokeExact("icyfenix");
    }
    public static void testMethod(String s) {
        System.out.println("hello String:" + s);
    }
    public static CallSite BootstrapMethod(MethodHandles.Lookup lookup, String name, MethodType mt) throws Throwable {
        return new ConstantCallSite(lookup.findStatic(InvokeDynamicTest.class, name, mt));
    }
    private static MethodType MT_BootstrapMethod() {
        return MethodType.fromMethodDescriptorString("(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;", null);
    }
    private static MethodHandle MH_BootstrapMethod() throws Throwable {
        return lookup().findStatic(InvokeDynamicTest.class, "BootstrapMethod", MT_BootstrapMethod());
    }
    private static MethodHandle INDY_BootstrapMethod() throws Throwable {
        CallSite cs = (CallSite) MH_BootstrapMethod().invokeWithArguments(lookup(), "testMethod", MethodType.fromMethodDescriptorString("(Ljava/lang/String;)V", null));
        return cs.dynamicInvoker();
    }
}

这段代码与前面MethodHandleTest的作用基本上是一样的,虽然笔者没有加以注释,但是阅读起来应当不困难。真没读懂也不要紧,我没写注释的原因是这段代码并非写给人看的(@_@,我不是在骂人)。由于目前光靠Java语言的编译器javac没有办法生成带有invokedynamic 指令的字节码(曾经有一个java.dyn.InvokeDynamic的语法糖可以实现,但后来被取消了),所以只能用一些变通的办法,John Rose(Da Vinci Machine Project的Leader)编写了一个把程序的字节码转换为使用invokedynamic的简单工具INDY[4]来完成这件事情,我们要使用这个工具来产生最终要的字节码,因此这个示例代码中的方法名称不能乱改,更不能把几个方法合并到一起写。

把上面代码编译、转换后重新生成的字节码如下(结果使用javap输出,因版面原因,精简了许多无关的内容):

Constant pool: 
    #121 = NameAndType #33:#30 // testMethod:(Ljava/lang/String;)V 
    #123 = InvokeDynamic #0:#121 // #0:testMethod:(Ljava/lang/String;)V 
    public static void main(java.lang.String[]) throws java.lang.Throwable; 
Code: 
    stack=2, locals=1, args_size=1 
0: ldc #23 // String abc 
2: invokedynamic #123, 0 // InvokeDynamic #0:testMethod:(Ljava/lang/String;)V 
7: nop 
8: return 
    public static java.lang.invoke.CallSite BootstrapMethod(java.lang.invoke.MethodHandles$Lookup, java.lang.String, java.lang.invoke.MethodType) throws java.lang.Throwable; 
Code: 
    stack=6, locals=3, args_size=3 
0: new #63 // class java/lang/invoke/ConstantCallSite 
3: dup 
4: aload_0 
5: ldc #1 // class org/fenixsoft/InvokeDynamicTest 
7: aload_1 
8: aload_2 
9: invokevirtual #65 // Method         java/lang/invoke/MethodHandles$Lookup.findStatic:(Ljava/lang/Class;Ljava/lang/String;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/MethodHandle; 
12: invokespecial #71 // Method java/lang/invoke/ConstantCallSite."<init>":(Ljava/lang/invoke/MethodHandle;)V 
15: areturn 

从main()方法的字节码中可见,原本的方法调用指令已经被替换为invokedynamic了,它的参数为第123项常量(第二个值为0的参数在HotSpot中用不到,与invokeinterface那个的值为0的参数一样是占位的):

2: invokedynamic #123, 0 // InvokeDynamic #0:testMethod:(Ljava/lang/String;)V 

从常量池中可见,第123项常量显示“#123 = InvokeDynamic #0:#121”说明它是一项CONSTANT_InvokeDynamic_info类型常量,常量值中前面“#0”代表引导方法取BootstrapMethods属性表的第0项(javap没有列出属性表的具体内容,不过示例中仅有一个引导方法,即BootstrapMethod()),而后面的“#121”代表引用第121项类型为CONSTANT_NameAndType_info的常量,从个常量中可以获取方法名称和描述符,既后面输出的“testMethod:(Ljava/lang/String;)V”。

再看BootstrapMethod(),这个方法Java源码中没有,是INDY产生的,但是它的字节码很容易读懂,所有逻辑就是调用MethodHandles$Lookup的findStatic()方法,产生testMethod()方法的MethodHandle,然后用它创建一个ConstantCallSite对象。最后,这个对象返回给invokedynamic指令实现对testMethod()方法的调用,invokedynamic指令的调用过程到此就宣告完成了。

参考资料


[1] 这里特指数量上没有变化。前面有少量指令的语义、格式发生过变化,例如invokespecial指令,也有少量指令在JDK 7中被禁用掉,例如jsr、jsr_w指令。

[2] 动态类型语言与动态语言、弱类型语言并不是一个概念,需要区别对待。

[3] 这个包在不算短的时间里的名称是java.dyn,也曾经短暂更名为java.lang.mh,如果读者在其他资料上看到这两个包名可以把它们与java.lang.invoke理解为同一样东西。

[4] INDY:http://blogs.oracle.com/jrose/entry/a_modest_tool_for_writing。

本文源代码可在这里下载


感谢张凯峰对本文的审校。

给InfoQ中文站投稿或者参与内容翻译工作,请邮件至editors@cn.infoq.com。也欢迎大家通过新浪微博(@InfoQ)或者腾讯微博(@InfoQ)关注我们,并与我们的编辑和其他读者朋友交流。

评价本文

专业度
风格

您好,朋友!

您需要 注册一个InfoQ账号 或者 才能进行评论。在您完成注册后还需要进行一些设置。

获得来自InfoQ的更多体验。

告诉我们您的想法

允许的HTML标签: a,b,br,blockquote,i,li,pre,u,ul,p

当有人回复此评论时请E-mail通知我

言简意赅 by Dongbin Nie

写的很好,言简意赅。
不过最后的bytecode里的BootstrapMethod()不就是原来的java源代码里面的嘛。

期待。 by Zhao Ren

要学习在java中如何使用动态语言了。

Re: 期待。 by zaixiang wang

原来不知道有indy这个工具,花了些时间自己写了类似的一个工具,在github.com/wangzaixiang/invokedynamic-demo上,是一个...

Re: 期待。 by zaixiang wang

原来不知道有indy这个工具,花了些时间自己写了类似的一个工具,在 github.com/wangzaixiang/invokedynamic-demo 上,是一个相似的东西。感兴趣的同学可以参考一下。这个项目中还包括了使用ASM来生成一个使用invokedynamic指令的测试类的代码,可以帮助熟悉invokedynamic指令

还是搞不明白为什么Java里的Lambda语法需要invokedynamic支持 by Jeffrey Zhao

比如C++和C#里的类比,不都是纯静态语言的功能吗?而且从Java 8语法的草案来看,也完全没必要用invokedynamic才对。

Re: 还是搞不明白为什么Java里的Lambda语法需要invokedynamic支持 by Yang Yang

不是说一定要用invokedynamic来做closure。而只是选择了这个方案而已。

动态语言和闭包都符合“方法接收者不固定”这个条件。那么用invokeDynamic来做也是合理的。你仔细看MethodHandle和CallSite其实就是函数指针了。现在Java8还没有发布,只是有人说“用invokeDynamic来做”,我觉得实际上指的可能是“JSR 292 ”而不是区区一个invokeDynamic关键字。

Dynamic这里和动态语言不能划等号。理论上来说C++的函数指针也能叫Dynamic。我觉得Lua也是基于函数指针来实现和C++的结合的。

Re: 还是搞不明白为什么Java里的Lambda语法需要invokedynamic支持 by Yang Yang

在这里CallSite和MethodHandle都是lookup find出来的。不排除真正的闭包的CallSite就是New出来的嘛。

说笑了,呵呵。

obj.println(“hello world”); 中obj必须是PrintStream接口类型??? by dan luo

public class test {
// public void println(String s) {
// System.out.println("s=" + s);
// }
private String println(String s) {
System.out.println("s=" + s);
return s;
}

/**
* @param args
*/
public static void main(String[] args) {
// TODO Auto-generated method stub
new test().println("hello world");
}

}
这个类就是一个简单得类,根PrintStream没任何关系。两个println方法都可以正常运行啊。。。。。

Re: obj.println(“hello world”); 中obj必须是PrintStream接口类型??? by 李 小寒

我也没看懂,自己也向你一样试了一下,可以正常运行的,不明白楼主的意思

允许的HTML标签: a,b,br,blockquote,i,li,pre,u,ul,p

当有人回复此评论时请E-mail通知我

允许的HTML标签: a,b,br,blockquote,i,li,pre,u,ul,p

当有人回复此评论时请E-mail通知我

9 讨论

登陆InfoQ,与你最关心的话题互动。


找回密码....

Follow

关注你最喜爱的话题和作者

快速浏览网站内你所感兴趣话题的精选内容。

Like

内容自由定制

选择想要阅读的主题和喜爱的作者定制自己的新闻源。

Notifications

获取更新

设置通知机制以获取内容更新对您而言是否重要

BT