2011 年 12 月 22 日发布

众所周知,多态是面向对象编程语言的重要特性,它允许基类的指针或引用指向派生类的对象,而在具体访问时实现方法的动态绑定。C++ 和 Java 作为当前最为流行的两种面向对象编程语言,其内部对于多态的支持到底是如何实现的呢,本文对此做了全面的介绍。

注意到在本文中,指针和引用会互换使用,它们仅是一个抽象概念,表示和另一个对象的连接关系,无须在意其具体的实现。

Java 的实现方式

Java 对于方法调用动态绑定的实现主要依赖于方法表,但通过类引用调用和接口引用调用的实现则有所不同。总体而言,当某个方法被调用时,JVM 首先要查找相应的常量池,得到方法的符号引用,并查找调用类的方法表以确定该方法的直接引用,最后才真正调用该方法。以下分别对该过程中涉及到的相关部分做详细介绍。

JVM 的结构

典型的 Java 虚拟机的运行时结构如下图所示

图 1.JVM 运行时结构

此结构中,我们只探讨和本文密切相关的方法区 (method area)。当程序运行需要某个类的定义时,载入子系统 (class loader subsystem) 装入所需的 class 文件,并在内部建立该类的类型信息,这个类型信息就存贮在方法区。类型信息一般包括该类的方法代码、类变量、成员变量的定义等等。可以说,类型信息就是类的 Java 文件在运行时的内部结构,包含了改类的所有在 Java 文件中定义的信息。

注意到,该类型信息和 class 对象是不同的。class 对象是 JVM 在载入某个类后于堆 (heap) 中创建的代表该类的对象,可以通过该 class 对象访问到该类型信息。比如最典型的应用,在 Java 反射中应用 class 对象访问到该类支持的所有方法,定义的成员变量等等。可以想象,JVM 在类型信息和 class 对象中维护着它们彼此的引用以便互相访问。两者的关系可以类比于进程对象与真正的进程之间的关系。

Java 的方法调用方式

Java 的方法调用有两类,动态方法调用与静态方法调用。静态方法调用是指对于类的静态方法的调用方式,是静态绑定的;而动态方法调用需要有方法调用所作用的对象,是动态绑定的。类调用 (invokestatic) 是在编译时刻就已经确定好具体调用方法的情况,而实例调用 (invokevirtual) 则是在调用的时候才确定具体的调用方法,这就是动态绑定,也是多态要解决的核心问题。

JVM 的方法调用指令有四个,分别是 invokestatic,invokespecial,invokesvirtual 和 invokeinterface。前两个是静态绑定,后两个是动态绑定的。本文也可以说是对于 JVM 后两种调用实现的考察。

常量池(constant pool)

常量池中保存的是一个 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,

清单 11

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24class 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 在调用实例方法其实只需要指定调用方法表中的第几个方法即可。

如调用如下:

清单 21

2

3

4

5

6

7

8class 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 允许一个类实现多个接口,从某种意义上来说相当于多继承,这样同样的方法在基类和派生类的方法表的位置就可能不一样了。

清单 31

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29interface 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 指向的实例得到该实例的方法表,进而搜索方法表来找到合适的方法地址。

因为每次接口调用都要搜索方法表,所以从效率上来说,接口方法的调用总是慢于类方法的调用的。

C++ 的实现方式

从上文可以看到,Java 对于多态的实现依赖于方法表,但比较特殊的是,对于接口的支持是非常不同的,每次调用都要搜索方法表。实际上,在 C++ 中,单继承时对于多态的实现非常类似于 Java,但由于支持多重继承,这会碰到和 Java 支持接口动态调用同样的问题,C++ 的解决方案是利用对象的多个方法表指针,不幸的是,这会引入额外的指针调整的复杂性。

单继承

单继承时,C++ 对于多态的实现本质上与 Java 是一样的,也是基于方法表。但 C++ 在编译时就可以确认要调用的方法在方法表中的位置,而没有 JVM 在方法调用时查询常量池的过程。

C++ 编译时,编译器会自动做很多工作,其中之一就是在需要时在对象插入一个变量 vptr 指向类的方法表。如 Person,、Girl 的类定义与上文中 Java 类似,若

清单 41

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17class 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 的对象模型

如下的调用代码1

2

3Person *p = new Girl();

p->speak();

p->eat();

