C++ 内存模型深入解析

前言

C++ 内存模型所聚焦的问题在于一个 C++ 对象在内存中是如何编排的,各种类内数据及方法,是如何存储在内存中的,以及模型是如何适配继承体系的。本文有大量例程进行验证和说明,需要花一定时间阅读,也推荐读者在自己的机器上进行试验和验证。

本文中程序所用编译器为 clang 14.0.0,在 64 位系统下

数据成员

对于数据来说,一个 C++ 对象,仅有非静态的数据成员会在对象实例中占据内存,其余都不会储存在对象实例中,另外还需要考虑内存对齐需要填充的的情况。以下假设内存以 32 位对齐,即 4 字节对齐。

静态成员储存在静态存储区

一个典型的 C++ 对象

数据在对象实例中按声明顺序排列:

// 内存模型:
// |       mem1        |
// |   mem2  | padding |
// |       mem3        |
class A {
public:
	int member1 = 1;
protected:
	char member2 = 2;
private:
	int member3 = 3;
	static int s_member;
};

int main() {
	cout << sizeof(A) << endl; // 结果:12字节
	A test;
	void* ptr_base = &test;
	int* ptr_mem1 = (int*)ptr_base;
	char* ptr_mem2 = (char*)((int*)ptr_base + 1);
	int* ptr_mem3 = (int*)ptr_base + 2;
	cout << *ptr_mem1 << (int)*ptr_mem2 << *ptr_mem3 << endl;
	// print: 123
	return 0;
}

继承中的内存对齐

虽然是跨类型,但如果能合并产生更高效的内存模型,则会合并,例如:

class A {
	short mem1;
};

// 内存模型:
// ====A=========B====
// |  mem1  |  mem2  |
class B : A {
	short mem2;
};

sizeof(B); // 结果:4字节

单继承

在单继承体系下,数据成员分布和单一对象没有什么区别,仅仅是按继承顺序拓展。

class A {
public:
	int member1 = 1;
protected:
	char member2 = 2;
private:
	int member3 = 3;
	static int s_member;
};

// 内存模型:
// ==========A==========
// |       mem1        |
// | mem2 |   padding  |
// |       mem3        |
// ==========B==========
// | mem4 |   padding  |
class B : A {
public:
	char member4 = 4;
};

int main() {
	cout << sizeof(B) << endl; // 结果:16字节
	B test;
	void* ptr_base = &test;
	char* ptr_mem4 = (char*)((int*)ptr_base + 3);
	cout << (int)*ptr_mem4 << endl;
	// print: 4
	return 0;
}

多继承

在多继承体系下,仍然遵顼按顺序拓展的原则,多个基类按照声明顺序依次拓展。

class A {
public:
	int member1 = 1;
protected:
	char member2 = 2;
private:
	int member3 = 3;
	static int s_member;
};

class B {
public:
	char member4 = 4;
};

// 内存模型:
// ==========B==========
// | mem4 |   padding  |
// ==========A==========
// |       mem1        |
// | mem2 |   padding  |
// |       mem3        |
// ==========C==========
// |       mem5        |
class C : B, A {
public:
	int member5 = 5;
};

int main() {
	cout << sizeof(C) << endl; // 结果:20字节
	C test;
	void* ptr_base = &test;
	char* ptr_mem4 = (char*)ptr_base;
	int* ptr_mem5 = (int*)ptr_base + 4;
	cout << (int)*ptr_mem4 << *ptr_mem5 << endl;
	// print: 45
	return 0;
}

虚继承

对于如下继承结构,如果不采用虚继承,则类型 D 中将包含两个类型 A 的实例,此时将无法区分两个类型 A 的实例。虚继承保证了继承体系中同一个类型只会存在一个实例,其内存模型也将有所不同。

class A {};
class B : virtual A {};
class C : virtual A {};
class D : B, C {};

