众所周知,多态是面向对象编程语言的重要特性,它容许基类的指针或引用指向派生类的对象,而在具体访问时实现方法的动态绑定。C++ 和 Java 做为当前最为流行的两种面向对象编程语言,其内部对于多态的支持究竟是如何实现的呢,本文对此作了全面的介绍。 编程
注意到在本文中,指针和引用会互换使用,它们仅是一个抽象概念,表示和另外一个对象的链接关系,无须在乎其具体的实现。 app
Java 对于方法调用动态绑定的实现主要依赖于方法表,但经过类引用调用和接口引用调用的实现则有所不一样。整体而言,当某个方法被调用时,JVM 首先要查找相应的常量池,获得方法的符号引用,并查找调用类的方法表以肯定该方法的直接引用,最后才真正调用该方法。如下分别对该过程当中涉及到的相关部分 作详细介绍。 编程语言
典型的 Java 虚拟机的运行时结构以下图所示 函数
图 1.JVM 运行时结构 this
此 结构中,咱们只探讨和本文密切相关的方法区 (method area)。当程序运行须要某个类的定义时,载入子系统 (class loader subsystem) 装入所需的 class 文件,并在内部创建该类的类型信息,这个类型信息就存贮在方法区。类型信息通常包括该类的方法代码、类变量、成员变量的定义等等。能够说,类型信息就是类 的 Java 文件在运行时的内部结构,包含了改类的全部在 Java 文件中定义的信息。 spa
注意到,该类型信息和 class 对象是不一样的。class 对象是 JVM 在载入某个类后于堆 (heap) 中建立的表明该类的对象,能够经过该 class 对象访问到该类型信息。好比最典型的应用,在 Java 反射中应用 class 对象访问到该类支持的全部方法,定义的成员变量等等。能够想象,JVM 在类型信息和 class 对象中维护着它们彼此的引用以便互相访问。二者的关系能够类比于进程对象与真正的进程之间的关系。 设计
Java 的方法调用有两类,动态方法调用与静态方法调用。静态方法调用是指对于类的静态方法的调用方式,是静态绑定的;而动态方法调用须要有方法调用所做用的对 象,是动态绑定的。类调用 (invokestatic) 是在编译时刻就已经肯定好具体调用方法的状况,而实例调用 (invokevirtual) 则是在调用的时候才肯定具体的调用方法,这就是动态绑定,也是多态要解决的核心问题。 指针
JVM 的方法调用指令有四个,分别是 invokestatic,invokespecial,invokesvirtual 和 invokeinterface。前两个是静态绑定,后两个是动态绑定的。本文也能够说是对于 JVM 后两种调用实现的考察。 code
常量池中保存的是一个 Java 类引用的一些常量信息,包含一些字符串常量及对于类的符号引用信息等。Java 代码编译生成的类文件中的常量池是静态常量池,当类被载入到虚拟机内部的时候,在内存中产生类的常量池叫运行时常量池。 对象
常量池在逻辑上能够分红多个表,每一个表包含一类的常量信息,本文只探讨对于 Java 调用相关的常量池表。
CONSTANT_Utf8_info
字符串常量表,该表包含该类所使用的全部字符串常量,好比代码中的字符串引用、引用的类名、方法的名字、其余引用的类与方法的字符串描述等等。其他常量池表中所涉及到的任何常量字符串都被索引至该表。
CONSTANT_Class_info
类信息表,包含任何被引用的类或接口的符号引用,每个条目主要包含一个索引,指向 CONSTANT_Utf8_info 表,表示该类或接口的全限定名。
CONSTANT_NameAndType_info
名字类型表,包含引用的任意方法或字段的名称和描述符信息在字符串常量表中的索引。
CONSTANT_InterfaceMethodref_info
接口方法引用表,包含引用的任何接口方法的描述信息,主要包括类信息索引和名字类型索引。
CONSTANT_Methodref_info
类方法引用表,包含引用的任何类型方法的描述信息,主要包括类信息索引和名字类型索引。
图 2. 常量池各表的关系
可 以看到,给定任意一个方法的索引,在常量池中找到对应的条目后,能够获得该方法的类索引(class_index)和名字类型索引 (name_and_type_index), 进而获得该方法所属的类型信息和名称及描述符信息(参数,返回值等)。注意到全部的常量字符串都是存储在 CONSTANT_Utf8_info 中供其余表索引的。
方法表是动态调用的核心,也是 Java 实现动态调用的主要方式。它被存储于方法区中的类型信息,包含有该类型所定义的全部方法及指向这些方法代码的指针,注意这些具体的方法代码多是被覆写的方法,也多是继承自基类的方法。
若有类定义 Person, Girl, Boy,
清单 1
class Person { public String toString(){ return "I'm a person."; } public void eat(){} public void speak(){} } class Boy extends Person{ public String toString(){ return "I'm a boy"; } public void speak(){} public void fight(){} } class Girl extends Person{ public String toString(){ return "I'm a girl"; } public void speak(){} public void sing(){} }
当这三个类被载入到 Java 虚拟机以后,方法区中就包含了各自的类的信息。Girl 和 Boy 在方法区中的方法表可表示以下:
图 3.Boy 和 Girl 的方法表
可 以看到,Girl 和 Boy 的方法表包含继承自 Object 的方法,继承自直接父类 Person 的方法及各自新定义的方法。注意方法表条目指向的具体的方法地址,如 Girl 的继承自 Object 的方法中,只有 toString() 指向本身的实现(Girl 的方法代码),其他皆指向 Object 的方法代码;其继承自于 Person 的方法 eat() 和 speak() 分别指向 Person 的方法实现和自己的实现。
Person 或 Object 的任意一个方法,在它们的方法表和其子类 Girl 和 Boy 的方法表中的位置 (index) 是同样的。这样 JVM 在调用实例方法其实只须要指定调用方法表中的第几个方法便可。
如调用以下:
清单 2
class Party{ … void happyHour(){ Person girl = new Girl(); girl.speak(); … } }
当编译 Party 类的时候,生成girl.speak()的方法调用假设为:
Invokevirtual #12
设该调用代码对应着 girl.speak(); #12 是 Party 类的常量池的索引。JVM 执行该调用指令的过程以下所示:
图 4. 解析调用过程
JVM 首先查看 Party 的常量池索引为 12 的条目(应为 CONSTANT_Methodref_info 类型,可视为方法调用的符号引用),进一步查看常量池(CONSTANT_Class_info,CONSTANT_NameAndType_info ,CONSTANT_Utf8_info)可得出要调用的方法是 Person 的 speak 方法(注意引用 girl 是其基类 Person 类型),查看 Person 的方法表,得出 speak 方法在该方法表中的偏移量 15(offset),这就是该方法调用的直接引用。
当 解析出方法调用的直接引用后(方法表偏移量 15),JVM 执行真正的方法调用:根据实例方法调用的参数 this 获得具体的对象(即 girl 所指向的位于堆中的对象),据此获得该对象对应的方法表 (Girl 的方法表 ),进而调用方法表中的某个偏移量所指向的方法(Girl 的 speak() 方法的实现)。
由于 Java 类是能够同时实现多个接口的,而当用接口引用调用某个方法的时候,状况就有所不一样了。Java 容许一个类实现多个接口,从某种意义上来讲至关于多继承,这样一样的方法在基类和派生类的方法表的位置就可能不同了。
清单 3
interface IDance{ void dance(); } class Person { public String toString(){ return "I'm a person."; } public void eat(){} public void speak(){} } class Dancer extends Person implements IDance { public String toString(){ return "I'm a dancer."; } public void dance(){} } class Snake implements IDance{ public String toString(){ return "A snake."; } public void dance(){ //snake dance } }
图 5.Dancer 的方法表
可 以看到,因为接口的介入,继承自于接口 IDance 的方法 dance()在类 Dancer 和 Snake 的方法表中的位置已经不同了,显然咱们没法经过给出方法表的偏移量来正确调用 Dancer 和 Snake 的这个方法。这也是 Java 中调用接口方法有其专有的调用指令(invokeinterface)的缘由。
Java 对于接口方法的调用是采用搜索方法表的方式,对以下的方法调用
invokeinterface #13
JVM 首先查看常量池,肯定方法调用的符号引用(名称、返回值等等),而后利用 this 指向的实例获得该实例的方法表,进而搜索方法表来找到合适的方法地址。
由于每次接口调用都要搜索方法表,因此从效率上来讲,接口方法的调用老是慢于类方法的调用的。
从 上文能够看到,Java 对于多态的实现依赖于方法表,但比较特殊的是,对于接口的支持是很是不一样的,每次调用都要搜索方法表。实际上,在 C++ 中,单继承时对于多态的实现很是相似于 Java,但因为支持多重继承,这会碰到和 Java 支持接口动态调用一样的问题,C++ 的解决方案是利用对象的多个方法表指针,不幸的是,这会引入额外的指针调整的复杂性。
单继承时,C++ 对于多态的实现本质上与 Java 是同样的,也是基于方法表。但 C++ 在编译时就能够确认要调用的方法在方法表中的位置,而没有 JVM 在方法调用时查询常量池的过程。
C++ 编译时,编译器会自动作不少工做,其中之一就是在须要时在对象插入一个变量 vptr 指向类的方法表。如 Person,、Girl 的类定义与上文中 Java 相似,若
清单 4
class Person{ . . . public : Person (){} virtual ~Person (){}; virtual void speak (){}; virtual void eat (){}; }; class Girl : public Person{ . . . public : Girl(){} virtual ~Girl(){}; virtual void speak(){}; virtual void sing(){}; };
则 Person 与 Girl 实例的内存对象模型为:
图 6.Person 与 Girl 的对象模型
以下的调用代码
Person *p = new Girl(); p->speak(); p->eat();
经编译器编译后调用代码为:
p->vptr[1](p); p->vptr[2](p);
这样在运行时,会天然的过渡到对 Girl 的相应函数的调用。
能够 看到方法表中没有各自的构造函数,这是由于 C++ 的方法表中仅含有用 virtual 修饰的方法,非 virtual 的方法是静态绑定的,没有必要占用方法表的空间。这与 Java 是不一样的,Java 的方法表含有类所支持的全部的方法,能够说,Java 类的全部方法都是”virtual”(动态绑定)的。
多重继承下,状况就彻底不一 样了,由于两个不一样的类,其继承自与同一个基类的方法,在各自的方法表中的位置可能不一样(和 Java 中的接口状况相似),但 Java 在运行时有 JVM 的支持,C++ 在这里引入了多个指向方法表的指针来解决这个问题,由此带来了调整指针位置的额外复杂性。
如有以下关系的三个类,Engineer 继承自 Person 和 Employee
图 7. 类静态结构关系图
Engineer 实例对象模型为:
图 8.Engineer 对象模型
能够看到 Engineer 实例有两个指向方法表的指针,这是与 Java 大不相同的。
设有以下的代码 ,
清单 5
Engineer *p = new Engineer(); Person * p1 = (Person *)p; Empolyee *p2 = (Employee *)p;
则各指针在运行时分别指向各自的子对象,以下所示:
图 7.Engineer 实例
C++ 中对象的指针老是指向对象的起始处,如上述代码中,p 是 Engineer 对象的起始地址,而 p1 指向 p 转型成 Person 子对象的指针,能够看到实际上,二者是相等的;但 Employee 子对象的指针 p2 则于 p 和 p1 不一样,实际上
p2 = p + sizeof(Person); p1->eat(); p2->work();
则编译后生成的调用代码为:
*(p1->vptr1[i]) (p1) *(p2->vptr2[j]) (p2)
某些状况下,甚至须要将 this 指针调整到整个对象的起始处,如:
delete p2;
析构函数的 this 指针要被调整到 p 所指向的位置,不然则会出现内存泄漏。设析构函数在方法表中的位置为 0,则编译后为:
*(p2->vptr2[0]) (p)
对 于指针的调整,编译器没有足够的知识在编译时刻完成这个任务。如上例中,对于 p2 所指向的对象,该对象类型多是 Employee 或任何该类的子类 ( 其它的子类如 Teacher 等 ),编译器没法确切的知道 p2 和整个对象的初始地址的距离 (offset), 这样的调整只能发生在运行时刻。
通常有两种方法来调整指针,以下图:
图 8. 指针调整 - 扩展方法表
这种方法将指针全部调整的 offset 存储于方法表的每一个条目中,当调用方法表中的方法时,首先利用 offset 的值完成指针调整再作实际的调用。缺点显而易见,增长了方法表的大小,并且并非每一个方法都须要作指针调整。
图 9. 指针调整 -thunk 技术
这就是所谓的 thunk 技术,方法表的每一个条目指向一小段汇编代码,这段代码来保证作指针调整和调用正确的方法,至关于加了一层抽象。
上文分别对于多态在 Java 和 C++ 中的实现作了比较详细的介绍,下面对这两种语言的多态实现的异同作个小结:
可 以看到,二者之间既有类似之处,也有不一样的地方。对于单继承的实现本质上是同样的,但也有细微的差异(如方法表);差异最大的是对于多重继承(多重接口) 的支持。实际上,因为 C++ 是静态编译型语言,它没法像 Java 那样,在运行时刻动态的“查找”所要调用的方法。