一、定义
多态就是指程序中定义的引用变量所指向的具体类型和通过该引用变量发出的方法调用在编译时并不确定,而是在程序运行期间才确定,即一个引用变量倒底会指向哪个类的实例对象,该引用变量发出的方法调用到底是哪个类中实现的方法,必须在由程序运行期间才能决定。因为在程序运行时才确定具体的类,这样,不用修改源程序代码,就可以让引用变量绑定到各种不同的类实现上,从而导致该引用调用的具体方法随之改变,即不修改程序代码就可以改变程序运行时所绑定的具体代码,让程序可以选择多个运行状态,这就是多态性。
二、多态的机制
本质上多态分两种:
1、编译时多态(又称静态多态)
2、运行时多态(又称动态多态)
重载(overload)就是编译时多态的一个例子,编译时多态在编译时就已经确定,运行时运行的时候调用的是确定的方法。
我们通常所说的多态指的都是运行时多态,也就是编译时不确定究竟调用哪个具体方法,一直延迟到运行时才能确定。这也是为什么有时候多态方法又被称为延迟方法的原因。
下面简要介绍一下运行时多态(以下简称多态)的机制。
多态通常有两种实现方法:
1、子类继承父类(extends)
2、类实现接口(implements)
无论是哪种方法,其核心之处就在于对父类方法的改写或对接口方法的实现,以取得在运行时不同的执行效果。
要使用多态,在声明对象时就应该遵循一条法则:声明的总是父类类型或接口类型,创建的是实际类型。举例来说,假设我们要创建一个ArrayList对象,声明就应该采用这样的语句:
List list =newArrayList();而不是
ArrayList list =newArrayList();在定义方法参数时也通常总是应该优先使用父类类型或接口类型,例如某方法应该写成:
publicvoid doSomething(List list);而不是
publicvoid doSomething(ArrayList list);这样声明最大的好处在于结构的灵活性:假如某一天我认为ArrayList的特性无法满足我的要求,我希望能够用LinkedList来代替它,那么只需要在对象创建的地方把new ArrayList()改为new LinkedList即可,其它代码一概不用改动。
虚拟机会在执行程序时动态调用实际类的方法,它会通过一种名为动态绑定(又称延迟绑定)的机制自动实现,这个过程对程序员来说是透明的。
三、多态的实现
下面从虚拟机运行时的角度来简要介绍多态的实现原理,这里以Java虚拟机(Java Virtual Machine, JVM)规范的实现为例。
在JVM执行Java字节码时,类型信息被存放在方法区中,通常为了优化对象调用方法的速度,方法区的类型信息中增加一个指针,该指针指向一张记录该类方法入口的表(称为方法表),表中的每一项都是指向相应方法的指针。
方法表的构造如下:
由于Java的单继承机制,一个类只能继承一个父类,而所有的类又都继承自Object类。方法表中最先存放的是Object类的方法,接下来是该类的父类的方法,最后是该类本身的方法。这里关键的地方在于,如果子类改写了父类的方法,那么子类和父类的那些同名方法共享一个方法表项,都被认作是父类的方法。
注意这里只有非私有的实例方法才会出现,并且静态方法也不会出现在这里,原因很容易理解:静态方法跟对象无关,可以将方法地址直接引用,而不像实例方法需要间接引用。
更深入地讲,静态方法是由虚拟机指令invokestatic调用的,私有方法和构造函数则是由invokespecial指令调用,只有被invokevirtual和invokeinterface指令调用的方法才会在方法表中出现。
由于以上方法的排列特性(Object——父类——子类),使得方法表的偏移量总是固定的。例如,对于任何类来说,其方法表中equals方法的偏移量总是一个定值,所有继承某父类的子类的方法表中,其父类所定义的方法的偏移量也总是一个定值。
前面说过,方法表中的表项都是指向该类对应方法的指针,这里就开始了多态的实现:
假设Class A是Class B的子类,并且A改写了B的方法method(),那么在B的方法表中,method方法的指针指向的就是B的method方法入口。
而对于A来说,它的方法表中的method方法则会指向其自身的method方法而非其父类的(这在类加载器载入该类时已经保证,同时JVM会保证总是能从对象引用指向正确的类型信息)。
结合方法指针偏移量是固定的以及指针总是指向实际类的方法域,我们不难发现多态的机制就在这里:
在调用方法时,实际上必须首先完成实例方法的符号引用解析,结果是该符号引用被解析为方法表的偏移量。虚拟机通过对象引用得到方法区中类型信息的入口,查询类的方法表,当将子类对象声明为父类类型时,形式上调用的是父类方法,此时虚拟机会从实际类的方法表(虽然声明的是父类,但是实际上这里的类型信息中存放的是子类的信息)中查找该方法名对应的指针(这里用“查找”实际上是不合适的,前面提到过,方法的偏移量是固定的,所以只需根据偏移量就能获得指针),进而就能指向实际类的方法了。
我们的故事还没有结束,事实上上面的过程仅仅是利用继承实现多态的内部机制,多态的另外一种实现方式:实现接口相比而言就更加复杂,原因在于,Java的单继承保证了类的线性关系,而接口可以同时实现多个,这样光凭偏移量就很难准确获得方法的指针。所以在JVM中,多态的实例方法调用实际上有两种指令:
invokevirtual指令用于调用声明为类的方法;
invokeinterface指令用于调用声明为接口的方法。
当使用invokeinterface指令调用方法时,就不能采用固定偏移量的办法,只能老老实实挨个找了(当然实际实现并不一定如此,JVM规范并没有规定究竟如何实现这种查找,不同的JVM实现可以有不同的优化算法来提高搜索效率)。我们不难看出,在性能上,调用接口引用的方法通常总是比调用类的引用的方法要慢。这也告诉我们,在类和接口之间优先选择接口作为设计并不总是正确的,当然设计问题不在本文探讨的范围之内,但显然具体问题具体分析仍然不失为更好的选择。
个人见解:多态机制包括静态多态(编译时多态)和动态多态(运行时多态),静态多态比如说重载,动态多态是在编译时不能确定调用哪个方法,得在运行时确定。动态多态的实现方法包括子类继承父类和类实现接口。当多个子类上转型(不知道这么说对不)时,对象掉用的是相应子类的方法,这种实现是与JVM有关的。
四.方法表
基于基类的调用和基于接口的调用,从性能上来讲,基于基类的调用性能更高 。因为invokevirtual是基于偏移量的方式来查找方法的,而invokeinterface是基于搜索的。
概述
多态是面向对象程序设计的重要特性。多态允许基类的引用指向派生类的对象,而在具体访问时实现方法的动态绑定。
java对方法动态绑定的实现方法主要基于方法表,但是这里分两种调用方式invokevirtual和invokeinterface,即类引用调用和接口引用调用。类引用调用只需要修改方法表的指针就可以实现动态绑定(具有相同签名的方法,在父类、子类的方法表中具有相同的索引号),而接口引用调用需要扫描整个方法表才能实现动态绑定(因为,一个类可以实现多个接口,另外一个类可能只实现一个接口,无法具有相同的索引号。这句如果没有看懂,继续往下看,会有例子。写到这里,感觉自己看书时,有的时候也会不理解,看不懂,思考一段时间,还是不明白,做个标记,继续阅读吧,然后回头再看,可能就豁然开朗。)。
类引用调用的大致过程为:java编译器将java源代码编译成class文件,在编译过程中,会根据静态类型将调用的符号引用写到class文件中。在执行时,JVM根据class文件找到调用方法的符号引用,然后在静态类型的方法表中找到偏移量,然后根据this指针确定对象的实际类型,使用实际类型的方法表,偏移量跟静态类型中方法表的偏移量一样,如果在实际类型的方法表中找到该方法,则直接调用,否则,按照继承关系从下往上搜索。
下面对上面的描述做具体的分析讨论。
JVM的运行时结构
从上图可以看出,当程序运行时,需要某个类时,类载入子系统会将相应的class文件载入到JVM中,并在内部建立该类的类型信息,这个类型信息其实就是class文件在JVM中存储的一种数据结构,他包含着java类定义的所有信息,包括方法代码,类变量、成员变量、以及本博文要重点讨论的方法表。这个类型信息就存储在方法区。
注意,这个方法区中的类型信息跟在堆中存放的class对象是不同的。在方法区中,这个class的类型信息只有唯一的实例(所以是各个线程共享的内存区域),而在堆中可以有多个该class对象。可以通过堆中的class对象访问到方法区中类型信息。就像在java反射机制那样,通过class对象可以访问到该类的所有信息一样。
方法表是实现动态调用的核心。方法表存放在方法区中的类型信息中。方法表中存放有该类定义的所有方法及指向方法代码的指针。这些方法中包括从父类继承的所有方法以及自身重写(override)的方法。
类引用调用invokevirtual
代码如下:
package org.fan.learn.methodTable;
/**
* Created by fan on 2016/3/30.
*/
public class ClassReference {
static class Person {
@Override
public String toString(){
return "I'm a person.";
}
public void eat(){
System.out.println("Person eat");
}
public void speak(){
System.out.println("Person speak");
}
}
static class Boy extends Person{
@Override
public String toString(){
return "I'm a boy";
}
@Override
public void speak(){
System.out.println("Boy speak");
}
public void fight(){
System.out.println("Boy fight");
}
}
static class Girl extends Person{
@Override
public String toString(){
return "I'm a girl";
}
@Override
public void speak(){
System.out.println("Girl speak");
}
public void sing(){
System.out.println("Girl sing");
}
}
public static void main(String[] args) {
Person boy = new Boy();
Person girl = new Girl();
System.out.println(boy);
boy.eat();
boy.speak();
//boy.fight();
System.out.println(girl);
girl.eat();
girl.speak();
//girl.sing();
}
}
注意,boy.fight();
和 girl.sing();
这两个是有问题的,在IDEA中会提示“Cannot resolve method ‘fight()’”。因为,方法的调用是有静态类型检查的,而boy和girl的静态类型都是Person类型的,在Person中没有fight方法和sing方法。因此,会报错。
执行结果如下:
从上图可以看到,boy.eat()
和 girl.eat()
调用产生的输出都是”Person eat”,因为Boy和Girl中没有override 父类的eat方法。
字节码指令:
public static void main(java.lang.String[]);
Code:
Stack=2, Locals=3, Args_size=1
0: new #2; //class ClassReference$Boy
3: dup
4: invokespecial #3; //Method ClassReference$Boy."<init>":()V
7: astore_1
8: new #4; //class ClassReference$Girl
11: dup
12: invokespecial #5; //Method ClassReference$Girl."<init>":()V
15: astore_2
16: getstatic #6; //Field java/lang/System.out:Ljava/io/PrintStream;
19: aload_1
20: invokevirtual #7; //Method java/io/PrintStream.println:(Ljava/lang/Object;)V
23: aload_1
24: invokevirtual #8; //Method ClassReference$Person.eat:()V
27: aload_1
28: invokevirtual #9; //Method ClassReference$Person.speak:()V
31: getstatic #6; //Field java/lang/System.out:Ljava/io/PrintStream;
34: aload_2
35: invokevirtual #7; //Method java/io/PrintStream.println:(Ljava/lang/Object;)V
38: aload_2
39: invokevirtual #8; //Method ClassReference$Person.eat:()V
42: aload_2
43: invokevirtual #9; //Method ClassReference$Person.speak:()V
46: return
其中所有的invokevirtual调用的都是Person类中的方法。
下面看看java对象的内存模型:
从上图可以清楚地看到调用方法的指针指向。而且可以看出相同签名的方法在方法表中的偏移量是一样的。这个偏移量只是说Boy方法表中的继承自Object类的方法、继承自Person类的方法的偏移量与Person类中的相同方法的偏移量是一样的,与Girl是没有任何关系的。
下面再看看调用过程,以girl.speak()
方法的调用为例。在我的字节码中,这条指令对应43: invokevirtual #9; //Method ClassReference$Person.speak:()V
,为了便于使用IBM的图,这里采用跟IBM一致的符号引用:invokevirtual #12;
。调用过程图如下所示:
(1)在常量池中找到方法调用的符号引用
(2)查看Person的方法表,得到speak方法在该方法表的偏移量(假设为15),这样就得到该方法的直接引用。
(3)根据this指针确定方法接收者(girl)的实际类型
(4)根据对象的实际类型得到该实际类型对应的方法表,根据偏移量15查看有无重写(override)该方法,如果重写,则可以直接调用;如果没有重写,则需要拿到按照继承关系从下往上的基类(这里是Person类)的方法表,同样按照这个偏移量15查看有无该方法。
接口引用调用invokeinterface
代码如下:
package org.fan.learn.methodTable;
/**
* Created by fan on 2016/3/29.
*/
public class InterfaceReference {
interface IDance {
void dance();
}
static class Person {
@Override
public String toString() {
return "I'm a person";
}
public void speak() {
System.out.println("Person speak");
}
public void eat() {
System.out.println("Person eat");
}
}
static class Dancer extends Person implements IDance {
@Override
public String toString() {
return "I'm a Dancer";
}
@Override
public void speak() {
System.out.println("Dancer speak");
}
public void dance() {
System.out.println("Dancer dance");
}
}
static class Snake implements IDance {
@Override
public String toString() {
return "I'm a Snake";
}
public void dance() {
System.out.println("Snake dance");
}
}
public static void main(String[] args) {
IDance dancer = new Dancer();
System.out.println(dancer);
dancer.dance();
//dancer.speak();
//dancer.eat();
IDance snake = new Snake();
System.out.println(snake);
snake.dance();
}
}
上面的代码中dancer.speak(); dancer.eat();
这两句同样不能调用。
执行结果如下所示:
其字节码指令如下所示:
public static void main(java.lang.String[]);
Code:
Stack=2, Locals=3, Args_size=1
0: new #2; //class InterfaceReference$Dancer
3: dup
4: invokespecial #3; //Method InterfaceReference$Dancer."<init>":()V
7: astore_1
8: getstatic #4; //Field java/lang/System.out:Ljava/io/PrintStream;
11: aload_1
12: invokevirtual #5; //Method java/io/PrintStream.println:(Ljava/lang/Object;)V
15: aload_1
16: invokeinterface #6, 1; //InterfaceMethod InterfaceReference$IDance.dance:()V
21: new #7; //class InterfaceReference$Snake
24: dup
25: invokespecial #8; //Method InterfaceReference$Snake."<init>":()V
28: astore_2
29: getstatic #4; //Field java/lang/System.out:Ljava/io/PrintStream;
32: aload_2
33: invokevirtual #5; //Method java/io/PrintStream.println:(Ljava/lang/Object;)V
36: aload_2
37: invokeinterface #6, 1; //InterfaceMethod InterfaceReference$IDance.dance:()V
42: return
从上面的字节码指令可以看到,dancer.dance();
和snake.dance();
的字节码指令都是invokeinterface #6, 1; //InterfaceMethod InterfaceReference$IDance.dance:()V
。
为什么invokeinterface指令会有两个参数呢?
对象的内存模型如下所示:
从上图可以看到IDance接口中的方法dance()在Dancer类的方法表中的偏移量跟在Snake类的方法表中的偏移量是不一样的,因此无法仅根据偏移量来进行方法的调用。(这句话在理解时,要注意,只是为了强调invokeinterface在查找方法时不再是基于偏移量来实现的,而是基于搜索的方式。)应该这么说,dance方法在IDance方法表(如果有的话)中的偏移量与在Dancer方法表中的偏移量是不一样的。
因此,要在Dancer的方法表中找到dance方法,必须搜索Dancer的整个方法表。
下面写一个,如果Dancer中没有重写(override)toString方法,会发生什么?
代码如下:
package org.fan.learn.methodTable;
/**
* Created by fan on 2016/3/29.
*/
public class InterfaceReference {
interface IDance {
void dance();
}
static class Person {
@Override
public String toString() {
return "I'm a person";
}
public void speak() {
System.out.println("Person speak");
}
public void eat() {
System.out.println("Person eat");
}
}
static class Dancer extends Person implements IDance {
// @Override
// public String toString() {
// return "I'm a Dancer";
// }
@Override
public void speak() {
System.out.println("Dancer speak");
}
public void dance() {
System.out.println("Dancer dance");
}
}
static class Snake implements IDance {
@Override
public String toString() {
return "I'm a Snake";
}
public void dance() {
System.out.println("Snake dance");
}
}
public static void main(String[] args) {
IDance dancer = new Dancer();
System.out.println(dancer);
dancer.dance();
//dancer.speak();
//dancer.eat();
IDance snake = new Snake();
System.out.println(snake);
snake.dance();
}
}
执行结果如下:
可以看到System.out.println(dancer);
调用的是Person的toString方法。
内存模型如下所示:
结束语
这篇博文讨论了invokevirtual和invokeinterface的内部实现的区别,以及override的实现原理。下一步,打算讨论下invokevirtual的具体实现细节,如:如何实现符号引用到直接引用的转换的?可能会看下OpenJDK底层的C++实现。
参考资料
- 周志明 《深入理解JAVA虚拟机》
- IBM 多态在 Java 和 C++ 编程语言中的实现比较