此时就无法静态地确定各类型内存该如何分布,可以考虑类型 BC 的内存模型,如果静态考虑,那么 BC 应该一致。但此时引入 D 同时继承 BC,这时必然就包含两份 A 的实例,如果需要保证只有一份实例,那么总有一方,要么 B 要么 C 的内存模型需要更改,那么就无法静态确定内存分布了,即无法通过基址偏移确定各类型的内存位置。

为了解决这一问题,就需要引入虚基类表指针,通过虚基类表定位基类实例 A,虽然这会带来一定的开销(访问虚基类需要查表)。具体是通过偏移量来定位虚基类,对于每个基类 BC 的内部,都有一个对应的虚基类表,储存对应的偏移量,根据 BC 的基址(BC 类内的 this 指针),偏移得到虚基类的位置,从而在 BC 中操作虚基类实例 A

内存对齐在 32 位和 64 位地址有所不同,以下假设地址为 64 位,方便在现代计算机上验证。示例程序不关注虚基类表内是如何编排各类信息的,主要关注一个类型实例的内存占用,关于虚表的具体实现,可以参照附录。

// 以下图示,一行代表8个字节

class A {
public:
	int a_member = 1;
};

// 内存模型:64位地址
// ==========B===========
// |       vbptr        |
// =====B====|=====A=====
// |   b_m   |   a_m    |
class B : virtual A {
public:
	int b_member = 2;
	void b_func() {
		a_member = 123;
	}
};

// 内存模型:64位地址
// ==========C===========
// |       vbptr        |
// =====C====|=====A=====
// |   c_m   |   a_m    |
class C : virtual A {
public:
	int c_member = 3;
	void c_func() {
		a_member = 456;
	}
};

// 内存模型:64位地址
// ==========D==========
// ==========B==========
// |      b_vbptr      |
// |   b_m   | padding |
// ==========C==========
// |      c_vbptr      |
// =====C====|====D=====
// |   c_m   |   d_m   |
// ==========A==========
// |   a_m   | padding |
// ==========D==========
class D : B, C {
	int d_member = 4;
};

int main() {
	cout << sizeof(A) << endl; // 结果:4字节
	cout << sizeof(B) << endl; // 结果:16字节
	cout << sizeof(C) << endl; // 结果:16字节
	cout << sizeof(D) << endl; // 结果:40字节

	D test;
	void* base_ptr = &test;
	// 一个int*的偏移量是4字节,对应图示的半格
	int* b_member = (int*)base_ptr + 2;
	int* c_member = (int*)base_ptr + 6;
	int* d_member = (int*)base_ptr + 7;
	int* a_member = (int*)base_ptr + 8;
	cout << "a_member = " << *a_member
		<< "\nb_member = " << *b_member
		<< "\nc_member = " << *c_member
		<< "\nd_member = " << *d_member << endl;
	return 0;

	// print:
	// a_member = 1
	// b_member = 2
	// c_member = 3
	// d_member = 4
}

特例

一个没有数据成员的对象需要占据 1 字节,但如果被继承,就还原成 0 字节。虽然没有数据成员的空对象可以不占据内存,但空对象也是对象,也应该能够生成类型实例,且能够区分不同的对象实例,所以至少需要 1 个字节,让对象拥有一个实际的地址用于区分。

class A {};
class B : A {
	int member;
};

int main() {
	cout << sizeof(A) << endl; // 结果:1字节
	cout << sizeof(B) << endl; // 结果:4字节
	A a1, a2;
	cout << (&a1 != &a2) << endl; // true
}

成员函数

非虚函数不占用对象实例内存

函数储存在代码区,并不占用对象实例内存,非静态函数会隐式传入对象地址 this,所以示例中,可以通过 nullptr 调用函数,函数可以成功调用,但前提是该函数没有访问成员变量。因为成员变量通过 this 的地址通过偏移访问得到,此时如果访问,则会产生非法访问,导致未定义行为。

class A {
public:
	int member;
	void func() {
        cout << "func(): " << this << endl;
    }
	static void s_func() {
        cout << "s_func()" << endl;
    }
};