经编译器编译后调用代码为:1

2p->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 大不相同的。

设有如下的代码 ,

清单 51

2

3Engineer *p = new Engineer();

Person * p1 = (Person *)p;

Empolyee *p2 = (Employee *)p;

则各指针在运行时分别指向各自的子对象,如下所示:

图 7.Engineer 实例

C++ 中对象的指针总是指向对象的起始处,如上述代码中,p 是 Engineer 对象的起始地址,而 p1 指向 p 转型成 Person 子对象的指针,可以看到实际上,两者是相等的;但 Employee 子对象的指针 p2 则于 p 和 p1 不同,实际上1

2

3p2 = p + sizeof(Person);

p1->eat();

p2->work();

则编译后生成的调用代码为:1

2*(p1->vptr1[i]) (p1)

*(p2->vptr2[j]) (p2)

某些情况下,甚至需要将 this 指针调整到整个对象的起始处,如:1delete p2;

析构函数的 this 指针要被调整到 p 所指向的位置,否则则会出现内存泄漏。设析构函数在方法表中的位置为 0,则编译后为:1*(p2->vptr2[0]) (p)

对于指针的调整,编译器没有足够的知识在编译时刻完成这个任务。如上例中,对于 p2 所指向的对象,该对象类型可能是 Employee 或任何该类的子类 ( 其它的子类如 Teacher 等 ),编译器无法确切的知道 p2 和整个对象的初始地址的距离 (offset), 这样的调整只能发生在运行时刻。

一般有两种方法来调整指针,如下图:

图 8. 指针调整 - 扩展方法表

这种方法将指针所有调整的 offset 存储于方法表的每个条目中,当调用方法表中的方法时,首先利用 offset 的值完成指针调整再做实际的调用。缺点显而易见,增加了方法表的大小,而且并不是每个方法都需要做指针调整。

图 9. 指针调整 -thunk 技术

这就是所谓的 thunk 技术,方法表的每个条目指向一小段汇编代码,这段代码来保证做指针调整和调用正确的方法,相当于加了一层抽象。

多态在 Java 和 C++ 中的实现比较

上文分别对于多态在 Java 和 C++ 中的实现做了比较详细的介绍,下面对这两种语言的多态实现的异同做个小结:单继承情况下,两者实现在本质上相同,都是使用方法表,通过方法表的偏移量来调用具体的方法。Java 的方法表中包含 Java 类所定义的所有实例方法,而 C++ 的方法表则只包含需要动态绑定的方法 (virtual 修饰的方法 )。这样,在 Java 下所有的实例方法都要通过方法表调用,而 C++ 中的非虚方法则是静态绑定的。任意 Java 对象只 “指向”一个方法表,而 C++ 在多重继承下则可能指向多个方法表,编译器保证这多个方法表的正确初始化。多层继承中 C++ 面临的主要问题是 this 指针的调整,设计更精巧更复杂;而 Java 在接口调用时完全采用搜索的方式,实现更直观,但调用效率比实例方法调用要慢许多。

可以看到,两者之间既有相似之处,也有不同的地方。对于单继承的实现本质上是一样的,但也有细微的差别(如方法表);差别最大的是对于多重继承(多重接口)的支持。实际上,由于 C++ 是静态编译型语言,它无法像 Java 那样,在运行时刻动态的“查找”所要调用的方法。

