《深入理解Java虚拟机》 动态类型语言支持
Java虚拟机的字节码指令集自Sun公司推出第一款Java虚拟机至今,二十余年间只新增过一条指令,它就是随着JDK 7一起发布的 invokedynamic 指令。这条新增指令是JDK 7的项目目标:实现动态类型语言支持而进行的改进之一,也是为JDK 8中可以顺利实现 Lambda 表达式而做的技术储备。
1. 动态类型语言
动态类型语言的关键特征是它的类型检查的主体过程是在运行期而不是编译期进行的,满足这个特征的语言有很多,包括APL、Groovy、JavaScript、Lua、PHP、Python等待。相对的,在编译期就进行类型检查过程的语言,比如 C++ 和 Java 等就是最常用的静态类型语言。
比如下面这行代码:
1 | obj.println("hello world!"); |
对于计算机来讲,这样一行代码并不能直接执行,需要将其放在一个具体的上下文中才有讨论的意义。
假设是在Java语言中,且变量 obj 的静态类型为 java.io.PrintStream,那么变量 obj 的实际类型就必须是 PrintStream 的子类。否则,就算 obj 所属类型确实包含相同签名的方法,只要它与 PrintStream 没有继承关系,代码就不可能运行,因为类型检查不合法。
但是如果在 JavaScript 中,无论 obj 具体是何种类型,无论其继承关系如何,只要这种类型中确实定义了 println 方法,只要能够找到相同签名的方法,便可以正常调用。
产生这种差别的根本原因是Java语言在编译期间就已经将方法完整的符号引用生成出来,并作为方法调用指令的参数存储到Class文件中。这个符号引用包含了该方法定义在哪个具体类型中、方法的名字以及参数顺序、参数类型和方法返回值等信息,通过这个符号引用,Java虚拟机就可以翻译出该方法的直接引用。
而JavaScript等动态类型语言与Java有一个核心的差异就是变量 obj 本身没有类型,变量 obj 的值才有类型,所以编译器在编译时最多只能确定方法名称、参数、返回值等信息,而不会去确定方法所在的具体类型,变量无类型而变量值才有类型这个特点也是动态类型语言的一个核心特征。
2. Java虚拟机 与 动态类型语言
早在1997年出版的第一版《Java虚拟机规范》中就规划了这样一个愿景:“在未来,我们会对Java虚拟机进行适当的扩展,以便更好地支持其他语言在其上运行。”如果能够在同一个虚拟机上实现静态语言的严谨与动态语言的灵活,这确实是一件很美妙的事情,而目前已经有一些动态类型语言能够在Java虚拟机上运行,比如Groovy、Jython等。
但Java虚拟机层面对动态类型语言的支持一直还有所欠缺,主要表现在方法调用方面:JDK 7之前的字节码指令集中,4条方法调用指令的第一个参数都是被调用方法的符号引用,而方法的符号引用在编译时产生,但动态类型语言语言只有在运行期才能确定方法的接收者。
这样,在Java虚拟机上实现动态类型语言就不得不使用“曲线救国”的方式,比如编译时留个占位符类型,运行时再动态生成字节码,实现具体类型到占位符类型的适配。但这样势必让动态类型语言的实现复杂度增加,也会带来额外的性能和内存开销。内存开销是很显然的,因为方法调用会产生一大堆动态类,而性能问题在于动态类型方法调用时,由于无法确定调用对象的静态类型,将导致方法内联无法有效进行,而方法内联是其他优化措施的基础,可以说是最重要的一项优化。
尽管可以想一些办法,比如占用些缓存,尽量缓解因支持动态语言而导致的性能下降,但这种改善毕竟不是本质的,比如下面代码:
1 | var arrays = {"abc", new ObjectX(), 123, Dog, Cat...} |
由于在运行时arrays
中的元素可以是任意类型,即使它们的类型中都有sayHello()
方法,编译时也无法确定具体sayHello()
的方法在哪里,所以编译器只能不停编译它所遇见的每一个sayHello()
方法,只要能够找到相同签名的方法,便可以正常调用。并缓存起来供执行时选择、调用和内联。而如果arrays
中不同类型的对象很多,就势必会对内联缓存造成很大的压力。
所以这种动态类型方法调用的底层问题终归是应当在Java虚拟机层面去解决才比较合适,于是,在Java虚拟机层面上提供动态类型的直接支持就成为Java平台发展必须解决的问题,这便是JDK 7时JSR-292提案中 invokedynamic 指令和 java.lang.invoke 包出现的技术背景。
2.1. java.lang.invoke包
JDK 7加入这个包的主要目的是在之前单纯依靠符号引用来确定调用的目标方法这条路之外,提供一种新的动态确定目标方法的机制,称为方法句柄。
比如在C/C++中,可以通过函数指针的方式来将函数作为参数传入
1 | void sort(int list[], const int size, int (*compare)(int, int)) |
但在Java语言中做不到这一点,无法单独将一个函数作为参数进行传递,通常是设计一个带有compare()
方法的接口,然后以这个接口的实现类实例作为参数,比如:
1 | void sort(List list, Comparator comp) |
不过,在拥有方法句柄之后,Java也可以拥有类似于函数指针的能力了,比如下面示例中演示了方法句柄的基本用法
1 | public static MethodHandle getPrintlnMH(Object receiver) throws NoSuchMethodException, IllegalAccessException { |
方法getPrintlnMH
实际上相当于模拟了invokevirtual
指令的执行过程,只是它的分派逻辑没有固话在 class 文件的字节码上,而是通过一个由用户设计的Java方法来实现,而这个方法的返回值可以视为对最终调用方法的一个引用。
以此为基础,可以利用 MethodHandle 写出类似于C/C++那样的函数声明了:
1 | void sort(List list, MethodHandle compare); |
对于上面的示例,如果通过反射也可以实现,如果是在Java中,那么 MethodHandle 的使用方式和效果确实与 Reflection 有很多相似之处,但也有一些区别
Reflection 和 MethodHandle 机制在本质上都是在模拟方法调用,但 Reflection 是在模拟Java代码层次的方法调用,而 MethodHandle 是在模拟字节码层次的方法调用。在
MethodHandles.lookup()
提供的3个方法findStatic()、findVirtual()、findSpecial()正是为了对应于 invokestatic、invokevirtual/invokeinterface、invokespecial几个字节码指令的执行过程,而这些底层细节在使用 Reflection Api时是不需要关心的。Reflection 中的 java.lang.reflect.Method 对象远比 MethodHandle 机制中的 java.lang.invoke.MethodHandle 对象所包含的信息来得多。前者是方法在Java端的全面映像,包含了方法的签名、描述符以及方法属性表中的各种属性,而后者只包含执行该方法的相关信息。
最关键的是,Reflection Api的设计目标仅仅是为Java语言服务的,而 MethodHandle 是设计用来服务于所有运行与Java虚拟机上语言,其中也包括了Java,但并不是只为Java服务的。
2.2. invokedynamic指令
invokedynamic 指令与 MethodHandle 机制类似,都是为了解决原有4个方法调用指令将分派规则完全固话在虚拟机中的问题,将如何查找目标方法的决定权从虚拟机转移到了用户手中,只是一个用上层代码和Api来实现,另一个用字节码和Class中的属性及常量来实现。
由于 invokedynamic 指令主要服务的对象并非Java语言,而是其他Java虚拟机上的其他动态类型语言。因此,光靠javac编译器的话,在JDK 7时甚至还完全没有办法生成带有 invokedynamic 指令的字节码,直到JDK 8引入了Lambda表达式和接口默认方法后,Java语言才算享受到可一点 invokedynamic 指令的好处。
每一处含有 invokedynamic 指令的位置都称为动态调用点,这条指令的第一个参数不再是代表方法的符号引用常量,而是JDK 7新增的CONSTANT_InvokeDynamic_info常量,比如下面示例:
1 | public class Test { |
1 | public class test.Test |
其中 invokedynamic 指令的第一个参数 #21 指向 CONSTANT_InvokeDynamic_info 常量,第二个参数 0 在虚拟机中不会直接用到,只是占位用的,目的是给常量池缓存留出足够的空间。
1 | 1: invokedynamic #21, 0 // InvokeDynamic #0:compare:()Ljava/util/Comparator; |
常量 #21 说明它是一项 CONSTANT_InvokeDynamic_info,其中 #0 表示引导方法取 BootstrapMethods 属性表的第0项,后面的 #18 则表示引用类型为 CONSTANT_NameAndType_info 的常量,从这个常量中可以获取到方法名称和描述符。
1 | #21 = InvokeDynamic #0:#18 // #0:compare:()Ljava/util/Comparator; |
再看 BootstrapMethods 属性
1 | BootstrapMethods: |
如果看明白了之前的 java.lang.invoke 机制,这里理解起来也不难,这里也是创建对应方法的 MethodHandle,然后用它创建一个CallSite
,最后再把这个对象返回给 invokedynamic 指令实现对方法的调用。
其中对于CallSite
的创建直接调用的静态方法LambdaMetafactory.metafactory
,同时指定了三个参数,即Method arguments
1 | public static CallSite metafactory(MethodHandles.Lookup caller, String invokedName, MethodType invokedType, |
通过字节码,可以看出来 Lambda 表达式也会产生一个内部类,通过InnerClassLambdaMetafactory
生成,然后将方法体挂到这个类下面, 再通过 invokedynamic 配合常量池想办法进行调用,其实就是上面的 MethodHandle 机制,至于为什么要绕这样一大圈,而不是直接将lambda编译成内部类进行实现,也许是为了减少编译后的文件数,具体可以再详细研究。
- 示例
下面尝试通过对象调用其祖类的方法,这在JDK 7之前,如果纯粹通过Java是语言是做不到的。因为Son
类的thinking()
方法中根本无法获取到一个实际类型为GrandFather
的对象引用,而 invokevirtual 指令的分派逻辑是固定的,只能按照方法接收者的实际类型进行分派,并且这个逻辑固化在虚拟机中,程序无法改变。
1 | public class Test { |
参考:
- Copyright ©《深入理解java虚拟机》
- https://www.zhihu.com/question/39462935/answer/81449619
- https://blog.csdn.net/xiaohulunb/article/details/104024716
- https://blog.csdn.net/u013855332/article/details/51754294
- https://www.jianshu.com/p/d74e92f93752
- https://blog.csdn.net/zxhoo/article/details/38495085
- https://blog.csdn.net/zxhoo/article/details/38387141