int main() {
	cout << sizeof(A) << endl; // 结果:4字节
	// 另一种验证方法
	A test;
    test.func();
    test.s_func();
    
    A* ptr = static_cast<A*>(nullptr);
    ptr->func();
    ptr->s_func();
    // print:
    // func(): 0x7fffc5be8368
	// s_func()
	// func(): 0
	// s_func()
}

虚函数:单继承

关于 C++ 的多态就不再赘述了,简单来说就是调用接口(虚函数)一致,但执行的函数不同。

为了实现多态,一个派生类可以视为基类调用,如何知道该调用基类方法还是父类方法,就需要引入虚函数表,虚函数表里记录了类型所有的虚函数地址。当一个派生类被视为基类调用一个虚函数,则会查询派生类里的虚函数表,找到对应的虚函数调用,以此来满足多态。

所以为了满足多态,任何声明了虚函数的类都包含一个指向虚函数表的地址,且一个类最多只有一个虚函数表指针。

// 64位下指针为8字节
using int64 = long int;

// 内存模型:
// ==========A==========
// |       vptr        |
// |     a_member      |
class A {
public:
	int64 a_member = 1;
	virtual void func() {}
};

// 内存模型:
// ==========B==========
// |       vptr        |
// |     a_member      |
// |     b_member      |
class B : A {
public:
	int64 b_member = 2;
	virtual void func() override {}
};

int main() {
	cout << sizeof(A) << endl; // 结果:16字节
	cout << sizeof(B) << endl; // 结果:24字节

	B b;
	void* base_ptr = &b;
	int64* a_member = (int64*)base_ptr + 1;
	int64* b_member = (int64*)base_ptr + 2;
	cout << *a_member << endl;
	cout << *b_member << endl;
	return 0;
}

虚函数:多继承

多继承情况复杂一些,由于虚函数表必须放在基址上(标准规定所有虚表都放在对象地址的第一个位置上),所以有可能违背布局按照声明顺序排列的规则。

using int64 = long int;
class A {
	int64 a_member = 1;
};

class B {
	int64 b_member = 2;
	virtual void func() {}
};

// 内存模型:
// ==========C==========
// ==========B==========
// |      b_vptr       |
// |      b_member     |
// ==========A==========
// |      a_member     |
// |      c_member     |
class C : B, A {};

// 内存布局与C一致
class D : A, B {};

int main() {
	D d;
	C c;
	void* c_base_ptr = &c;
	int64* c_a_member = (int64*)c_base_ptr + 2;
	int64* c_b_member = (int64*)c_base_ptr + 1;

	void* d_base_ptr = &d;
	int64* d_a_member = (int64*)d_base_ptr + 2;
	int64* d_b_member = (int64*)d_base_ptr + 1;

	cout << (*c_a_member == *d_a_member) << endl; // true
	cout << (*c_b_member == *d_b_member) << endl; // true
	return 0;
}

当多个基类存在虚函数时,派生类中可能存在多个虚表指针,这样才能满足各个基类的多态。我们都知道派生类可以完全视为基类看待,为了满足这一特性,在多继承中就必须保证每个基类的完整性,所以每个基类都有自己的虚表。

考虑以下继承关系,实际对象是 C 但完全可以看作是类型 AB,只是其基址不同而已,从内存的视角看,就是类型 AB,调用虚函数仍然需要查表,以此来实现多态。

using int64 = long int;

class A {
public:
	int64 a_member = 1;
	virtual void a_func() {}
};
class B {
public:
	int64 b_member = 2;
	virtual void b_func() {}
};

// 内存模型:
// ==========C========== <- C* begin
// ==========A========== <- A* begin
// |      a_vptr       |
// |      a_member     |
// ==========A========== <- A* end
// ==========B========== <- B* begin
// |      b_vptr       |
// |      b_member     |
// ==========B========== <- B* end
// |      c_member     |
// ==========C========== <- C* end

class C : public A, public B {
public:
	int64 c_member = 3;
	void a_func() override {}
	void b_func() override {}
};

