多态在 Java 和 C++ 编程语言中的实现比较
众所周知,多态是面向对象编程语言的重要特性,它允许基类的指针或引用指向派生类的对象,而在具体访问时实现方法的动态绑定。C++ 和 Java 作为当前最为流行的两种面向对象编程语言,其内部对于多态的支持到底是如何实现的呢,本文对此做了全面的介绍。
注意到在本文中,指针和引用会互换使用,它们仅是一个抽象概念,表示和另一个对象的连接关系,无须在意其具体的实现。
Java 对于方法调用动态绑定的实现主要依赖于方法表,但通过类引用调用和接口引用调用的实现则有所不同。总体而言,当某个方法被调用时,JVM 首先要查找相应的常量池,得到方法的符号引用,并查找调用类的方法表以确定该方法的直接引用,最后才真正调用该方法。以下分别对该过程中涉及到的相关部分做详细介绍。
典型的 Java 虚拟机的运行时结构如下图所示
图 1.JVM 运行时结构

此结构中,我们只探讨和本文密切相关的方法区 (method area)。当程序运行需要某个类的定义时,载入子系统 (class loader subsystem) 装入所需的 class 文件,并在内部建立该类的类型信息,这个类型信息就存贮在方法区。类型信息一般包括该类的方法代码、类变量、成员变量的定义等等。可以说,类型信息就是类的 Java 文件在运行时的内部结构,包含了改类的所有在 Java 文件中定义的信息。
注意到,该类型信息和 class 对象是不同的。class 对象是 JVM 在载入某个类后于堆 (heap) 中创建的代表该类的对象,可以通过该 class 对象访问到该类型信息。比如最典型的应用,在 Java 反射中应用 class 对象访问到该类支持的所有方法,定义的成员变量等等。可以想象,JVM 在类型信息和 class 对象中维护着它们彼此的引用以便互相访问。两者的关系可以类比于进程对象与真正的进程之间的关系。
Java 的方法调用有两类,动态方法调用与静态方法调用。静态方法调用是指对于类的静态方法的调用方式,是静态绑定的;而动态方法调用需要有方法调用所作用的对象,是动态绑定的。类调用 (invokestatic) 是在编译时刻就已经确定好具体调用方法的情况,而实例调用 (invokevirtual) 则是在调用的时候才确定具体的调用方法,这就是动态绑定,也是多态要解决的核心问题。
JVM 的方法调用指令有四个,分别是 invokestatic,invokespecial,invokesvirtual 和 invokeinterface。前两个是静态绑定,后两个是动态绑定的。本文也可以说是对于 JVM 后两种调用实现的考察。
常量池中保存的是一个 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++ 中的实现做了比较详细的介绍,下面对这两种语言的多态实现的异同做个小结:
- 单继承情况下,两者实现在本质上相同,都是使用方法表,通过方法表的偏移量来调用具体的方法。
- Java 的方法表中包含 Java 类所定义的所有实例方法,而 C++ 的方法表则只包含需要动态绑定的方法 (virtual 修饰的方法 )。这样,在 Java 下所有的实例方法都要通过方法表调用,而 C++ 中的非虚方法则是静态绑定的。
- 任意 Java 对象只 “指向”一个方法表,而 C++ 在多重继承下则可能指向多个方法表,编译器保证这多个方法表的正确初始化。
- 多层继承中 C++ 面临的主要问题是 this 指针的调整,设计更精巧更复杂;而 Java 在接口调用时完全采用搜索的方式,实现更直观,但调用效率比实例方法调用要慢许多。
可以看到,两者之间既有相似之处,也有不同的地方。对于单继承的实现本质上是一样的,但也有细微的差别(如方法表);差别最大的是对于多重继承(多重接口)的支持。实际上,由于 C++ 是静态编译型语言,它无法像 Java 那样,在运行时刻动态的“查找”所要调用的方法。
学习
- Java 虚拟机规范:Java 虚拟机规范规定了 Java 的具体工作方式,对 Java 语言的各个方面做了全面的阐述。
- Java 虚拟机专题:Java 虚拟机(Java virtual machine,JVM)是语言与底层软件和硬件之间的一种转换器。Java 语言的所有实现都必须实现 JVM,从而使 Java 程序可以在有 JVM 的任何系统上运行。
- 深入 Java 虚拟机:对 Java 虚拟机的各种可能实现做了独到而清晰的解析。
- 深入 C++ 对象模型:深入探讨了 C++ 的对象模型,函数调用机制等,对编译器在幕后所做的工作给出了详尽的解释。
- 技巧:用 C 语言实现程序的多态性:使用 C 语言模拟简单的多态特性,该文给出了一个简单的实现。
C++ Thunk技术(初学版)(转)
(2011-01-11 12:01:42)这里想说的是:代码中的关键点为用指令jmp pFunc跳转到你想要执行的函数pFunc。
指令“jmp xxxx”占5个字节,代码中用了个一字节对齐的结构体struct
Thunk , 当然也可以用 unsigned char code[5]; 说另一个关键点就是地址计算了,jmp xxxx指令用了相对跳转地址,
相对地址 = 要跳转函数的地址 - “jmp xxxx”指令的下一条指令的地址。
下面代码中的class
C 只有m_thunk一个数据成员,没有虚函数和在m_thunk前没有声明别的数据成员, 因此相对地址 = pFunc - [ (int)this
+ sizeof(struct Thunk) ] 如上所述,若有虚函数和在m_thunk前声明了别的数据成员,则相对地址的计算要做修改。
:)本来画个表会说得比较清楚,但本人嫌麻烦,就作罢了!
/下面是所转的文章
实际上C++
的THUNK技术是需要改变指令代码的,这里发一个贴说明之
//此程序演示 运行时 改变 指令代码
//实质是C++ 实现多态 的 THUNK 技术思想的简陋模拟
//在VC6.0中编译通过。
#include<windows.h>
#include<iostream.h>
typedefvoid(*pFUN)(); //函数类型
#pragmapack(push,1) //强制编译器,使数据按字节边界对齐。
//默认情况下VC6.0是按4字节对齐,VC7.0按8字节对齐
//指令本不是按双字边界对齐的,所以必须使其按字节边界对齐,否则出错
//下面是存储机器代码的结构
structThunk //有趣的是:这个结构不储存数据,而是储存指令。一个jmp跳转指令
{//我们将改变这个结构,然后让程序执行此代码,此结构的执行将会改变程序的执行路径
BYTE m_jmp; // 储存jmp指令的操作码
DWORDm_adrr; // 储存相对jmp指令的偏移地址(指令操作数)
};//
#pragmapack(pop)//撤销数据按字节对齐,数据按双字对齐的主要目的是优化执行速度
classC
{
public:
Thunk m_thunk; //产生一个 Thunk 实例
void Init(pFUN pFun)
{
m_thunk.m_jmp = 0xe9;// 跳转指令的操作码是 0xe9 所以。。。
m_thunk.m_adrr = (int)pFun - ((int)this+sizeof(Thunk));
// JMP跳转是相对跳转,也就是说:它是跳转到的地址是: 当前指令地址(EIP)+相对操作数
// 相对操作数有符号的!
//当指令执行到Thunk 中指令的时候,我们需要跳转到pFun,而当前EIP指为(int)this+sizeof(Thunk)
//原因:在顺序执行指令时,EIP在执行一条指令后会自动增,这里当然增的是sizeof(Thunk)
//又由于没有virtual指针,所以m_thunk的地址就是this指向地址,但是执行此指令后EIP会自动加, //所以EIP内容为(int)this+sizeof(Thunk)
//所以pFun=m_thunk.m_adrr+((int)this+sizeof(Thunk)),移项可得上式
FlushInstructionCache(GetCurrentProcess(),
&m_thunk, sizeof(m_thunk)); //强制刷新指令缓冲,
//目的是使指令CACHE与主存相一致
}
//实验的第一函数
void function()
{
// 初始化thunk
// 获得thunk代码地址
pFUN pFun = (pFUN)&(m_thunk);
// 调用StaticFun
pFun();
}
static void Fun1()
{
cout << "this is Fun1" << endl;
}
staticvoid Fun2()
{
cout<< "this is Fun2" << endl;
}
};
intmain()
{
C *pC=new C;
pC->Init(C::Fun1);
pC->function(); //1
pC->Init(C::Fun2);
pC->function();//2
//请注意,上面调用同一个函数,第一个执行的是C::Fun1,第二个却执行的是C::Fun2
//这充分说明实现了多态性!
return 0;
}
- developerWorks Java 技术专区:这里有数百篇关于 Java 编程各个方面的文章。
C++和java多态的区别
C++和java都具多态性,多态性其实就是方法调用的机制,也就是说当在编译时无法确定一个对象的实际类型时,应当能够在运行时基于对象的实际类型来决定调用的具体方法。本文主要介绍C++和java多态的区别。
以前我有个错误的观点:即使在C++和java中多态性的实现机制可能不同,但它们的表现形式应该相同,也就是说如果代码结构相同,那么执行结果也应该相同。可惜事与愿违,事情并不总是我想象中的那样子,那么C++和java多态到底有何区别呢?
首先我们提一下多态性的概念。根据Bjarne Stoustrup的说法,多态性其实就是方法调用的机制,也就是说当在编译时无法确定一个对象的实际类型时,应当能够在运行时基于对象的实际类型来决定调用的具体方法(动态绑定)。
我们先来看一下在C++中的函数调用方式:
- 普通函数调用:具体调用哪个方法在编译时间就可以决定(通过查找编译器的符号表),同时在使用标准过程调用机制基础上增加一个表示对象身份的指针(this指针)。
- 虚函数调用:函数调用依赖于对象的实际类型,一般地说,对象的实际类型只能在运行时间才能确定。虚函数一般要有两个步骤来支持,首先每一个类产生出一堆指向虚函数的指针,放在表格中,这个表格就叫虚函数表(virtual table);然后每一个类对象(class object)会添加一个指向相关虚函数表(virtual table)的指针,通常这个指针叫做vptr。
在java中又是如何的呢?恩,区别还是满大的。在java虚拟机中,类实例的引用就是指向一个句柄(handle)的指针,而该句柄(handle)其实是一对指针:其中一个指针指向一张表,该表格包含了对象的方法列表以及一个指向类对象(表示对象类型)的指针;另一个指针指向一块内存地址,该内存是从java堆中为对象的数据而分配出来的。
这时,你可能会说,好象差不多嘛,不是都要维护一张函数表吗?别急,让我们先看一下例子,这样你就能更好的理解它们之间的区别到底有多大了。
下面是C++和java的例子,不看后面的答案,你能够正确说出它们的执行结果吗?
例1:C++
- class Base
- {
- public:
- Base()
- {
- init();
- }
- virtual ~Base() {}
- public:
- virtual void do_init()
- {
- init();
- }
- protected:
- virtual void init()
- {
- cout << "in Base::init()" << endl;
- }
- };
- class Derived : public Base
- {
- public:
- Derived()
- {
- init();
- }
- protected:
- void init()
- {
- cout << "in Derived::init()" << endl;
- }
- };
- int main(int argc, char* argv[])
- {
- Base* pb;
- pb = new Derived();
- delete pb;
- return 0;
- }
例2:java
- class Base
- {
- public Base()
- {
- init();
- }
- protected void init()
- {
- System.out.println("in Base::init()");
- }
- public void do_init()
- {
- init();
- }
- }
- class Derived extends Base
- {
- public Derived()
- {
- init();
- }
- protected void init()
- {
- System.out.println("in Derived::init()");
- }
- }
- public class Test
- {
- public static void main(String[] args)
- {
- Base base = new Derived();
- }
- }
例1的执行结果是:
- in Base::init()
- in Derived::init()
例2的执行结果是:
- in Derived::init()
- in Derived::init()
看了结果后,你是马上顿悟呢抑或是处于疑惑中呢?ok,我们来分析一下两个例子的执行过程。
首先看一下例1(C++的例子):
1. Base* pb; 只是声明,不做什么。
2. pb = new Derived();
1) 调用new操作符,分配内存。
2) 调用基类(本例中是Base)的构造函数
3) 在基类的构造函数中调用init(),执行程序首先判断出当前对象的实际类型是Base(Derived还没构造出来,当然不会是Derived),所以这里调用的是Base::init()。
4) 调用派生类(本例中是Derived)的构造函数,在这里同样要调用init(),执行程序判断出当前对象的实际类型是Derived,调用Derived::init()。
3. delete pb; 无关紧要。
例2(java的例子)的执行过程:
1. Base base = new Derived();
1) 分配内存。
2) 调用基类(本例中是Base)的构造函数
3) 在基类的构造函数中调用init(),执行程序首先判断出当前对象的实际类型是Derived(对,Derived已经构造出来,它的函数表当然也已经确定了)所以这里调用的是Derived::init()。
4) 调用派生类(本例中是Derived)的构造函数,在这里同样要调用init(),执行程序判断出当前对象的实际类型是Derived,调用Derived::init()。
明白了吧。java中的类对象在构造前(调用构造函数之前)就已经存在了,其函数表和对象类型也已经确定了,就是说还没有出生就已经存在了。而C++中只有在构造完毕后(所有的构造函数都被成功调用)才存在,其函数表和对象的实际类型才会确定。所以这两个例子的执行结果会不一样。当然,构造完毕后,C++与java的表现就都一样了,例如你调用Derived::do_init()的话,其执行结果是:
- in Derived::init()
个人认为,java中的多态实现机制没有C++中的好。还是以例子说明吧:
例子3:C++
- class Base
- {
- public:
- Base()
- {
- init();
- }
- virtual ~Base() {}
- protected:
- int value;
- virtual void init()
- {
- value = 100;
- }
- };
- class Derived : public Base
- {
- public:
- Derived()
- {
- init();
- }
- protected:
- void init()
- {
- cout << "value = " << value << endl;
- // 做一些额外的初始化工作
- }
- };
- int main(int argc, char* argv[])
- {
- Base* pb;
- pb = new Derived();
- delete pb;
- return 0;
- }
例4:java
- class Base
- {
- public Base()
- {
- init();
- }
- protected int value;
- protected void init()
- {
- value = 100;
- }
- }
- class Derived extends Base
- {
- public Derived()
- {
- init();
- }
- protected void init()
- {
- System.out.println("value = " + value);
- // 做一些额外的初始化工作
- }
- }
- public class Test
- {
- public static void main(String[] args)
- {
- Base base = new Derived();
- }
- }
例3的执行结果是:
- value = 10
例4的执行结果是:
- value = 0
- value = 0
从以上结果可以看出,java例子中应该被初始化的值(这里是value)没有被初始化,派生类根本不能重用基类的初始化函数。试问,如果初始化要在构造时完成,并且初始化逻辑比较复杂,派生类也需要额外的初始化,派生类是不是需要重新实现基类的初始化函数呢?这样的面向对象方法好不好呢?欢迎大家讨论。