众所周知,多态是面向对象编程语言的重要特性,它允许基类的指针或引用指向派生类的对象,而在具体访问时实现方法的动态绑定。C++ 和 Java 作为当前最为流行的两种面向对象编程语言,其内部对于多态的支持对于单继承的实现非常类似。
首先来体现一下C++的动态分派,如下:
class Base1{
public:
int base1_var1;
int base1_var2;
void func(){}
};
C++中有函数的动态分派,就类似于Java中方法的多态。而C++实现动态分派主要就是通过虚函数来完成的,非虚函数在编译时就已经确定调用目标。C++中的虚函数通过关键字virtual来声明,如上函数func()没有virtual关键字,所以是非虚函数。
查看内存布局,如下:
1> class Base1 size(8):
1> +---
1> 0 | base1_var1
1> 4 | base1_var2
1> +---
非虚函数不会影响内存布局。
class Base1{
public:
int base1_var1;
int base1_var2;
virtual void base1_fun1() {}
};
内存布局如下:
1> class Base1 size(16):
1> +---
1> 0 | {vfptr}
1> 8 | base1_var1
1> 12 | base1_var2
1> +---
在64位环境下,指针占用8字节,而vfptr就是指向虚函数表(vtable)的指针,其类型为void**, 这说明它是一个void*指针。类似于在类Base1中定义了如下类似的伪代码:
void* vtable[1] = { &Base1::base1_fun1 };
const void** vfptr = &vtable[0];
这个非常类似于Java虚拟机中对Java方法动态分派时的虚函数表(可参看深入剖析Java虚拟机:源码剖析与实例详解》一书第6.3节)。
虚函数表是属于类的,而不是属于某个具体的对象,一个类只需要一个虚函数表即可。同一个类的所有对象都使用同一个虚函数表。为了指定对象的虚函数表,对象内部包含一个虚函数表的指针,来指向自己所使用的虚函数表。为了让每个包含虚函数表的类的对象都拥有一个虚函数表指针,编译器在类中添加了一个指针,用来指向虚函数表。这样,当类的对象在创建时便拥有了这个指针,且这个指针的值会自动被设置为指向类的虚函数表。
从如上的例子我们应该能够得到如下一些结论:
- C++的动态分派需要明确用virtual关键字指明,而Java中的方法默认就是动态分派,这也体现出两种语言设计理念的不同,C++不想让用户为用不到的功能付出代价,Java更看重开发效率,将一些复杂的底层控制全权托管给虚拟机;
- C++中只要有虚函数,就会在类中有一个虚函数表,而且类的每个实例都会多出一个指向虚函数表的指针。
对于第2点来说,HotSpot VM的设计者充分考虑了这个情况,所以在一些类的设计上能不用虚函数就绝对不用,例如oop继承体系下的所有类都不会用虚函数,因为有一个Java实例就会有一个oop,现在的应用程序一般都有过千万的实例,那我们可以算一下,每个实例要多出8个字节存储指向虚表的指针,那要消耗掉多少内存呢?!
下面我们来谈谈第1点提到的动态分派。先来看一下单继承情况下C++的动态分派。
class Person{
. . .
public :
void init(){} // 非virtual方法
virtual void sing (){};
virtual void dance (){};
};
class Girl : public Person{
. . .
public :
virtual void sing(){};
virtual void speak(){};
};
动态分派的过程大概如下图所示。
只有加virtual关键字的虚函数才会存在于虚函数表中,也就是这些虚函数需要动态分派。当子类重写了父类的方法时,动态分派能准确根据接收者类型找到实际需要调用的函数。
在HotSpot VM中有非常多使用动态分派的例子,如Klass继承体系下的类有个oop_oop_iterate_v_m()函数,在发生YGC时,由于老年代也会有引用指向年轻代对象,所以必须通过卡表找到这些可能的对象,在找到这些对象后,这些对象的哪些区域是引用还要进一步借助Klass来完成,如一般的对象会调用InstanceKlass中的oop_oop_iterate_v_m()函数,而java.lang.Class对象会调用InstanceMirrorKlass类中的oop_oop_iterate_v_m()函数。
下面看一下Java的动态分派。举个例子如下:
class Person{
void sing (){}
void dance (){}
}
class Girl extends Person{
void sing(){}
void speak(){}
}
了解过Java虚函数表的人可能知道,类中许多的方法都会放到虚函数表中,这就是我们前面说的,Java中的方法默认都是动态分派的。
动态分派的过程大概如下图所示。
从Object继承下来5个方法,final、静态方法和构造方法不会进入虚函数表中。final关键字与C++的virtual有着相反的作用。
JVM的方法调用指令有四个,分别是 invokestatic,invokespecial,invokesvirtual 和 invokeinterface。前两个是静态绑定,后两个是动态绑定的,invokevirtual表示调用虚方法,也就是会查虚函数表进行调用,而invokeinterface表示调用接口方法,会有个接口函数表,这里暂不介绍。
本人最近准备出一个手写Hotspot VM的课程,超级硬核,从0开始写HotSpot VM,将HotSpot VM所有核心的实现全部走一遍,如感兴趣,速速入群。
群里可讨论虚拟机和Java性能剖析与故障诊断等话题,欢迎加入。