- 前言
- 第2版与第1版的区别
- 本书面向的读者
- 如何阅读本书
- 语言约定
- 内容特色
- 参考资料
- 第一部分 走近 Java
- 第1章 走近 Java
- 第二部分 自动内存管理机制
- 第2章 Java 内存区域与内存溢出异常
- 第3章 垃圾收集器与内存分配策略
- 第4章 虚拟机性能监控与故障处理工具
- 第5章 调优案例分析与实战
- 第三部分 虚拟机执行子系统
- 第6章 类文件结构
- 第7章 虚拟机类加载机制
- 第8章 虚拟机字节码执行引擎
- 第9章 类加载及执行子系统的案例与实战
- 第四部分 程序编译与代码优化
- 第10章 早期(编译期)优化
- 第11章 晚期(运行期)优化
- 第五部分 高效并发
- 第12章 Java 内存模型与线程
- 第13章 线程安全与锁优化
- 附录
- 附录A 编译 Windows 版的 OpenJDK
- 附录B 虚拟机字节码指令表
- 附录C HotSpot 虚拟机主要参数表
- 附录D 对象查询语言(OQL)简介[1]
- 附录E JDK 历史版本轨迹
8.3 方法调用
方法调用并不等同于方法执行,方法调用阶段唯一的任务就是确定被调用方法的版本(即调用哪一个方法),暂时还不涉及方法内部的具体运行过程。在程序运行时,进行方法调用是最普遍、最频繁的操作,但前面已经讲过,Class文件的编译过程中不包含传统编译中的连接步骤,一切方法调用在Class文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址(相当于之前说的直接引用)。这个特性给Java带来了更强大的动态扩展能力,但也使得Java方法调用过程变得相对复杂起来,需要在类加载期间,甚至到运行期间才能确定目标方法的直接引用。
8.3.1 解析
继续前面关于方法调用的话题,所有方法调用中的目标方法在Class文件里面都是一个常量池中的符号引用,在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用,这种解析能成立的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。换句话说,调用目标在程序代码写好、编译器进行编译时就必须确定下来。这类方法的调用称为解析(Resolution)。
在Java语言中符合“编译期可知,运行期不可变”这个要求的方法,主要包括静态方法和私有方法两大类,前者与类型直接关联,后者在外部不可被访问,这两种方法各自的特点决定了它们都不可能通过继承或别的方式重写其他版本,因此它们都适合在类加载阶段进行解析。
与之相对应的是,在Java虚拟机里面提供了5条方法调用字节码指令,分别如下。
invokestatic:调用静态方法。
invokespecial:调用实例构造器<init>方法、私有方法和父类方法。
invokevirtual:调用所有的虚方法。
invokeinterface:调用接口方法,会在运行时再确定一个实现此接口的对象。
invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法,在此之前的4条调用指令,分派逻辑是固化在Java虚拟机内部的,而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的。
只要能被invokestatic和invokespecial指令调用的方法,都可以在解析阶段中确定唯一的调用版本,符合这个条件的有静态方法、私有方法、实例构造器、父类方法4类,它们在类加载的时候就会把符号引用解析为该方法的直接引用。这些方法可以称为非虚方法,与之相反,其他方法称为虚方法(除去final方法,后文会提到)。代码清单8-5演示了一个最常见的解析调用的例子,此样例中,静态方法sayHello()只可能属于类型StaticResolution,没有任何手段可以覆盖或隐藏这个方法。
代码清单8-5 方法静态解析演示
/** *方法静态解析演示 * *@author zzm */ public class StaticResolution{ public static void sayHello(){ System.out.println("hello world"); } public static void main(String[]args){ StaticResolution.sayHello(); } }
使用javap命令查看这段程序的字节码,会发现的确是通过invokestatic命令来调用sayHello()方法的。
D:\Develop\>javap-verbose StaticResolution public static void main(java.lang.String[]); Code: Stack=0,Locals=1,Args_size=1 0:invokestatic#31;//Method sayHello:()V 3:return LineNumberTable: line 15:0 line 16:3
Java中的非虚方法除了使用invokestatic、invokespecial调用的方法之外还有一种,就是被final修饰的方法。虽然final方法是使用invokevirtual指令来调用的,但是由于它无法被覆盖,没有其他版本,所以也无须对方法接收者进行多态选择,又或者说多态选择的结果肯定是唯一的。在Java语言规范中明确说明了final方法是一种非虚方法。
解析调用一定是个静态的过程,在编译期间就完全确定,在类装载的解析阶段就会把涉及的符号引用全部转变为可确定的直接引用,不会延迟到运行期再去完成。而分派(Dispatch)调用则可能是静态的也可能是动态的,根据分派依据的宗量数[1]可分为单分派和多分派。这两类分派方式的两两组合就构成了静态单分派、静态多分派、动态单分派、动态多分派4种分派组合情况,下面我们再看看虚拟机中的方法分派是如何进行的。
[1]这里涉及的分派的相关概念(如“宗量”等)在后文中都会有所解释。
8.3.2 分派
众所周知,Java是一门面向对象的程序语言,因为Java具备面向对象的3个基本特征:继承、封装和多态。本节讲解的分派调用过程将会揭示多态性特征的一些最基本的体现,如“重载”和“重写”在Java虚拟机之中是如何实现的,这里的实现当然不是语法上该如何写,我们关心的依然是虚拟机如何确定正确的目标方法。
1.静态分派
在开始讲解静态分派[1]前,笔者准备了一段经常出现在面试题中的程序代码,读者不妨先看一遍,想一下程序的输出结果是什么。后面我们的话题将围绕这个类的方法来重载(Overload)代码,以分析虚拟机和编译器确定方法版本的过程。方法静态分派如代码清单8-6所示。
代码清单8-6 方法静态分派演示
package org.fenixsoft.polymorphic; /** *方法静态分派演示 *@author zzm */ public class StaticDispatch{ static abstract class Human{ } static class Man extends Human{ } static class Woman extends Human{ } public void sayHello(Human guy){ System.out.println("hello,guy!"); } public void sayHello(Man guy){ System.out.println("hello,gentleman!"); } public void sayHello(Woman guy){ System.out.println("hello,lady!"); } public static void main(String[]args){ Human man=new Man(); Human woman=new Woman(); StaticDispatch sr=new StaticDispatch(); sr.sayHello(man); sr.sayHello(woman); } }
运行结果:
hello,guy! hello,guy!
代码清单8-6中的代码实际上是在考验阅读者对重载的理解程度,相信对Java编程稍有经验的程序员看完程序后都能得出正确的运行结果,但为什么会选择执行参数类型为Human的重载呢?在解决这个问题之前,我们先按如下代码定义两个重要的概念。
Human man=new Man();
我们把上面代码中的“Human”称为变量的静态类型(Static Type),或者叫做的外观类型(Apparent Type),后面的“Man”则称为变量的实际类型(Actual Type),静态类型和实际类型在程序中都可以发生一些变化,区别是静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是在编译期可知的;而实际类型变化的结果在运行期才可确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么。例如下面的代码:
//实际类型变化 Human man=new Man(); man=new Woman(); //静态类型变化 sr.sayHello((Man)man) sr.sayHello((Woman)man)
解释了这两个概念,再回到代码清单8-6的样例代码中。main()里面的两次sayHello()方法调用,在方法接收者已经确定是对象“sr”的前提下,使用哪个重载版本,就完全取决于传入参数的数量和数据类型。代码中刻意地定义了两个静态类型相同但实际类型不同的变量,但虚拟机(准确地说是编译器)在重载时是通过参数的静态类型而不是实际类型作为判定依据的。并且静态类型是编译期可知的,因此,在编译阶段,Javac编译器会根据参数的静态类型决定使用哪个重载版本,所以选择了sayHello(Human)作为调用目标,并把这个方法的符号引用写到main()方法里的两条invokevirtual指令的参数中。
所有依赖静态类型来定位方法执行版本的分派动作称为静态分派。静态分派的典型应用是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的。另外,编译器虽然能确定出方法的重载版本,但在很多情况下这个重载版本并不是“唯一的”,往往只能确定一个“更加合适的”版本。这种模糊的结论在由0和1构成的计算机世界中算是比较“稀罕”的事情,产生这种模糊结论的主要原因是字面量不需要定义,所以字面量没有显式的静态类型,它的静态类型只能通过语言上的规则去理解和推断。代码清单8-7演示了何为“更加合适的”版本。
代码清单8-7 重载方法匹配优先级
package org.fenixsoft.polymorphic; public class Overload{ public static void sayHello(Object arg){ System.out.println("hello Object"); } public static void sayHello(int arg){ System.out.println("hello int"); } public static void sayHello(long arg){ System.out.println("hello long"); } public static void sayHello(Character arg){ System.out.println("hello Character"); } public static void sayHello(char arg){ System.out.println("hello char"); } public static void sayHello(char……arg){ System.out.println("hello char……"); } public static void sayHello(Serializable arg){ System.out.println("hello Serializable"); } public static void main(String[]args){ sayHello('a'); } }
上面的代码运行后会输出:
hello char
这很好理解,'a'是一个char类型的数据,自然会寻找参数类型为char的重载方法,如果注释掉sayHello(char arg)方法,那输出会变为:
hello int
这时发生了一次自动类型转换,'a'除了可以代表一个字符串,还可以代表数字97(字符'a'的Unicode数值为十进制数字97),因此参数类型为int的重载也是合适的。我们继续注释掉sayHello(int arg)方法,那输出会变为:
hello long
这时发生了两次自动类型转换,'a'转型为整数97之后,进一步转型为长整数97L,匹配了参数类型为long的重载。笔者在代码中没有写其他的类型如float、double等的重载,不过实际上自动转型还能继续发生多次,按照char->int->long->float->double的顺序转型进行匹配。但不会匹配到byte和short类型的重载,因为char到byte或short的转型是不安全的。我们继续注释掉sayHello(long arg)方法,那输出会变为:
hello Character
这时发生了一次自动装箱,'a'被包装为它的封装类型java.lang.Character,所以匹配到了参数类型为Character的重载,继续注释掉sayHello(Character arg)方法,那输出会变为:
hello Serializable
这个输出可能会让人感觉摸不着头脑,一个字符或数字与序列化有什么关系?出现hello Serializable,是因为java.lang.Serializable是java.lang.Character类实现的一个接口,当自动装箱之后发现还是找不到装箱类,但是找到了装箱类实现了的接口类型,所以紧接着又发生一次自动转型。char可以转型成int,但是Character是绝对不会转型为Integer的,它只能安全地转型为它实现的接口或父类。Character还实现了另外一个接口java.lang.Comparable<Character>,如果同时出现两个参数分别为Serializable和Comparable<Character>的重载方法,那它们在此时的优先级是一样的。编译器无法确定要自动转型为哪种类型,会提示类型模糊,拒绝编译。程序必须在调用时显式地指定字面量的静态类型,如:sayHello((Comparable<Character>)'a'),才能编译通过。下面继续注释掉sayHello(Serializable arg)方法,输出会变为:
hello Object
这时是char装箱后转型为父类了,如果有多个父类,那将在继承关系中从下往上开始搜索,越接近上层的优先级越低。即使方法调用传入的参数值为null时,这个规则仍然适用。我们把sayHello(Object arg)也注释掉,输出将会变为:
hello char……
7个重载方法已经被注释得只剩一个了,可见变长参数的重载优先级是最低的,这时候字符'a'被当做了一个数组元素。笔者使用的是char类型的变长参数,读者在验证时还可以选择int类型、Character类型、Object类型等的变长参数重载来把上面的过程重新演示一遍。但要注意的是,有一些在单个参数中能成立的自动转型,如char转型为int,在变长参数中是不成立的[2]。
代码清单8-7演示了编译期间选择静态分派目标的过程,这个过程也是Java语言实现方法重载的本质。演示所用的这段程序属于很极端的例子,除了用做面试题为难求职者以外,在实际工作中几乎不可能有实际用途。笔者拿来做演示仅仅是用于讲解重载时目标方法选择的过程,大部分情况下进行这样极端的重载都可算是真正的“关于茴香豆的茴有几种写法的研究”。无论对重载的认识有多么深刻,一个合格的程序员都不应该在实际应用中写出如此极端的重载代码。
另外还有一点读者可能比较容易混淆:笔者讲述的解析与分派这两者之间的关系并不是二选一的排他关系,它们是在不同层次上去筛选、确定目标方法的过程。例如,前面说过,静态方法会在类加载期就进行解析,而静态方法显然也是可以拥有重载版本的,选择重载版本的过程也是通过静态分派完成的。
2.动态分派
了解了静态分派,我们接下来看一下动态分派的过程,它和多态性的另外一个重要体现[3]——重写(Override)有着很密切的关联。我们还是用前面的Man和Woman一起sayHello的例子来讲解动态分派,请看代码清单8-8中所示的代码。
代码清单8-8 方法动态分派演示
package org.fenixsoft.polymorphic; /** *方法动态分派演示 *@author zzm */ public class DynamicDispatch{ static abstract class Human{ protected abstract void sayHello(); } static class Man extends Human{ @Override protected void sayHello(){ System.out.println("man say hello"); } } static class Woman extends Human{ @Override protected void sayHello(){ System.out.println("woman say hello"); } } public static void main(String[]args){ Human man=new Man(); Human woman=new Woman(); man.sayHello(); woman.sayHello(); man=new Woman(); man.sayHello(); } }
运行结果:
man say hello woman say hello woman say hello
这个运行结果相信不会出乎任何人的意料,对于习惯了面向对象思维的Java程序员会觉得这是完全理所当然的。现在的问题还是和前面的一样,虚拟机是如何知道要调用哪个方法的?
显然这里不可能再根据静态类型来决定,因为静态类型同样都是Human的两个变量man和woman在调用sayHello()方法时执行了不同的行为,并且变量man在两次调用中执行了不同的方法。导致这个现象的原因很明显,是这两个变量的实际类型不同,Java虚拟机是如何根据实际类型来分派方法执行版本的呢?我们使用javap命令输出这段代码的字节码,尝试从中寻找答案,输出结果如代码清单8-9所示。
代码清单8-9 main()方法的字节码
public static void main(java.lang.String[]); Code: Stack=2,Locals=3,Args_size=1 0:new#16;//class org/fenixsoft/polymorphic/Dynamic- Dispatch $Man 3:dup 4:invokespecial#18;//Method org/fenixsoft/polymorphic/Dynamic- Dispatch $Man."<init>":()V 7:astore_1 8:new#19;//class org/fenixsoft/polymorphic/Dynamic- Dispatch $Woman 11:dup 12:invokespecial#21;//Method org/fenixsoft/polymorphic/DynamicDispa tch $Woman."<init>":()V 15:astore_2 16:aload_1 17:invokevirtual#22;//Method org/fenixsoft/polymorphic/Dynamic- Dispatch $Human.sayHello:()V 20:aload_2 21:invokevirtual#22;//Method org/fenixsoft/polymorphic/Dynamic- Dispatch $Human.sayHello:()V 24:new#19;//class org/fenixsoft/polymorphic/Dynamic- Dispatch $Woman 27:dup 28:invokespecial#21;//Method org/fenixsoft/polymorphic/Dynam icDispatch $Woman."<init>":()V 31:astore_1 32:aload_1 33:invokevirtual#22;//Method org/fenixsoft/polymorphic/ DynamicDispatch $Human.sayHello:()V 36:return
0~15行的字节码是准备动作,作用是建立man和woman的内存空间、调用Man和Woman类型的实例构造器,将这两个实例的引用存放在第1、2个局部变量表Slot之中,这个动作也就对应了代码中的这两句:
Human man=new Man(); Human woman=new Woman();
接下来的16~21句是关键部分,16、20两句分别把刚刚创建的两个对象的引用压到栈顶,这两个对象是将要执行的sayHello()方法的所有者,称为接收者(Receiver);17和21句是方法调用指令,这两条调用指令单从字节码角度来看,无论是指令(都是invokevirtual)还是参数(都是常量池中第22项的常量,注释显示了这个常量是Human.sayHello()的符号引用)完全一样的,但是这两句指令最终执行的目标方法并不相同。原因就需要从invokevirtual指令的多态查找过程开始说起,invokevirtual指令的运行时解析过程大致分为以下几个步骤:
1)找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C。
2)如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;如果不通过,则返回java.lang.IllegalAccessError异常。
3)否则,按照继承关系从下往上依次对C的各个父类进行第2步的搜索和验证过程。
4)如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。
由于invokevirtual指令执行的第一步就是在运行期确定接收者的实际类型,所以两次调用中的invokevirtual指令把常量池中的类方法符号引用解析到了不同的直接引用上,这个过程就是Java语言中方法重写的本质。我们把这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。
3.单分派与多分派
方法的接收者与方法的参数统称为方法的宗量,这个定义最早应该来源于《Java与模式》一书。根据分派基于多少种宗量,可以将分派划分为单分派和多分派两种。单分派是根据一个宗量对目标方法进行选择,多分派则是根据多于一个宗量对目标方法进行选择。
单分派和多分派的定义读起来拗口,从字面上看也比较抽象,不过对照着实例看就不难理解了。代码清单8-10中列举了一个Father和Son一起来做出“一个艰难的决定”的例子。
代码清单8-10 单分派和多分派
/** *单分派、多分派演示 *@author zzm */ public class Dispatch{ static class QQ{} static class_360{} public static class Father{ public void hardChoice(QQ arg){ System.out.println("father choose qq"); } public void hardChoice(_360 arg){ System.out.println("father choose 360"); } } public static class Son extends Father{ public void hardChoice(QQ arg){ System.out.println("son choose qq"); } public void hardChoice(_360 arg){ System.out.println("son choose 360"); } } public static void main(String[]args){ Father father=new Father(); Father son=new Son(); father.hardChoice(new_360()); son.hardChoice(new QQ()); } }
运行结果:
father choose 360 son choose qq
在main函数中调用了两次hardChoice()方法,这两次hardChoice()方法的选择结果在程序输出中已经显示得很清楚了。
我们来看看编译阶段编译器的选择过程,也就是静态分派的过程。这时选择目标方法的依据有两点:一是静态类型是Father还是Son,二是方法参数是QQ还是360。这次选择结果的最终产物是产生了两条invokevirtual指令,两条指令的参数分别为常量池中指向Father.hardChoice(360)及Father.hardChoice(QQ)方法的符号引用。因为是根据两个宗量进行选择,所以Java语言的静态分派属于多分派类型。
再看看运行阶段虚拟机的选择,也就是动态分派的过程。在执行“son.hardChoice(new QQ())”这句代码时,更准确地说,是在执行这句代码所对应的invokevirtual指令时,由于编译期已经决定目标方法的签名必须为hardChoice(QQ),虚拟机此时不会关心传递过来的参数“QQ”到底是“腾讯QQ”还是“奇瑞QQ”,因为这时参数的静态类型、实际类型都对方法的选择不会构成任何影响,唯一可以影响虚拟机选择的因素只有此方法的接受者的实际类型是Father还是Son。因为只有一个宗量作为选择依据,所以Java语言的动态分派属于单分派类型。
根据上述论证的结果,我们可以总结一句:今天(直至还未发布的Java 1.8)的Java语言是一门静态多分派、动态单分派的语言。强调“今天的Java语言”是因为这个结论未必会恒久不变,C#在3.0及之前的版本与Java一样是动态单分派语言,但在C#4.0中引入了dynamic类型后,就可以很方便地实现动态多分派。
按照目前Java语言的发展趋势,它并没有直接变为动态语言的迹象,而是通过内置动态语言(如JavaScript)执行引擎的方式来满足动态性的需求。但是Java虚拟机层面上则不是如此,在JDK 1.7中实现的JSR-292[4]里面就已经开始提供对动态语言的支持了,JDK 1.7中新增的invokedynamic指令也成为了最复杂的一条方法调用的字节码指令,稍后笔者将专门讲解这个JDK 1.7的新特性。
4.虚拟机动态分派的实现
前面介绍的分派过程,作为对虚拟机概念模型的解析基本上已经足够了,它已经解决了虚拟机在分派中“会做什么”这个问题。但是虚拟机“具体是如何做到的”,可能各种虚拟机的实现都会有些差别。
由于动态分派是非常频繁的动作,而且动态分派的方法版本选择过程需要运行时在类的方法元数据中搜索合适的目标方法,因此在虚拟机的实际实现中基于性能的考虑,大部分实现都不会真正地进行如此频繁的搜索。面对这种情况,最常用的“稳定优化”手段就是为类在方法区中建立一个虚方法表(Vritual Method Table,也称为vtable,与此对应的,在invokeinterface执行时也会用到接口方法表——Inteface Method Table,简称itable),使用虚方法表索引来代替元数据查找以提高性能。我们先看看代码清单8-10所对应的虚方法表结构示例,如图8-3所示。
图 8-3 方法表结构
虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方法表里面的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。如果子类中重写了这个方法,子类方法表中的地址将会替换为指向子类实现版本的入口地址。图8-3中,Son重写了来自Father的全部方法,因此Son的方法表没有指向Father类型数据的箭头。但是Son和Father都没有重写来自Object的方法,所以它们的方法表中所有从Object继承来的方法都指向了Object的数据类型。
为了程序实现上的方便,具有相同签名的方法,在父类、子类的虚方法表中都应当具有一样的索引序号,这样当类型变换时,仅需要变更查找的方法表,就可以从不同的虚方法表中按索引转换出所需的入口地址。
方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始值后,虚拟机会把该类的方法表也初始化完毕。
上文中笔者说方法表是分派调用的“稳定优化”手段,虚拟机除了使用方法表之外,在条件允许的情况下,还会使用内联缓存(Inline Cache)和基于“类型继承关系分析”(Class Hierarchy Analysis,CHA)技术的守护内联(Guarded Inlining)两种非稳定的“激进优化”手段来获得更高的性能,关于这两种优化技术的原理和运作过程,读者可以参考本书第11章中的相关内容。
[1]严格来说,Dispatch这个词一般不用在静态环境之中,英文技术文档的称呼是“Method Overload Resolution”,但国内的各种资料都普遍将这种行为翻译成“静态分派”,特此说明。
[2]重载中选择最合适方法的过程,可参见Java语言规范的§15.12.2.5 Choosing the Most Specific Method章节。
[3]有一种观点认为:因为重载是静态的,重写是动态的,所以只有重写算是多态性的体现,重载不算多态。笔者认为这种争论没有意义,概念仅仅是说明问题的一种工具而已。
[4]JSR-292:Supporting Dynamically Typed Languages on the Java Platform(Java平台的动态语言支持)。
8.3.3 动态类型语言支持
Java虚拟机的字节码指令集的数量从Sun公司的第一款Java虚拟机问世至JDK 7来临之前的十余年时间里,一直没有发生任何变化。随着JDK 7的发布,字节码指令集终于迎来了第一位新成员——invokedynamic指令。这条新增加的指令是JDK 7实现“动态类型语言”(Dynamically Typed Language)支持而进行的改进之一,也是为JDK 8可以顺利实现Lambda表达式做技术准备。在本节中,我们将详细讲解JDK 7这项新特性出现的前因后果和它的深远意义。
1.动态类型语言
在介绍Java虚拟机的动态类型语言支持之前,我们要先弄明白动态类型语言是什么?它与Java语言、Java虚拟机有什么关系?了解JDK 1.7提供动态类型语言支持的技术背景,对理解这个语言特性是很有必要的。
什么是动态类型语言[1]?动态类型语言的关键特征是它的类型检查的主体过程是在运行期而不是编译期,满足这个特征的语言有很多,常用的包括: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的值才具有类型,编译时最多只能确定方法名称、参数、返回值这些信息,而不会去确定方法所在的具体类型(即方法接收者不固定)。“变量无类型而变量值才有类型”这个特点也是动态类型语言的一个重要特征。
了解了动态和静态类型语言的区别后,也许读者的下一个问题就是动态、静态类型语言两者谁更好,或者谁更加先进?这种比较不会有确切答案,因为它们都有自己的优点,选择哪种语言是需要经过权衡的。静态类型语言在编译期确定类型,最显著的好处是编译器可以提供严谨的类型检查,这样与类型相关的问题能在编码的时候就及时发现,利于稳定性及代码达到更大规模。而动态类型语言在运行期确定类型,这可以为开发人员提供更大的灵活性,某些在静态类型语言中需用大量“臃肿”代码来实现的功能,由动态类型语言来实现可能会更加清晰和简洁,清晰和简洁通常也就意味着开发效率的提升。
2.JDK 1.7与动态类型
回到本节的主题,来看看Java语言、虚拟机与动态类型语言之间有什么关系。Java虚拟机毫无疑问是Java语言的运行平台,但它的使命并不仅限于此,早在1997年出版的《Java虚拟机规范》中就规划了这样一个愿景:“在未来,我们会对Java虚拟机进行适当的扩展,以便更好地支持其他语言运行于Java虚拟机之上”。而目前确实已经有许多动态类型语言运行于Java虚拟机之上了,如Clojure、Groovy、Jython和JRuby等,能够在同一个虚拟机上可以达到静态类型语言的严谨性与动态类型语言的灵活性,这是一件很美妙的事情。
但遗憾的是,Java虚拟机层面对动态类型语言的支持一直都有所欠缺,主要表现在方法调用方面:JDK 1.7以前的字节码指令集中,4条方法调用指令(invokevirtual、invokespecial、invokestatic、invokeinterface)的第一个参数都是被调用的方法的符号引用(CONSTANT_Methodref_info或者CONSTANT_InterfaceMethodref_info常量),前面已经提到过,方法的符号引用在编译时产生,而动态类型语言只有在运行期才能确定接收者类型。这样,在Java虚拟机上实现的动态类型语言就不得不使用其他方式(如编译时留个占位符类型,运行时动态生成字节码实现具体类型到占位符类型的适配)来实现,这样势必让动态类型语言实现的复杂度增加,也可能带来额外的性能或者内存开销。尽管可以利用一些办法(如Call Site Caching)让这些开销尽量变小,但这种底层问题终归是应当在虚拟机层次上去解决才最合适,因此在Java虚拟机层面上提供动态类型的直接支持就成为了Java平台的发展趋势之一,这就是JDK 1.7(JSR-292)中invokedynamic指令以及java.lang.invoke包出现的技术背景。
3.java.lang.invoke包
JDK 1.7实现了JSR-292,新加入的java.lang.invoke包[2]就是JSR-292的一个重要组成部分,这个包的主要目的是在之前单纯依靠符号引用来确定调用的目标方法这种方式以外,提供一种新的动态确定目标方法的机制,称为MethodHandle。这种表达方式也许不太好懂?那不妨把MethodHandle与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语言也可以拥有类似于函数指针或者委托的方法别名的工具了。代码清单8-11演示了MethodHandle的基本用途,无论obj是何种类型(临时定义的ClassA抑或是实现PrintStream接口的实现类System.out),都可以正确地调用到println()方法。
代码清单8-11 MethodHandle演示
import static java.lang.invoke.MethodHandles.lookup; import java.lang.invoke.MethodHandle; import java.lang.invoke.MethodType; /** *JSR-292 Method Handle基础用法演示 *@author zzm */ 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中的3个方法——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语言。
4.invokedynamic指令
本节一开始就提到了JDK 1.7为了更好地支持动态类型语言,引入了第5条方法调用的字节码指令invokedynamic,之后一直没有再提到它,甚至把代码清单8-11中使用MethodHandle的示例代码反编译后也不会看见invokedynamic的身影,它的应用之处在哪里呢?
在某种程度上,invokedynamic指令与MethodHandle机制的作用是一样的,都是为了解决原有4条“invoke*”指令方法分派规则固化在虚拟机之中的问题,把如何查找目标方法的决定权从虚拟机转嫁到具体用户代码之中,让用户(包含其他语言的设计者)有更高的自由度。而且,它们两者的思路也是可类比的,可以把它们想象成为了达成同一个目的,一个采用上层Java代码和API来实现,另一个用字节码和Class中其他属性、常量来完成。因此,如果理解了前面的MethodHandle例子,那么理解invokedynamic指令也并不困难。
每一处含有invokedynamic指令的位置都称做“动态调用点”(Dynamic Call Site),这条指令的第一个参数不再是代表方法符号引用的CONSTANT_Methodref_info常量,而是变为JDK 1.7新加入的CONSTANT_InvokeDynamic_info常量,从这个新常量中可以得到3项信息:引导方法(Bootstrap Method,此方法存放在新增的BootstrapMethods属性中)、方法类型(MethodType)和名称。引导方法是有固定的参数,并且返回值是java.lang.invoke.CallSite对象,这个代表真正要执行的目标方法调用。根据CONSTANT_InvokeDynamic_info常量中提供的信息,虚拟机可以找到并且执行引导方法,从而获得一个CallSite对象,最终调用要执行的目标方法。我们还是举一个实际的例子来解释这个过程,如代码清单8-12所示。
代码清单8-12 invokedynamic指令演示
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的作用基本上是一样的,虽然笔者没有加以注释,但是阅读起来应当不困难。本书前面提到过,由于invokedynamic指令所面向的使用者并非Java语言,而是其他Java虚拟机之上的动态语言,因此仅依靠Java语言的编译器Javac没有办法生成带有invokedynamic指令的字节码(曾经有一个java.dyn.InvokeDynamic的语法糖可以实现,但后来被取消了),所以要使用Java语言来演示invokedynamic指令只能用一些变通的办法。John Rose(Da Vinci Machine Project的Leader)编写了一个把程序的字节码转换为使用invokedynamic的简单工具INDY[3]来完成这件事情,我们要使用这个工具来产生最终要的字节码,因此这个示例代码中的方法名称不能随意改动,更不能把几个方法合并到一起写,因为它们是要被INDY工具读取的。
把上面代码编译、再使用INDY转换后重新生成的字节码如代码清单8-13所示(结果使用javap输出,因版面原因,精简了许多无关的内容)。
代码清单8-13 invokedynamic指令演示(2)
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."<in it>":(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指令的调用过程到此就宣告完成了。
5.掌控方法分派规则
invokedynamic指令与前面4条“invoke*”指令的最大差别就是它的分派逻辑不是由虚拟机决定的,而是由程序员决定。在介绍Java虚拟机动态语言支持的最后一个小结中,笔者通过一个简单例子(如代码清单8-14所示),帮助读者理解程序员在可以掌控方法分派规则之后,能做什么以前无法做到的事情。
代码清单8-14 方法调用问题
class GrandFather{ void thinking(){ System.out.println("i am grandfather"); } } class Father extends GrandFather{ void thinking(){ System.out.println("i am father"); } } class Son extends Father{ void thinking(){ //请读者在这里填入适当的代码(不能修改其他地方的代码) //实现调用祖父类的thinking()方法,打印"i am grandfather" } }
在Java程序中,可以通过“super”关键字很方便地调用到父类中的方法,但如果要访问祖类的方法呢?读者在阅读本书下面提供的解决方案之前,不妨自己思考一下,在JDK 1.7之前有没有办法解决这个问题。
在JDK 1.7之前,使用纯粹的Java语言很难处理这个问题(直接生成字节码就很简单,如使用ASM等字节码工具),原因是在Son类的thinking()方法中无法获取一个实际类型是GrandFather的对象引用,而invokevirtual指令的分派逻辑就是按照方法接收者的实际类型进行分派,这个逻辑是固化在虚拟机中的,程序员无法改变。在JDK 1.7中,可以使用代码清单8-15中的程序来解决这个问题。
代码清单8-15 使用MethodHandle来解决相关问题
import static java.lang.invoke.MethodHandles.lookup; import java.lang.invoke.MethodHandle; import java.lang.invoke.MethodType; class Test{ class GrandFather{ void thinking(){ System.out.println("i am grandfather"); } } class Father extends GrandFather{ void thinking(){ System.out.println("i am father"); } } class Son extends Father{ void thinking(){ try{ MethodType mt=MethodType.methodType(void.class); MethodHandle mh=lookup().findSpecial(GrandFather.class,"thinking",mt,getClass()); mh.invoke(this); }catch(Throwable e){ } } } public static void main(String[]args){ (new Test().new Son()).thinking(); } }
运行结果:
i am grandfather
[1]注意,动态类型语言与动态语言、弱类型语言并不是一个概念,需要区别对待。
[2]这个包在很长一段时间里称为java.dyn,也曾经短暂更名为java.lang.mh,如果读者在其他资料上看到这两个包名,可以把它们理解为java.lang.invoke。
[3]INDY下载地址:http://blogs.oracle.com/jrose/entry/a_modest_tool_for_writing。
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论