int main() {
	C c;
	void* base_ptr = &c;
	int64* a_member = (int64*)base_ptr + 1;
	int64* b_member = (int64*)base_ptr + 3;
	int64* c_member = (int64*)base_ptr + 4;
	cout << *a_member << endl;
	cout << *b_member << endl;
	cout << *c_member << endl;
	return 0;
}

虚函数:虚继承

虚继承对于虚函数表来说并没有什么不同,内存布局参照数据成员的虚继承,但需要注意的是数据成员中的虚基表和虚函数表,统一为虚表,所以一个类无论有虚继承还是存在虚函数,至多只有一个虚表。

using int64 = long int;

class A {
public:
	int64 a_member = 1;
	virtual void a_func() {}
};

class B : public virtual A {
public:
	int64 b_member = 2;
	virtual void b_func() {}
};

class C : public virtual A {
public:
	int64 c_member = 3;
	virtual void c_func() {}
};

// 内存模型:
// ==========D========== <- D* begin
// ==========B========== <- B* begin
// |      b_vptr       |
// |      b_member     |
// ==========B========== <- B* end
// ==========C========== <- C* begin
// |      c_vptr       |
// |      c_member     |
// ==========C========== <- C* end
// |      d_member     |
// ==========A========== <- A* begin
// |      a_vptr       |
// |      a_member     |
// ==========A========== <- A* end
// ==========D========== <- D* end
class D : public B, public C {
public:
	int64 d_member = 4;
	void a_func() override {}
	void b_func() override {}
	void c_func() override {}
};

int main() {
	cout << sizeof(D) << endl;
	D d;
	void* base_ptr = &d;
	int64* a_member = (int64*)base_ptr + 6;
	int64* b_member = (int64*)base_ptr + 1;
	int64* c_member = (int64*)base_ptr + 3;
	int64* d_member = (int64*)base_ptr + 4;
	cout << *a_member << endl;
	cout << *b_member << endl;
	cout << *c_member << endl;
	cout << *d_member << endl;
	return 0;
}

纯虚函数的继承

这部分并没有什么特殊的例外,纯虚函数是一个接口,只是不能被实例化而已,当被继承后,和继承带有虚函数的普通类没什么不同。

类型裁切

类型裁切是另一个理解 C++ 内存模型的角度,派生类可以发生裁切从而适配基类,这里适配可以是复制移动,也可以仅仅是视为基类。结合 虚函数:多继承 这一节内存模型的理解,类型裁切本质上也只是派生类可以完全替代基类的一种概念延申。

class Base {
public:
	Base() : base(0) {
		cout << "Base()" << endl;
	}
	Base(const Base& val) {
		cout << "Base copy" << endl;
	}
	Base& operator=(const Base& val) {
		cout << "Base operator= copy" << endl;
		return *this;
	}
	Base(Base&& val) {
		cout << "Base move" << endl;
	}
	Base& operator=(Base&& val) {
		cout << "Base operator= move" << endl;
		return *this;
	}
	virtual void func() {
		cout << "Base::func()" << endl;
	}
	int base;
};

class Derived : public Base {
public:
	Derived() : derived(0) {
		cout << "Derived()" << endl;
	}
	Derived(const Derived&)            = delete;
	Derived& operator=(const Derived&) = delete;
	Derived(Derived&&)                 = delete;
	Derived& operator=(Derived&&)      = delete;
	void func() override {
		cout << "Derived::func()" << endl;
	}
	int derived;
};

// 派生类调用该函数会产生适配
// 适配有两种,复制和移动无法体现多态,视为基类(指针或引用)可以体现多态
void shrink(Base b, Base& ref, Base* ptr) {
	b.func();     // Base::func()
	ref.func();   // Derived::func()
	ptr->func();  // Derived::func()
}

int main() {
	Base b;
	Derived dev;
	// 发生裁切,Derived 被裁切为基类 Base
	b = dev;  			// 调用拷贝赋值
	b.func(); 			// 调用 Base::func()
	b = std::move(dev); // 调用移动赋值

	Base copy(dev);
	Base move(std::move(dev));
	
	// Base 需要支持拷贝构造,Derived 可以不支持,因为拷贝只发生在 Base 类
	shrink(dev, dev, &dev);
}

