C++与Java的多态性实现分析

C++的多态性由编译器提供的虚方法(方法和函数两个词可能会混合用)表实现;Java的多态性由JVM硬编码设计实现。本文旨在通过分析代码,讲述C++与Java多态性的不同实现方式。

在讲述多态性之前,先声明一点:类只体现在高级语言当中,例如C++和Java,编译成汇编语言之后没有类的概念。C++源文件经编译后形成的目标文件被分为代码段、数据段、只读数据段、bss段等。目标文件经链接、加载到内存中,会将对应的数据段和代码段从可执行文件映射到进程地址空间中,进程在执行的时候还会用到栈和堆等空间。最终的进程地址空间示意图如下图所示:

类的对象最终在栈或者堆中创建,创建时只创建类中的成员变量,类中的方法代码只存在于进程虚拟地址空间的CODE区。C++源文件经编译后,所有需要使用类的地方都已经被编译器覆盖到,所以不再需要类的信息。Java会在JVM中保存类的信息,至于Java为什么保留类信息这块还需要更深入的探讨。

下面使用C++的源码模拟虚方法的调用过程

#include <iostream>
using namespace std;

class Base{
public:
    int bvalue;
	virtual void f() {cout << "base::f()" <<endl;}
	virtual void g() {cout << "base::g()" <<endl;}
	virtual void h() {cout << "base::h()" <<endl;}
};

class Derive: public Base {
public:
    int cvalue;
    void f() {cout << "derive::f()" <<endl;}
    void g() {cout << "derive::g()" <<endl;}	
};

typedef void (*Func) (void);
int main() {
	Base * pbase = new Derive();
        long * ptr = (long *)pbase;
	long * vptr = (long *)*ptr;

	Func f = (Func)vptr[0];
	Func g = (Func)vptr[1];
	Func h = (Func)vptr[2];

	f();
	g();
	h();
	return 0;
}

上面的代码可以看成以下代码的解释版

pbase->f();
pbase->g();
pbase->h();

模拟过程代码定义一个基类,基类里面有一个成员bvalue和三个虚方法f(), g(), h();派生类新增一个成员cvalue并重写f(),和g(),未重写h();定义一个参数和返回值都是void的函数指针类型Func;后面的main方法逻辑为:定位虚方法表和具体的方法地址信息以及定位具体虚方法地址后的执行,接下来慢慢分析。

在main函数中,首先使用派生类Derive的对象地址初始化基类对象指针。如上图所示,Derive类对象在内存中布局如上图所示,pbase作为基类指针,只能看见虚方法表指针和bvalue,看不到Dervie类对象的cvalue信息。代码

  long * ptr = (long *)pbase;

表示将基类对象指针pbase转化为long类型指针ptr,long类型在32位系统中表示32位长整型,在64位系统中表示64位长整型,和32、64位操作系统的指针类型占用字节数相同。ptr本身还是Dervie类对象的起始地址,ptr所指向的位置表示虚方法表的地址,ptr所指向的类型是个地址(即指针)定义为long,体现出很好的可移植性。

long * vptr = (long *)*ptr;

上述代码表示取ptr所指向的值,即为虚方法表的地址。虚方法表是一个函数指针组成的数组,拿到虚方法表的首地址之后,需要将其转化为指针类型(long)的指针(这里以指针来表示数组)vptr,这样就可以使用下标来访问vptr,vptr[0]表示第一个虚方法的函数指针,也就是f()方法的入口地址。编译器会为virtual方法按照声明顺序编号,顺序为f(),g(),h(),对应虚方法表的索引值为0,1,2。

Func f = (Func)vptr[0];
Func g = (Func)vptr[1];
Func h = (Func)vptr[2];

f();
g();
h();

上述代码获取f(),g(),h()三个方法的入口地址,到对应的地址处获取指令放入程序计数器中等待执行,结果和正常调用相同如下:

从上述过程可以看出,C++的多态性依赖于编译器,编译器会在对象首部插入一个指针类型变量,同时在可执行文件装载时将方法的入口地址装入对应类的虚方法表槽位中,派生类的虚方法表装载是重载后的方法地址。这样在进行虚方法调用时,通过虚方法表的指针和相关逻辑就能从对应的虚方法表中获取正确的方法入口地址,也就实现了C++的多态性。C++支持多重继承,编译器对于继承的每一个基类都会生成一个虚方法表。其中第一个虚方法表包含子类中新增的虚方法地址、重写的虚方法地址和基类本身的虚方法地址,其他的虚方法表只包含子类中重写的虚方法地址和基类本身的虚方法地址。关于C++多种继承下的内存布局,此处不做深入分析,具体可参考《深度探索C++对象模型》一书。

