解析
所有方法调用的目标方法在Class文件里面都是一个常量池中的符
号引用,在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用,这种解析能够成立的前
提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不
可改变的。换句话说,调用目标在程序代码写好、编译器进行编译那一刻就已经确定下来。这类方法
的调用被称为解析(Resolution)。
在Java语言中符合“编译期可知,运行期不可变”这个要求的方法,主要有静态方法和私有方法两
大类,前者与类型直接关联,后者在外部不可被访问,这两种方法各自的特点决定了它们都不可能通
过继承或别的方式重写出其他版本,因此它们都适合在类加载阶段进行解析。(静态方法、私有方法、实例构造器、父类方法4种,再加上被final
修饰的方法(尽管它使用invokevirtual指令调用),这5种方法调用会在类加载的时候就可以把符号引
用解析为该方法的直接引用。这些方法统称为“非虚方法”(Non-Virtual Method),与之相反,其他方
法就被称为“虚方法”(Virtual Method)。)
Java中的非虚方法除了使用invokestatic、invokespecial调用的方法之外还有一种,就是被final修饰
的实例方法。虽然由于历史设计的原因,final方法是使用invokevirtual指令来调用的,但是因为它也无
法被覆盖,没有其他版本的可能,所以也无须对方法接收者进行多态选择,又或者说多态选择的结果
肯定是唯一的。
分派
方法静态分派演示
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!为什么虚拟机会选择执行参数类型为Human的重载版本呢?
Human man = new Man();我们把上面代码中的“Human”称为变量的“静态类型”(Static Type),或者叫“外观类
型”(Apparent Type),后面的“Man”则被称为变量的“实际类型”(Actual Type)或者叫“运行时类
型”(Runtime Type)。静态类型和实际类型在程序中都可能会发生变化,区别是静态类型的变化仅仅
在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是在编译期可知的;而实际类
型变化的结果在运行期才可确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么。
// 实际类型变化
Human human = (new Random()).nextBoolean() ? new Man() : new Woman();
// 静态类型变化
sr.sayHello((Man) human)
sr.sayHello((Woman) human)对象human的实际类型是可变的,编译期间它完全是个“薛定谔的人”,到底是Man还是Woman,必
须等到程序运行到这行的时候才能确定。而human的静态类型是Human,也可以在使用时(如
sayHello()方法中的强制转型)临时改变这个类型,但这个改变仍是在编译期是可知的,两次sayHello()
方法的调用,在编译期完全可以明确转型的是Man还是Woman。
解释清楚了静态类型与实际类型的概念,main()里面的两次sayHello()方法调用,在方法接收者已经确定是对象“sr”的前提下,使用哪个重载版
本,就完全取决于传入参数的数量和数据类型。代码中故意定义了两个静态类型相同,而实际类型不
同的变量,但虚拟机(或者准确地说是编译器)在重载时是通过参数的静态类型而不是实际类型作为
判定依据的。由于静态类型在编译期可知,所以在编译阶段,Javac编译器就根据参数的静态类型决定
了会使用哪个重载版本,因此选择了sayHello(Human)作为调用目标。
需要注意Javac编译器虽然能确定出方法的重载版本,但在很多情况下这个重载版本并不是“唯
一”的,往往只能确定一个“相对更合适的”版本。
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(intarg)方法,那输出会变为:
hello 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>的重载方法,那它们在此时的优先级是一样的。编译器无法确定要自动转型为
哪种类型,会提示“类型模糊”(Type Ambiguous),并拒绝编译。程序必须在调用时显式地指定字面
量的静态类型,如:sayHello((Comparable<Character>)'a'),才能编译通过。但是如果读者愿意花费一点
时间,绕过Javac编译器,自己去构造出表达相同语义的字节码,将会发现这是能够通过Java虚拟机的
类加载校验,而且能够被Java虚拟机正常执行的,但是会选择Serializable还是Comparable<Character>的
重载方法则并不能事先确定,这是《Java虚拟机规范》所允许的,在第7章介绍接口方法解析过程时曾
经提到过。
下面继续注释掉sayHello(Serializable arg)方法,输出会变为:
hello Object这时是char装箱后转型为父类了,如果有多个父类,那将在继承关系中从下往上开始搜索,越接
上层的优先级越低。即使方法调用传入的参数值为null时,这个规则仍然适用。我们把sayHello(Object
arg)也注释掉,输出将会变为:
hello char ...7个重载方法已经被注释得只剩1个了,可见变长参数的重载优先级是最低的,这时候字符'a'被当
作了一个char[]数组的元素。但是要注意的是,有一些
在单个参数中能成立的自动转型,如char转型为int,在变长参数中是不成立的。
动态分派
它与Java语言多态性的另外
一个重要体现[3]——重写(Override)有着很密切的关联。
/**
* 方法动态分派演示
* @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因为静态类型同样都是Human
的两个变量man和woman在调用sayHello()方法时产生了不同的行为,甚至变量man在两次调用中还执行
了两个不同的方法。导致这个现象的原因很明显,是因为这两个变量的实际类型不同。
运行时解析过程[4]大致分为以下几步:
1)找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C。
2)如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果
通过则返回这个方法的直接引用,查找过程结束;不通过则返回java.lang.IllegalAccessError异常。
3)否则,按照继承关系从下往上依次对C的各个父类进行第二步的搜索和验证过程。
4)如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。
以下演示字段不参与多态:
/**
* 字段不参与多态
* @author zzm
*/
public class FieldHasNoPolymorphic {
static class Father {
public int money = 1;
public Father() {
money = 2;
showMeTheMoney();
}
public void showMeTheMoney() {
System.out.println("I am Father, i have $" + money);
}
}
static class Son extends Father {
public int money = 3;
public Son() {
money = 4;
showMeTheMoney();
}
public void showMeTheMoney() {
System.out.println("I am Son, i have $" + money);
}
}
public static void main(String[] args) {
Father gay = new Son();
System.out.println("This gay has $" + gay.money);
}
}
运行后输出结果为:
I am Son, i have $0
I am Son, i have $4
This gay has $2输出两句都是“I am Son”,这是因为Son类在创建的时候,首先隐式调用了Father的构造函数,而
Father构造函数中对showMeTheMoney()的调用是一次虚方法调用,实际执行的版本是
Son::showMeTheMoney()方法,所以输出的是“I am Son”,这点经过前面的分析相信读者是没有疑问的
了。而这时候虽然父类的money字段已经被初始化成2了,但Son::showMeTheMoney()方法中访问的却
是子类的money字段,这时候结果自然还是0,因为它要到子类的构造函数执行时才会被初始化。
main()的最后一句通过静态类型访问到了父类中的money,输出了2。
因为方法调用需要invokevirtual指令,会根据方法接收者
的实际类型来选择方法版本,这个过程就是Java语言中方法重写的本质。字段不使用这条指令所以不使用多态。
动态类型语言
何谓动态类型语言[1]?动态类型语言的关键特征是它的类型检查的主体过程是在运行期而不是编
译期进行的,满足这个特征的语言有很多,常用的包括:APL、Clojure、Erlang、Groovy、
JavaScript、Lisp、Lua、PHP、Prolog、Python、Ruby、Smalltalk、Tcl,等等。那相对地,在编译期就
进行类型检查过程的语言,譬如C++和Java等就是最常用的静态类型语言。
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这个符号引用包含了该方法定义在哪个具体类型之中、方法的名字以及参数顺序、参数类型和方
法返回值等信息,通过这个符号引用,Java虚拟机就可以翻译出该方法的直接引用。而ECMAScript等
动态类型语言与Java有一个核心的差异就是变量obj本身并没有类型,变量obj的值才具有类型,所以编
译器在编译时最多只能确定方法名称、参数、返回值这些信息,而不会去确定方法所在的具体类型
(即方法接收者不固定)。“变量无类型而变量值才有类型”这个特点也是动态类型语言的一个核心特
征。
静态类型语言能够在编译期确定变量类型,最显著的好处是编译器可以提供全面严谨的
类型检查,这样与数据类型相关的潜在问题就能在编码时被及时发现,利于稳定性及让项目容易达到
更大的规模。而动态类型语言在运行期才确定类型,这可以为开发人员提供极大的灵活性,某些在静
态类型语言中要花大量臃肿代码来实现的功能,由动态类型语言去做可能会很清晰简洁,清晰简洁通
常也就意味着开发效率的提升。(具体可参见python和java)
Java与动态类型
java虚拟机一开始就希望有其他语言能运行在该虚拟机上面,现在也已经有了一部分,如
Clojure、Groovy、Jython和JRuby等。
但遗憾的是Java虚拟机层面对动态类型语言的支持一直都还有所欠缺,主要表现在方法调用方
面:JDK 7以前的字节码指令集中,4条方法调用指令(invokevirtual、invokespecial、invokestatic、
invokeinterface)的第一个参数都是被调用的方法的符号引用(CONSTANT_Methodref_info或者
CONSTANT_InterfaceMethodref_info常量),前面已经提到过,方法的符号引用在编译时产生,而动
态类型语言只有在运行期才能确定方法的接收者。这样,在Java虚拟机上实现的动态类型语言就不得
不使用“曲线救国”的方式(如编译时留个占位符类型,运行时动态生成字节码实现具体类型到占位符
类型的适配)来实现,但这样势必会让动态类型语言实现的复杂度增加,也会带来额外的性能和内存
开销。内存开销是很显而易见的,方法调用产生的那一大堆的动态类就摆在那里。而其中最严重的性
能瓶颈是在于动态类型方法调用时,由于无法确定调用对象的静态类型,而导致的方法内联无法有效
进行。在第11章里我们会讲到方法内联的重要性,它是其他优化措施的基础,也可以说是最重要的一
项优化。
var arrays = {"abc", new ObjectX(), 123, Dog, Cat, Car..}
for(item in arrays){
item.sayHello();
}在动态类型语言下这样的代码是没有问题,但由于在运行时arrays中的元素可以是任意类型,即使
它们的类型中都有sayHello()方法,也肯定无法在编译优化的时候就确定具体sayHello()的代码在哪里,
编译器只能不停编译它所遇见的每一个sayHello()方法,并缓存起来供执行时选择、调用和内联,如果
arrays数组中不同类型的对象很多,就势必会对内联缓存产生很大的压力,缓存的大小总是有限的,类
型信息的不确定性导致了缓存内容不断被失效和更新,先前优化过的方法也可能被不断替换而无法重
复使用。所以这种动态类型方法调用的底层问题终归是应当在Java虚拟机层次上去解决才最合适。因
此,在Java虚拟机层面上提供动态类型的直接支持就成为Java平台发展必须解决的问题,这便是JDK 7
时JSR-292提案中invokedynamic指令以及java.lang.invoke包出现的技术背景。
java.lang.invoke包
import static java.lang.invoke.MethodHandles.lookup;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodType;
/**
* JSR 292 MethodHandle基础用法演示
* @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);
}
}运行结果:
icyfenix以上使用MethodHandle 可也达到动态语言的效果,比如js,只要有这个方法,不需关心类型就可以执行(类似c语言函数指针,可以指向某一个函数)。
·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语言而已,而且Java在这里并不是主
角。
掌控方法分派规则
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"
}
}使用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();
}
}使用JDK 7 Update 9之前的HotSpot虚拟机运行,会得到如下运行结果:
i am grandfather但是这个逻辑在JDK 7 Update 9之后被视作一个潜在的安全性缺陷修正了,原因是必须保证
findSpecial()查找方法版本时受到的访问约束(譬如对访问控制的限制、对参数类型的限制)应与使用
invokespecial指令一样,两者必须保持精确对等,包括在上面的场景中它只能访问到其直接父类中的方
法版本。所以在JDK 7 Update 10修正之后,运行以上代码只能得到如下结果:
i am father那在新版本的JDK中,上面的问题是否能够得到解决呢?答案是可以的,如果读者去查看
MethodHandles.Lookup类的代码,将会发现需要进行哪些访问保护,在该API实现时是预留了后门
的。访问保护是通过一个allowedModes的参数来控制,而且这个参数可以被设置成“TRUSTED”来绕开
所有的保护措施。尽管这个参数只是在Java类库本身使用,没有开放给外部设置,但我们通过反射可
以轻易打破这种限制。由此,我们可以把代码清单8-16中子类的thinking()方法修改为如下所示的代码
来解决问题:
void thinking() {
try {
MethodType mt = MethodType.methodType(void.class);
Field lookupImpl = MethodHandles.Lookup.class.getDeclaredField("IMPL_LOOKUP");
lookupImpl.setAccessible(true);
MethodHandle mh = ((MethodHandles.Lookup) lookupImpl.get(null)).findSpecial(GrandFather.class,"thinking", mt, GrandFather.class);
mh.invoke(this);
} catch (Throwable e) {
}
}
运行以上代码,在目前所有JDK版本中均可获得如下结果:
i am grandfather