附录:虚表结构

综上,虚表中包含了虚函数地址和虚基类的偏移量,C++ 还为虚表多加了一个类型元数据的表项。

所以简单说,虚表中有以下数据:2 和 3 一定存在一个,否则就不会构造出虚表

  1. 类型元数据(一定存在)
  2. 虚函数地址
  3. 虚基类的偏移量

那么在内存中,虚表是如何编排的呢?

我们拿两个典型的继承结构来管中窥豹:

  1. 只有虚基表
class A {
public:
    int64 a_member;
};

class B : virtual A {
public:
    int64 b_member;
};

class C : virtual A {
public:
    int64 c_member;
};

// 内存模型:
// =========top=========
// |      b_vptr       | 0
// |      b_member     | 8
// |      c_vptr       | 16
// |      c_member     | 24
// |      d_member     | 32
// ========base_a======= 
// |      a_member     | 40
// =========end========= 48
//
// 虚表内容:
// ======b_vtable======
// |  offset to base  | [b_vptr -> base_a]: 40
// |  offset to top   | [b_vptr -> top]:    0
// |  typeinfo data   |
// | virtual function | <- b_vptr
// ======c_vtable======
// |  offset to base  | [c_vptr -> base_a]: 24
// |  offset to top   | [c_vptr -> top]:   -16
// |  typeinfo data   |
// | virtual function | <- c_vptr
class D : public B, public C {
public:
    int64 d_member;
};

int main() {
	D d;
	B* b = &d;
	void* b_vtable = (void*)*(int64*)b;
	int64* b_offset_base = ((int64*)b_vtable - 3);
	int64* b_offset_top = ((int64*)b_vtable - 2);
	int64* b_meta_data = ((int64*)b_vtable - 1);
	cout << "b offset to base: " << *b_offset_base << endl;
	cout << "b offset to top: " << *b_offset_top << endl;
	cout << "b meta data: " << *b_meta_data << endl;
	// print:
	// b offset to base: 40
	// b offset to top: 0
	// b meta data: 4202856
	
	C* c = &d;
	void* c_vtable = (void*)*(int64*)c;
	int64* c_offset_base = ((int64*)c_vtable - 3);
	int64* c_offset_top = ((int64*)c_vtable - 2);
	int64* c_meta_data = ((int64*)c_vtable - 1);
	cout << "c offset to base: " << *c_offset_base << endl;
	cout << "c offset to top: " << *c_offset_top << endl;
	cout << "c meta data: " << *c_meta_data << endl;
	// print:
	// c offset to base: 24
	// c offset to top: -16
	// c meta data: 4202856 两者类型原信息是一致的,因为实际类型都是D
	return 0;
}
  1. 只有虚函数表
class A {
public:
    int64 a_member = 1;
	virtual void func() {}
	virtual void func2() {}
};

// 内存模型:
// |       vptr        |
// |      a_member     |
// |      b_member     |
// 虚表内容:
// =======vtable=======
// |  offset to base  | 无意义
// |  offset to top   | 无意义
// |  typeinfo data   |
// |      func()      | <- vptr
// |      func2()     |
class B : A {
public:
    int64 b_member = 2;
	void func() override {
		cout << "func" << endl;
	}
	void func2() override {
		cout << "func2" << endl;
	}
};

int main() {
	B b;
	void* vtable = (void*)*(int64*)&b;
	auto func = (*(void(*)())(*(int64*)vtable));
	func(); // print: func
	auto func2 = (*(void(*)())(*((int64*)vtable + 1)));
	func2(); // print: func2
	return 0;
}

以上两种情况就可以大致看出,虚表中各个内容是如何编排的,虚基表存储在虚表指针上方,虚函数表存储在虚表指针下方,存储顺序按基类中函数的声明顺序决定。

上一篇 下一篇

评论 | 0条评论