Java作为一门面向对象的语言,势必要支持多态性,它的多态性通过JVM硬编码实现的。Java语言又是一门解释性语言(Java也有即时编译功能),生成的字节码指令需要JVM解释执行。JVM以hotspot为例主要使用C++语言编写,解释每一条字节码指令。Java和C++不同,C++需要通过virtual关键字声明虚方法,而Java不需要用virtual关键字声明虚方法,并且Java类(非接口)中每一个普通public非init、clinit、static方法在字节码层面都通过invokevirtual这条字节码调用执行,都使用"虚方法表"进行分发。具体来说,JVM在设计时,构造了VTABLE和ITABLE的数据结构用来支持多态性。为什么要同时VTABLE和ITABLEl两个数据结构来支持多态性?因为Java语言在类方法不支持多重继承,而Java支持接口,接口的实现类能够实现多个接口,用VTABLE和ITABLE就能满足类和接口调用方法时的多态性。

JVM的架构如下图所示:

                            

JVM通过类加载子系统加载class文件到内存。在内存中完成验证、解析、准备,和对象的初始化等操作,最终类中的方法在执行引擎的协调下被执行。其中解析操作是将常量池内的符号引用转化为直接引用的过程,此过程与Java的多态性实现相关,此处需解释一下。符号引用是以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义的定位到目标即可。符号引用和虚拟机实现的内存部署无关,引用的目标不一定已经加载到内存。直接引用是可以是直接指向目标的指针(如前面定位函数入口地址的函数指针)、相对偏移量或是一个能间接定位到目标的句柄。通过一个符号引用在不同的虚拟机实例上翻译出来的直接引用一般不相同,有了直接引用,引用的目标必定在内存中存在。关于类加载的过程,本文不准备大量描述,有兴趣的读者请参考《深入理解Java虚拟机》。Java方法执行时,内存之间关系如下图所示:

通过new关键字创建的Java对象在堆的新生代或者老年代中分配,常量池、方法的字节码等存在于堆中永久代,因方法调用而新建的栈帧则在Java栈中分配。Java的程序计数器保存方法的下一条字节码指令的地址。执行到普通方法调用时,JVM会开启一个新的栈帧,并通过对象头的指针定位方法区的类信息,进而获取方法的直接引用以便执行方法。为什么说可以通过对象头信息定位到方法区的类信息?这来自于hotspot设计的OOP-Klass二分模型。

C++是一门面向对象的语言,hotspot主要是C++编写的(其中还有少量的C语言和汇编语言),为什么在JVM中不用一个C++类来表示Java类呢?这个问题目前笔者还回答不出来,只知道hotspot设计了OOP-Klass二分模型。OOP表示普通对象指针,用来描述对象实例信息;Klass表示Java语言层面的类和实现虚函数的分发功能。下面简单看一下oopDesc的定义:

从定义可以看出OopDesc有两个成员变量,markOop类型的_mark和一个_metadata共用体。这两个成员就是我们常说的对象头中的_mark标记和元数据指针(指向方法区相关类信息)。以Oop结尾的类型表示指针,WideKlassOop、narrowOop、markOop的定义如下如:

其中markOop我们也称之为Mark Word用来存储对象运行时记录信息,如哈希码、GC分代年龄、锁状态标志等等;_metadata中存储的是指向klassOopDesc对象的指针(这里默认使用wideKlassOop,不讨论压缩的情况),KlassOopDesc对象表示Java类的C++的对等体,通过它可以定位Klass类对象信息,Klass对象是JVM层用来表示Java类的。上图中还需要介绍一个指针methodOop,用它可以定位Java方法在内存中的具体位置,从而实现方法调用。在64位操作系统中,每个指针占用8个字节,两个指针占用16个字节;OopDesc是所有OopDesc家族的基类,在Java中创建一个对象在JVM中就创建一个OopDesc对象,故所有Java非数组对象在64位系统中所占内存空间会自动加上16字节(对象头的两个指针)的对象头,数组对象除了上面的16字节还有一个表示数组长度的int类型成员,故64位系统中数组类型的Java对象的对象头占20个字节。

我们首先看一段Java代码如下:

public class Demo {
	public int calc() {
		int a = 10;
		int b = 20;
		return a + b;
	}
	
	public static void main(String[] args) {
		Demo demo = new Demo();
		demo.calc();
	}
}

对上述Java代码使用javac Demo.java && javap -verbose Demo进行反编译后的main方法字节码如下图:

因为字节码比较简单,这里介绍一下这段字节码的含义。new指令在堆区新建一个Demo对象,并将指向此Demo对象的指针压入栈顶;因为JVM是栈式指令没有任何寄存器,而使用new指令新建对象后需要调用init方法,并且调用init方法后会弹出一个Demo对象指针,后面还需要调用calc方法,故栈中需要提前准备好两个Demo对象的指针。new指令之后紧跟一个dup指令,将Demo对象的指针复制一份重新压入栈顶。第一次调用invokespecial <init>后会弹出一个Demo对象指针,这样栈中只剩一个Demo对象指针。astore_1将栈中Demo对象指针存入局部变量表第二个位置(astore_0表示存入第一个位置);aload_1将局部变量表的第二个局部变量压入栈顶,也就是将Demo对象指针压栈。invokevirtual指令调用calc方法,pop指令弹出栈中Demo对象指针(此时栈空),return指令返回。

我们看到在字节码偏移量为9处的指令为: invokevirtual #4,这条指令占了3个字节(从下一条指令的偏移量为12可以看出)。invokevirtual指令为调用Java类(非接口)中的普通public非init、clinit、static方法,#4表示对常量池的引用。反编译后的常量池如下图:

