对多态的理解,结合《Think in java》和《深入理解JVM》
有一种观点认为:因为重载是静态的(编译阶段就已经确定调用的方法版本,是静态分派,和虚拟机无关),重写是动态的,所以只有重写算是多态性的体现,而重载不算多态。我认为这种争论是没有意义的,概念仅仅是说明问题的一种工具。本文只讨论重写。
理解多态就要理解下面这几个词的意思:向上转型和后期绑定。
在《Think in java》中是这样阐述多态的:多态(也称作动态绑定,后期绑定或者运行时绑定)的作用是从来消除类型之间的耦合关系,能够改善代码的组织结构和可读性,还能够创建可扩展的程序。多态方法调用允许一种类型表现出与其它相似类型之间的区别,只要它们从同一基类导出而来的。这种区别是根据方法的行为不同而表示出来的,虽然这些方法都可以通过同一个基类调用。
下面从《Think in java》中的一段代码入手:
public class Instrument {
public void play(){
System.out.println("Instrument play");
}
}
public class Windextends Instrument{
public void play(){
System.out.println("Wind play");
}
}
public class Music {
public static void tune(Instrumenti){
i.play();
}
public static void main(String[]args) {
Wind flute = new Wind();
tune(flute);
}
}
从代码中我们可以看到Music.tune()方法接受的是一个Instrument的引用,同时也可以接受其子类的引用,当wind的引用传递到这个方法的时候,就会被向上转型其父类的引用。那这里有一个疑问了,我们为什么不直接使用wind的引用呢,何必故意忘记一个对象的类型而使用其父类的类型?
public class Brassextends Instrument{
public void play(){
System.out.println("Brass play");
}
}
如果还有一个Instrument的子类需要调用tune方法,按前面所说tune方法中都使用子类的引用的话,那么也要为Brass 类重新编写一个新的tune方法。这样就导致了代码的可扩展性不强。
多态简单的理解就是一个引用在不同情况下的多种状态(通过指向父类的指针,类调用在不同子类中实现的方法。)
那么这里就又有一个疑问了,编译器怎么知道Instrument的引用指向的是Wind对象还是Brass对象。实际上编译器无法得知,是由JVM去确定的。下面我们研究下运行期绑定这个话题。
方法调用并不等于方法执行,方法调用阶段唯一的任务就是确定被调用方法的版本(即调用哪一个方法)。Class文件的编译过程中不包含传统编译中的连接步骤,一切方法调用在Class文件里面存储的只是符号引用,而不是方法在实际运行时内存布局中的入口地址(直接引用)。这个特性给Java带来了更强大的动态扩展能力,但也使得Java方法调用过程变得相对复杂起来,需要在类加载期间甚至到运行期间才能确定目标方法的直接引用。
在类加载的解析阶段会把一部分符号引用转化为直接引用,前提是在方法运行期之前就能确定方法的调用版本,并且方法的调用版本在运行期不可改变。而符合条件的只有static方法和private方法(前期绑定)。
以jdk1.6为例(java1.8新加了invokedynamic指令,在后文中详细解释)。
JVM提供了四条方法调用字节码指令,分别是:
Invokestatic:调用静态方法。
Invokespecial:调用实例构造器<init>方法,私有方法和父类方法。
Invokevirtual:调用所有的虚方法。
Invokeinterface:调用接口方法,会在运行时再确定一个实现此接口的对象。
只要能被invokstatic和invokespecial指令调用的方法,都可以在解析阶段确定为唯一的调用版本。符合这个条件的有静态方法,私有方法,实例构造器和父类方法。这些方法在类加载的时候就将符号引用转化为直接引用。这些方法称为非虚方法,虽然final方法也是用invokevirtual指令进行调用,但是它由于无法被覆盖,所以也就无法进行多态的选择,也是一种非虚方法。(把方法命名为final的好处一是方法无法被覆盖,二是关闭动态绑定,提高执行效率。)
package test2;
public class Windextends Instrument{
public void play(){
super.play(); //测父类方法
}
private void test(){
}
public final void testFinal(){
}
public static void main(String[] args) {
Wind w = new Wind();
w.play();
w.test();//测私有方法
w.testFinal();//测final方法
}
}
//上述符合条件的四种方法所所对应的字节码指令
0 aload_0
1 invokespecial #15 <test2/Instrument.play>
4 return
0 new #1 <test2/Wind>
3 dup
4 invokespecial #21 <test2/Wind.<init>>
7 astore_1
8 aload_1
9 invokevirtual #22 <test2/Wind.play>
12 aload_1
13 invokespecial #23 <test2/Wind.test>
16 aload_1
17 invokevirtual #25 <test2/Wind.testFinal>
20 return
除了上述这几种情况其余的方法均为后期绑定。
下面解释一下JVM是怎么确定所要执行的对象的方法的。
继续看前文所述代码:
package test2;
public class Test {
public static void main(String[] args) {
Instrument i1 =new Wind();
Instrument i2 =new Brass();
i1.play();
i2.play();
}
}
所对应的字节码指令为:
0 new #16 <test2/Wind>
3 dup
4 invokespecial #18 <test2/Wind.<init>>
7 astore_1
8 new #19 <test2/Brass>
11 dup
12 invokespecial #21 <test2/Brass.<init>>
15 astore_2
16 aload_1
17 invokevirtual #22 <test2/Instrument.play>
20 aload_2
21 invokevirtual #22 <test2/Instrument.play>
24 return
在17和21行我们可以看到无论是字节码指令还是所调用的常量池中的常量都完全一样。那么invokevirtual指令是如何进行多态查找的?
1) 找到操作数栈顶的第一个元素所指向的对象的实际类型,记为C。
2) 如果在C中能找到所匹配的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束,不通过则返回java.lang.IllegalAcessor异常。
3) 否则按照继承关系从下往上依次对C的父类进行第二步的操作。
4) 如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。
在这里Instrument称为变量的静态类型,Wind则称为变量的实际类型。(参见《深入理解JVM第八章》)。执行第一步的时候C所指向的实际类型就为wind,而在wind中就能找到所匹配的play方法,如果wind中找不到,则会在Instrument中去找play方法。我们把这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。
我们知道了虚拟机在动态分派中会做什么,那JVM是怎么做到的呢?不同的虚拟机在实现上都会有所差别。
由于动态分派是非常繁琐的,虚拟机基于性能的考虑通常会为类在方法区中建立一个虚方法表。使用虚方法表索引来替代元数据的查找。
虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那么子类的虚方法表里面的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。