java 和c 多态比较_多态在 Java 和 C 编程语言中的实现比较相关推荐

  1. java 面试题合集_撩课-Java面试题合辑1-50题

    1.简述JDK.JRE.JVM? 一.JDK JDK(Java Development Kit) 是整个JAVA的核心, 包括了Java运行环境(Java Runtime Envirnment), 一 ...

  2. java怎么提升编程能力_怎样提升java编程能力

    1 怎样提升java编程能力 在互联网迅猛发展的时代,而我们如果对互联网一无所知,那就相当于文盲.所以很多人都想去了解它,去学习他.而作为互联网的产物--java,是一门非常不错的技术,学精通之后,你 ...

  3. 有谁转行学java成功了的吗_转行学习java靠谱吗?

    转行学Java靠谱吗?靠不靠谱主要还是看你自己是否想要学好Java技术,是否想要从事这方面的岗位工作,如果你已经有了这个决心,那么自然而然什么都不会问题.无论我们学Java是兴趣还是想要通过学好Jav ...

  4. java正则截取xml节点_实例讲述Java使用正则表达式截取重复出现的XML字符串功能...

    Java使用正则表达式截取重复出现的XML字符串功能示例 本文实例讲述了Java使用正则表达式截取重复出现的XML字符串功能.分享给大家供大家参考,具体如下: public static void m ...

  5. java 获取 反射 方法 名_乐字节Java反射之一:反射概念与获取反射源头Class

    一.Java反射机制概念 "程序运行时,允许改变程序结构或变量类型,这种语言称为动态语言",如Python, Ruby是动态语言:显然C++,Java,C#不是动态语言,但是JAV ...

  6. 深入java虚拟机 第四版_深入理解Java虚拟机-常用vm参数分析

    Java虚拟机深入理解系列全部文章更新中... https://blog.ouyangsihai.cn/shen-ru-li-jie-java-xu-ni-ji-java-nei-cun-qu-yu- ...

  7. java视频为什么这么多_为什么看java教学视频教的都是javase,两者难道语言相同吗?...

    Java 分类 Java SE(Java Platform Standard Edition) :Java平台标准版.主要用于桌面应用程序的开发,是Java技术的核心,提供基础 Java开发工具.执行 ...

  8. java工程师有什么要求_什么是java,java工程师应该具备哪些能力?

    这两年,虽然各种各样的新兴语言不断涌现,收获了一大批迷粉,但是java的地位,却始终难以撼动.作为一门成熟的,经久不衰的语言,java吸引了越来越多的人,想加入进来.但是,很多人在选择java前,或多 ...

  9. java语言的数组描述_下列关于Java语言的数组描述中,错误的是()。_学小易找答案...

    [单选题]下面()是正确的main()方法说明. [填空题]Java规定,如果子类中定义的成员方法与父类中定义的成员方法同名,并且参数的( )和类型以及( )的类型也相同,则父类中的同名成员方法被屏蔽 ...

最新文章

  1. 网络编程试卷选择解析
  2. Lilt:一个为翻译专家开发的人工智能翻译平台
  3. npm安装教程 集成npm webpack vue-cli
  4. 学习笔记(54):Python实战编程-Scale
  5. [渝粤教育] 西南科技大学 高频电子线路 在线考试复习资料2021版
  6. 两表格合并_六个高效办公Excel小技巧,让你提前下班两小时!
  7. 融合时代 存储迎来无限可能
  8. 阿里云 Composer 全量镜像
  9. 计算机图形学应用:java2d和3d_?硬核儿童节礼物:MIT学神、太极作者胡渊鸣送你一门计算机课程...
  10. 首页 系统相关 正文 小米手机远程和电脑互相连接及控制的方法
  11. bzoj2752 高速公路
  12. pyqt快速编程指南 源码_适合 C++ 新手学习的开源项目——在 GitHub 学编程
  13. quick cocos2dx 组件
  14. 数据分析达到精准营销的路径分析
  15. 计算机如何重新进行硬盘分区,最简单办法,如何在不重装电脑系统的情况下对硬盘进行重新分区-电脑怎么分区...
  16. 怎么快速录制高清的游戏视频?电脑或手机屏幕录制
  17. 有没有学长学姐可以推荐一些本科酒店管理专业好研究的毕业论文题目?
  18. mysql自我介绍_自我介绍
  19. 5月10日12点,看雪.深信服2021 KCTF春季赛正式开赛!
  20. PCB射频电路四大基础特性

热门文章

  1. 瓴羊CEO朋新宇:从数据发现问题到数据创造价值|2022全球数字价值峰会-阿里云开发者社区
  2. 微信私房菜走红 外卖、用户、监管三方皆受伤
  3. Velocity模板引擎
  4. Cisco设备telnet登录设置
  5. 数据智能让天下没有难做的营销——友盟+首席运营官吕志国 确认出席第七届GDMS!...
  6. Apache DolphinScheduler v2.0.1 Master 和 Worker 执行流程分析系列(三)
  7. 美团Java面试题,苹果手机java编程软件
  8. 多校冲刺NOIP模拟6 - 游戏——矩阵乘法、后缀自动机SAM
  9. 运行vue项目时,如果一直报eslint语法错误的解决方案
  10. 【线性代数】矩阵的初等变换与线性方程组