向上转型与后期(动态)绑定
在java程序的设计中,经常想把一个对象不当作它所属的特定类型来对待,而是将其当作其基类(父类)的对象来对待。例如对于下面这个例子(来自于《Java编程思想》):
上图是一个图形的类族,其中Circle、Square、Triangle三个导出类继承Shape这个基类。每一个导出类都实现了erase()和draw()这两个方法。如果有定义这样一个方法:
void doSomething(Shape shape){
shape.erase();
//...
shape.draw();
}
并在程序中其它部分这样调用:
Circle circle = new Circle();
Triangle triangle = new triangle();
doSomething(circle);
doSomething(triangle);
这里会正确调用circle和traingle的draw()和erase()方法,即对doSomething()的调用会自动地正确处理,而不管传入对象的确切类型。
也就是说,Circle可以被doSomething()看作是Shape,doSomething()可以给Shape发送的任何消息,Circle都可以接收(在这里,给某个类发送消息可以看成是调用这个类的方法)。
把导出类看作是它的基类的过程称为向上转型(upcasting)。
向上转型是如何实现的?
从上述例子中可以看出,doSomething()方法接受的是一个Shape引用,那么编译器是如何知道这个Shape引用指向的是一个Circle对象而不是一个Triangle对象呢?实际上,编译器无法得知。为了深入理解这个问题,需要引入“绑定”这个概念。
将 一个方法调用 同 一个方法主体 关联起来称为绑定。
若在程序执行前进行绑定(编译时实现),叫做前期绑定。它是面向过程语言中默认的绑定方式,例如C。如果使用前期绑定,肯定实现不了向上转型。而解决的方法就是后期绑定,它的含义是在运行时根据对象的类型进行绑定,后期绑定也称为动态绑定或运行时绑定。可以想到,后期绑定的实现机制应该是在对象中安置某种“类型信息”,以便在运行时能判断对象的类型。Java中除了static方法和final方法之外,其它所有的方法默认都是动态绑定。
后期(动态)绑定与重写(Override)的本质
那么问题来了:类型信息是怎么安置的?Java后期绑定的机制具体是怎么实现的?
以下内容并不在《Java编程思想》中,而是《深入理解Java虚拟机》的内容。
这要涉及到Java虚拟机(JVM)加载Java代码的详细过程。我们都知道Java代码的执行过程是先编译成字节码(class)文件,再由JVM解释执行的,由于篇幅关系,我们只关心方法调用部分。
JVM提供了5条方法调用字节码指令,其中有一条,叫做invokevirtual,这条指令的作用是调用所有的虚方法。什么是虚方法?虚方法是除了以下两类以外的方法:
(1)不是由invokevirtual指令调用的:有静态方法、私有方法、构造方法和父类方法4类
(2)final方法,虽然由invokevirtual指令调用,但不是虚方法
invokevirtual指令调用一个虚方法时,解析过程大致是这样的:
(1)找到操作数栈顶的第一个元素所指向的对象的实际类型,记作M。
(2)如果在类型M中找到与常量中的描述符和简单名称相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;如果不通过,则返回java.lang.IllegalAccessError异常。
(3)否则,按照继承关系从下往上依次对M的各个父类进行第二步的搜索和验证过程。
(4)如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。
上面这段话有很多生词,不过我们只需要关注第一步,它的意思就是在运行期确定接收者的实际类型。“接收者”即调用方法的那个对象,如shape.draw();
中的shape;“实际类型”即子类的类型,比如Shape circle = new Circle();
中,“Shape”是变量circle的静态类型(Static Type)或叫外观类型(Apparent Type),后面的“Circle”则称为变量的实际类型(Actual Type)。也就是说,在如下的代码中
void doSomething(Shape shape){
shape.erase();
//...
shape.draw();
}
//...
Circle circle = new Circle();
Triangle triangle = new triangle();
doSomething(circle);
doSomething(triangle);
两次调用中的invokevirtual指令在执行的第一步就把常量池中的类方法 符号引用 解析到了不同的直接引用上,这个过程就是Java语言中方法重写的本质,这种运行期根据对象实际类型进行绑定的方法就是后期绑定,也叫动态绑定。
后期(动态)绑定的实现方法
前面的内容已经解释了JVM在动态绑定中“会做什么”这个问题,但是虚拟机“具体是如何做到的”,可能各种虚拟机的实现都会有差别。在动态绑定中,最常用的方法就是为类在方法区中建立一个虚方法表(Virtual Method Table,也成为vtable),使用虚方法表索引来代替元数据查找以提高性能,如图所示:
虚方法表中存放着各个方法的实际入口地址。如果某个方法没有被重写,那子类的虚方法表里面的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。如果子类中重写了这个方法,子类方法表中的地址将会替换为指向子类实现版本的入口地址。上图中,Circle重写了来自Shape的全部方法,因此Circle的方法表并没有指向Shape类型数据的箭头。但是Shape和Circle都没有重写来自Object的方法,所以它们的方法表中所有从Object类继承来的方法都指向了Object数据类型。
为了程序实现上的方便,具有相同签名的方法,在父类、子类的虚方法表中都应当具有一样的索引序号,这样当类型变换时,仅需要变更查找的方法表,就可以从不同的虚方法表中按索引转换出所需的入口地址。
方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始值后,虚拟机会把该类的方法表也初始化完毕。
除了方法表之外,虚拟机还会使用内联缓存(Inline Cache)和基于“类型继承关系分析”(Class Hierarchy Analysis, CHA)技术的守护内联(Guarded Inlining)两种技术,感兴趣的同学可以自行查找资料,这里就不多作研究了。