jvm004 解析与分派
解析
所有方法调用中的目标方法在Class文件里面都是常量池中的符号引用,在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用。这种解析的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的,即“编译期可知,运行期不可变”,这类目标的方法的调用称为解析(Resolution)。
分派
解析调用一定是个静态的过程,在编译期就完全确定,在类加载的解析阶段就将涉及的符号引用全部转变为可以确定的直接引用,不会延迟到运行期 再去完成。而分派(Dispatch)调用则可能是静态的也可能是动态的。于是分派方式就有静态分派和动态分派。
java是一门面向对象的编程语言,具备面向对象的三个基本特征:继承、封装、多态。
接下来我将演示下分派调用过程来揭示多态性的一些最基本的体现。如重载(Overload)和重写(Override)在jvm中是如何实现的。
1、静态分派
静态分派发生在编译阶段,不是由jvm执行的,典型的应用是重载(Overload)。
静态分派的最直接的解释是在重载的时候是通过参数的静态类型而不是实际类型作为判断依据的。因此在编译阶段,Javac编译器会根据参数的静态类型决定使用哪个重载版本。
方法静态分派代码演示:
/** * 方法静态分派 * * @author sun * */ 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,sun"); } public void sayHello(Woman guy) { System.out.println("hello,ting"); } public static void main(String[] args) { Human man = new Man(); Human woman = new Woman(); StaticDispatch staticDispatch = new StaticDispatch(); staticDispatch.sayHello(man); staticDispatch.sayHello(woman); } }
运行结果:
hello,guy
hello,guy
对于有经验的程序员来说,出现上面的结果并不感到惊讶。接下来我们要分析为什么会出现这样的结果,但在分析之前,我们要明确两个概念。
即变量的静态类型和变量的实际类型。如下:
Human man = new Man();
Human 称为变量的静态类型;Man称为变量的实际类型。二者的区别在于静态类型在编译期是可知的,而实际类型是在运行期才确定,编译时并不知道一个对象的实际类型是什么。
// 实际类型变化 Human man = new Man(); man = new Woman(); // 静态类型变化 sr.sayHello((Man) man); sr.sayHello((Woman) man);
在方法接收者已经确定是对象sr的前提下,使用哪个重载版本就完全取决于传入参数的数量和数据类型。代码中刻意使用了两个静态类型相同而实际类型不同的变量,编译器在重载时是通过参数的静态类型而不是实际类型作为判定依据的,man和woman的静态类型都是Human。静态类型在编译期可知,因此在编译阶段,编译期根据man和woman的静态类型为Human的事实,来选择public void sayHello(Human guy)作为调用方法,这就是方法重载的具体体现。
所有依赖静态类型来定位方法执行版本的分派动作称为静态分派。
2、动态分派
动态分派和多态性的体现-重写(Override)有着本质的联系。
/** * 方法动态分派 * * @author sun * */ public class DynemicDispatch { 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
显然这里不可能根据静态类型来决定调用那个方法。导致这个现象很明显的原因是因为这两个变量的实际类型不一样,jvm根据实际类型来分派方法执行版本。我们使用javap命令来查看这段代码的字节码。以下是该段代码main方法的字节码:
public static void main(java.lang.String[]); Code: 0: new #16 // class Demo/DynemicDispatch$Man 3: dup 4: invokespecial #18 // Method Demo/DynemicDispatch$Man."<init>":()V 7: astore_1 8: new #19 // class Demo/DynemicDispatch$Woman 11: dup 12: invokespecial #21 // Method Demo/DynemicDispatch$Woman."<init>":()V 15: astore_2 16: aload_1 17: invokevirtual #22 // Method Demo/DynemicDispatch$Human.sayHello:()V 20: aload_2 21: invokevirtual #22 // Method Demo/DynemicDispatch$Human.sayHello:()V 24: new #19 // class Demo/DynemicDispatch$Woman 27: dup 28: invokespecial #21 // Method Demo/DynemicDispatch$Woman."<init>":()V 31: astore_1 32: aload_1 33: invokevirtual #22 // Method Demo/DynemicDispatch$Human.sayHello:()V 36: return
0~15行的字节码作用是建立man和woman的内存空间、调用Man和Woman类型的实例构造器的。
16、20两句把刚刚创建的两个对象的引用压到栈顶,这两个对象是将要执行的sayHello()方法的所有者,称为接收者(Receiver)!
17、21两句是方法调用指令,这两句指令最终的目标方法并不相同。
这需要说说jvm的invokevirtual指令了,这个指令的解析过程有助于我们更深刻理解重写的本质。该指令的具体解析过程如下:
-
找到操作数栈栈顶的第一个元素所指向的对象的实际类型,记为C
-
如果在类型C中找到与常量中描述符和简单名称都相符的方法,则进行访问权限的校验,如果通过则返回这个方法的直接引用,查找结束;如果不通过,则返回非法访问异常
-
如果在类型C中没有找到,则按照继承关系从下到上依次对C的各个父类进行第2步的搜索和验证过程
-
如果始终没有找到合适的方法,则抛出抽象方法错误的异常
从这个过程可以发现,在第一步的时候就在运行期确定接收者的实际类型,所以当调用invokevirtual指令就会把运行时常量池中符号引用解析为不同的直接引用,这就是方法重写的本质。我们把这种在运行期根据实际类型确定方法执行版本的分派称为动态分派。