首先,常量池中项之间可以相互引用。从图中可以看出#4表示一个Java方法,其值为#2.#18。#2表示一个Java类,其值为#17,#17表示UTF8字符串Demo(正是类名)。#18表示Java常量池类型NameAndType#10:#11,最终的引用为:calc:()I;  invokevirtual #4最终表示为: invokevirtual Demo.calc()I。Demo.calc()I是一个字符串,hotspot以字符串表示符号引用。符号引用定位不到内存中方法的位置,需要符号引用转换为直接引用,这个过程叫做常量池解析。常量池解析主要是解析4类符号引用:1、类     2、接口    3、字段    4、类方法和接口方法。多态性主要和方法相关,这里简单看一下方法的解析。为了优化解释器性能,JVM添加了常量池缓存项,原本字节码中表示常量池项索引位置的字节也需要相应跟着调整。例如#4这两个字节需要调整为指向相应的常量池缓存项,相应的hotspot示例代码如下:

reverse参数为在调用方为false,表示将常量池中索引项转换为常量池高速缓存中的索引过程,而不是逆过来。图中红线圈住的代码表示:取出常量池中的索引项,并将其转化常量池高速缓存索引项,以及失败后的处理。常量池高速缓存由一个数组组成,元素类型为常量池缓存项ConstantPoolCacheEntry。常量池缓存项有两种类型,分别为字段项和方法项。常量池缓存的内存布局如下:

这4个字段长度相同,以32为操作系统为例,_flags被分成了32位,其中第23位为0表示字段项常量池缓存,为1表示方法项常量池缓存。_flags其它位这里不做分析,主要讲解_f1和_f2以便后续讲解VTABLE和ITABLE结构。在方法分发时,对于invokespecial和invokestatic指令,_f2字段表示目标方法的methodOop(用它可以定位Java方法在内存中的具体位置,从而实现方法调用)。对于invokevirtual指令,如果是virtual final方法,_f2字段也直接指向目标方法的methodOop;当用到vtable时,例如调用其他非final的其他virtual方法,_f2字段中则存放目标方法在vtable中的索引编号。在用到itable时,JVM结合_f1和_f2字段实现虚方法的分发。对于invokeinterface指令,_f1字段指向对应接口的klassOop,而_f2字段存放的则是方法位于itable表中的索引编号。vtable和itable位于instanceKlass(instanceKlass是JVM层面描述的Java类)对象的末尾,它们在内存中的布局如下:

vtable表示是由一组变长(前面会有一个字段描述该表的长度)连续的vtableEntry元素构成的数组。其中每个vtableEntry用methodOop表示,指向一个方法。在类初始化时,JVM将复制父类的vtable,然后根据自己的方法定义更新vtableEntry,或向vtable添加新的元素。当Java方法是Override重写父类方法时,JVM将更新vtable中相同顺序的元素,使其指向覆盖后的实现方法;如果是方法重载或者自身新增的方法,JVM将按顺序添加到vtable中。尚未提供实现的Java方法也放在了vtable中,因没有实现,JVM不会为这个vtableEntry项分发具体的方法,这和C++的纯虚函数类似,不再赘述。调用类方法时,JVM通过ConstantPoolCacheEntry的_f2成员获取vtable中方法的索引,从而取到methodOop以便执行。

如上图所示,itable表由偏移表和方法表两个表组成,这两个表都是变长的。每个offset table entry保存的是类实现的一个接口klassOop和该接口方法表所在的偏移位置;方法表method table entry元素保存的是实现的接口方法,方法在方法表的位置同样是使用ConstantPoolCacheEntry的_f2成员保存的。在初始化itable时,JVM将类实现的接口以及实现的方法填写在上述两张表中。接口中的非public方法和abstract方法(在vtable中占一个槽位)不放入itable中。调用接口方法时,JVM通过ConstantPoolCacheEntry的_f1成员拿到接口的klassOop在itable的偏移表中逐一匹配,如果匹配上则获取它的方法表的位置,然后在方法表中通过ConstantPoolCacheEntry的_f2成员找到实现的方法methodOop。

类方法的调用比较简单,这简单讲一下接口方法的调用,首先看如下代码:

callee是最终要调用方法的指针(可以理解为函数指针),invokeinterface的过程就是确定callee的过程。代码首先获取接口的klassOop,然后获取instanceKlass(JVM层表示的Java类)对象,通过instanceKlass对象获取偏移表地址,然后遍历匹配接口的klassOop对象,匹配之后分别获取方法在itable方法表的索引以及方法表的地址,最后通过method方法获取被用方法的methodOop信息。

此篇文章是笔者对于C++和Java多态性的理解,所贴hotspot源码是openJDK1.7,JDK运行时数据区是JDK1.8之前的(永久代这块在JDK1.8被去掉了,取而代之的是元空间),但不影响分析,如有不对敬请谅解。

 

参考资料:

1、《程序员的自我修养——链接 装载与库》

2、《深度探索C++对象模型》

3、《深入理解java虚拟机》

4、《hotspot实战》

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值