类的内存布局
从简单到复杂吧,多态,虚继承、菱形继承……
成员变量
成员变量是顺序存储的,在内存(看实例化的时候被开辟在堆区还是栈区,只是位置不同,但是内存布局一致)空间会开辟一段连续内存,里面只放非静态数据成员,同时,也会进行一些内存对齐来使得 CPU 读取更快(Cache Line 那边已经说过
那么,静态成员变量 —— 自然是存放在全局 / 静态数据段
另外的,权限修饰的关键字并不会影响内存布局,只会影响编译期的检查。
class Simple {
public:
int a; // 4 字节
private:
char b; // 1 字节
double c; // 8 字节
};(以 64为操作系统为例 )那么看似应当占用 13 个字节,实际上会被对齐到 16 个字节
成员函数
如果把所有成员函数都存储在存变量的那段连续内存中,显然太笨了 —— 成员函数和其他普通的函数一样,都是存放在代码段里,然后在调用的时候 ——
Logger lg;
lg.log();
---------
Logger_log(&lg);也就是,实际上每个非静态的成员方法在本质上都会被隐式的传入一个this指针。
综上所述,成员方法并不会占用对象的内存
静态成员函数也是在代码段
class MethodClass {
public:
int a;
static int s_count; // 静态变量
void doSomething(); // 普通方法
static void print(); // 静态方法
};这样一个什么都有的类,实际上被实例化后也只会占用 4 个字节
单继承
比较简单,先把父类的基础上,先把父类的内存布局抄一份下来,接着在尾部接上自己的内存布局
class Base {
int a;
};
class Derived : public Base {
int b;
};那么它的内存分布图 ——
+-------------------+ <--- Derived 对象起始地址
| Base::a (4 bytes) | \__ 属于父类 Base 的部分
+-------------------+ /
| Derived::b(4 bytes| \__ 属于子类 Derived 自己的部分
+-------------------+ /多态
首先,因为多态函数调用的时候,其到底要调用哪个具体的方法,是无法在编译器确定,而是要在运行时确定的。
Base *p = getObj();
p->foo();由于 getObj 返回的对象只能在运行期直到,所以调用的 foo 是哪个也只能在运行时直到,所以需要用一种方式来对这些方法进行定位 ——
对于出现虚函数的类,会编译出对应的 vptr / vtable,来进行虚方法的定位
NOTE
The C++ standard does not specify how virtual functions should be implemented (this detail is left up to the implementation).
先说虚函数表,它记录了所有被 virtual 标记的虚函数的存放地址(函数指针地址,也就是虚函数的具体实现在代码段的位置
一个类的虚函数表的布局显然是在编译器就可以确定下来的,如果给每一个实例都分配一个显然是对内存空间的不尊重(严重浪费),所以它是放在只读段的一个函数指针数组。
接着只要类有虚函数(或继承自有虚函数的类),编译器就会在对象内存中加一个隐藏指针成员:vptr,用于指向自己这个类所对应的虚函数表。
所以每次在访问的时候,先读取到 vptr,然后再通过偏移量确定具体的实现。
举个例子 —— 假设 Base* p = new Derived(); p->func1();
- 取出
p对象头部的vptr值(一个内存地址)。 - 跳转到该地址,即进入了静态区中的
Derived虚函数表。 - 根据编译器确定的偏移量(
func1如果是第一个虚方法,通常在索引 0),然后取出表中的函数指针。 - 跳转到该指针指向的代码区,执行
Derived::func1的指令。
综上所述,虚函数本质上就是一个普通函数,只是调用方式不同(通过 vtable 间接调用)。
那么性能损耗也是必然的,普通函数可以直接跳地址,但是虚函数要先读 vptr,再读 vtable,多出两次内存寻址。而且这两个地址一定是非连续的,容易导致 CPU 的分支预测失败,清空 pipeline
很聪明的查表方法(编译期生成),但是也是有一定的性能损耗