<<深入理解java虚拟机>>
方法调用并不等同于方法中的代码被执行,方法调用阶段唯一的任务就是确定被调用方法的版本 (即调用哪一个方法),暂时还未涉及方法内部的具体运行过程
1.解析
1)什么是解析?
所有方法调用的目标方法在Class文件里面都是一个常量池中的符 号引用,在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用,这种解析能够成立的前 提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不 可改变的。
解析调用一定是个静态的过程,调用目标在程序代码写好、编译器进行编译那一刻就已经确定下来。在类加载的解析阶段就会把涉及的符号 引用全部转变为明确的直接引用,不必延迟到运行期再去完成,这类方法 的调用被称为解析(Resolution)。
2)哪类方法 的调用被称为解析?
符合“编译期可知,运行期不可变”,主要有静态方法、私有方法、实例构造器、父类方法(通过super.调用)4种,再加上被final 修饰的方法(尽管它使用invokevirtual指令调用),这5种方法调用会在类加载的时候就可以把符号引 用解析为该方法的直接引用。这些方法统称为“非虚方法”(Non-Virtual Method),与之相反,其他方 法就被称为“虚方法”(Virtual Method)。
调用不同类型的方法,字节码指令集里设计了不同的指令。在Java虚拟机支持以下5条方法调用字 节码指令,分别是:
·invokestatic。用于调用静态方法。
·invokespecial。用于调用实例构造器<init>()方法、私有方法和父类中的方法。
·invokevirtual。用于调用所有的虚方法。
·invokeinterface。用于调用接口方法,会在运行时再确定一个实现该接口的对象。
·invokedynamic。先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法。前面4 条调用指令,分派逻辑都固化在Java虚拟机内部,而invokedynamic指令的分派逻辑是由用户设定的引 导方法来决定的。
非虚方法
如果这个方法在编译器就确定了具体的调用版本,在运行时是不可以变的,称之为非虚方法。(也就是说在编译期间能够确定调用是子类方法还是父类方法,子类不能重写或者显示调用了父类方法)
虚方法
除了非虚方法的其它方法都为虚方法。
我们在使用多态的时候,是动态绑定,编译期间并不知道调用的是子类的方法还是父类的方法,只有在运行时才能确定下来。
上述讲解,大家可能不是很明白,简单来说就是:
①一个方法如果不会被重写(private 不会被继承、static会被继承不会被被重写、final能继承不能重写、构造器不能被继承),或者是显示调用的父类某个方法(super.),那么这些方法被称为非虚方法
②非虚方法在编译期间就能确定下来,运行期间不会改变,在类加载的解析阶段就会将常量池中的对非虚方法的符号引用,转变为直接引用
③对非虚方法的调用(此调用不是指执行具体方法指令,指确定具体调用哪个方法)称为解析(调用)
2.分派
分派调用过程将会揭示多态性特征的一些最基本的体现,如“重载”和“重写”在 Java虚拟机之中是如何实现的,也就是虚拟机如何 确定正确的目标方法。
1)静态分派
所有依赖静态类型来决定方法执行版本的分派动作,都称为静态分派。静态分派的最典型应用表 现就是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行 的,这点也是为何一些资料选择把它归入“解析”而不是“分派”的原因。
接下来通过一个例子解释什么是静态类型。
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”称为变量的“静态类型”(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()方法中的强制转型)临时改变这个类型,但这个改变仍是在编译期是可知的。
回到上面的例子:
在重载时,使用哪个重载版 本,完全取决于传入参数的数量和数据类型。虚拟机(或者准确地说是编译器)在重载时是通过参数的静态类型而不是实际类型作为 判定依据的。
由于静态类型在编译期可知,所以在编译阶段,Javac编译器就根据参数的静态类型决定 了会使用哪个重载版本,因此选择了sayHello(Human)作为调用目标,并把这个方法的符号引用写到 main()方法里的两条invokevirtual指令的参数中。
注意:Javac编译器虽然能确定出方法的重载版本,但在很多情况下这个重载版本并不是“唯 一”的,往往只能确定一个“相对更合适的”版本。(比如:上述例子中,还存在static class Human extends Monkey { }; public void sayHello(Monkey mk){};)
2)动态分派
这种在运行期根据实 际类型确定方法执行版本的分派过程称为动态分派。它与Java语言多态性的另外 一个重要体现——重写(Override)有着很密切的关联。
例子:
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虚拟机是 如何根据实际类型来分派方法执行版本的呢?使用javap命令输出这段代码的字节码
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=1
0: new #2 // class cn/hanna/jvm/chapter01/DynamicDispatch$Man
3: dup
4: invokespecial #3 // Method cn/hanna/jvm/chapter01/DynamicDispatch$Man."<init>":()V
7: astore_1
8: new #4 // class cn/hanna/jvm/chapter01/DynamicDispatch$Woman
11: dup
12: invokespecial #5 // Method cn/hanna/jvm/chapter01/DynamicDispatch$Woman."<init>":()V
15: astore_2
16: aload_1
17: invokevirtual #6 // Method cn/hanna/jvm/chapter01/DynamicDispatch$Human.sayHello:()V
20: aload_2
21: invokevirtual #6 // Method cn/hanna/jvm/chapter01/DynamicDispatch$Human.sayHello:()V
24: new #4 // class cn/hanna/jvm/chapter01/DynamicDispatch$Woman
27: dup
28: invokespecial #5 // Method cn/hanna/jvm/chapter01/DynamicDispatch$Woman."<init>":()V
31: astore_1
32: aload_1
33: invokevirtual #6 // Method cn/hanna/jvm/chapter01/DynamicDispatch$Human.sayHello:()V
36: return
0~15行的字节码是准备动作,作用是建立man和woman的内存空间、调用Man和Woman类型的实 例构造器,将这两个实例的引用存放在第1、2个局部变量表的变量槽中,这些动作实际对应了Java源 码中的这两行:
Human man = new Man();
Human woman = new Woman();
接下来的16~21行是关键部分,16和20行的aload指令分别把刚刚创建的两个对象的引用压到栈 顶,这两个对象是将要执行的sayHello()方法的所有者,称为接收者(Receiver);17和21行是方法调用指令,这两条调用指令单从字节码角度来看,无论是指令(都是invokevirtual)还是参数(都是常量 池中第22项的常量,注释显示了这个常量是Human.sayHello()的符号引用)都完全一样,但是这两句指 令最终执行的目标方法并不相同。那看来解决问题的关键还必须从invokevirtual指令本身入手,invokevirtual指令的运行时解析过程[4]大致分为以下几步:
1)找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C。
2)如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果 通过则返回这个方法的直接引用,查找过程结束;不通过则返回java.lang.IllegalAccessError异常。
3)否则,按照继承关系从下往上依次对C的各个父类进行第二步的搜索和验证过程。
4)如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。
正是因为invokevirtual指令执行的第一步就是在运行期确定接收者的实际类型,所以两次调用中的 invokevirtual指令并不是把常量池中方法的符号引用解析到直接引用上就结束了,还会根据方法接收者 的实际类型来选择方法版本,这个过程就是Java语言中方法重写的本质。
注意:但是字段是不参与多态的,也就是说当子类声明了与父类同名的字段时,虽然在子类的内存中两 个字段都会存在,但是子类的字段会遮蔽父类的同名字段,父类无法调用子类的同名字段。
补充一点:子类的构造函数执行时才会被初始化
也就是说public Men(){
private int money = 8;
super(); // 在父类构造方法没执行完之前,子类对象还未初始化,(money=0)一旦出了super()子类对象中的属性就会初始化了(money=8)。
}
3)分派与多分派
方法的接收者(方法调用者)与方法的参数统称为方法的宗量,根据分派基于多少种宗量,可以将分派划分为单分派和多分派两种。单分派是根据一个宗量对 目标方法进行选择,多分派则是根据多于一个宗量对目标方法进行选择。
举例说明:
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
我们关注的首先是编译阶段中编译器的选择过程,也就是静态分派的过程。这时候选 择目标方法的依据有两点:一是静态类型是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语言是一门静态多分派、动态单分派的语言。
4)虚拟机动态分派的实现
动态分派是执行非常频繁的动作,而且动态分派的方法版本选择过程需要运行时在接收者类型的 方法元数据中搜索合适的目标方法,因此,Java虚拟机实现基于执行性能的考虑,真正运行时一般不 会如此频繁地去反复搜索类型元数据。面对这种情况,一种基础而且常见的优化手段是为类型在方法 区中建立一个虚方法表(Virtual Method Table,也称为vtable,与此对应的,在invokeinterface执行时也 会用到接口方法表——Interface Method Table,简称itable),使用虚方法表索引来代替元数据查找以 提高性能。
虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方 法表中的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。如果子类中重写了 这个方法,子类虚方法表中的地址也会被替换为指向子类实现版本的入口地址。在图8-3中,Son重写 了来自Father的全部方法,因此Son的方法表没有指向Father类型数据的箭头。但是Son和Father都没有 重写来自Object的方法,所以它们的方法表中所有从Object继承来的方法都指向了Object的数据类型。
为了程序实现方便,具有相同签名的方法,在父类、子类的虚方法表中都应当具有一样的索引序 号,这样当类型变换时,仅需要变更查找的虚方法表,就可以从不同的虚方法表中按索引转换出所需 的入口地址。虚方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始值后,虚拟机会把 该类的虚方法表也一同初始化完毕。
查虚方法表是分派调用的一种优化手段,为了进一步提高性能,还会使用类型继 承关系分析(Class Hierarchy Analysis,CHA)、守护内联(Guarded Inlining)、内联缓存(Inline Cache)等多种非稳定的激进优化来争取更大的性能空间。
解析与分派这两者之间的关系并不是二选一的 排他关系,它们是在不同层次上去筛选、确定目标方法的过程。例如前面说过静态方法会在编译期确 定、在类加载期就进行解析,而静态方法显然也是可以拥有重载版本的,选择重载版本的过程也是通 过静态分派完成的。