C++ 内存分配作业解析

内存管理是 C++ 最令人切齿痛恨的问题,也是 C++ 最有争议的问题,C++ 高手从中获得了更好的性能,更大的自由,C++ 菜鸟的收获则是一遍一遍的检查代码和对 C++ 的痛恨,但内存管理在 C++ 中无处不在,内存泄漏几乎在每个 C++ 程序中都会发生,因此要想成为 C++ 高手,内存管理一关是必须要过的。

该作业是在 Boolan 的 C++ 系列课程中遇到的,作业的内容主要涉及 C++ 中的各种类型对象在内存中是如何存储的概念。比起常见的作业实现,这个作业更偏理论;所以这里记载下自己的思路,以便于以后参考。

题目

分别给出下面的类型Fruit和Apple的类型大小(即对象size),并通过画出二者对象模型图以及你的测试来解释该size的构成原因。

Fruit { 
    int no; 
    double weight; 
    char key; 
public: 
    void print() { } 
    virtual void process(){ } 
}; 

class Apple: public Fruit{ 
    int size; 
    char type; 
public: 
    void save() { } 
    virtual void process(){ } 
}; 

环境和约定

  • 操作系统64 位。
  • 编译器及版本GCC 5.4.0(后续使用 VS2015 进行对比,但说明部分使用 GCC 的数据)
  • 为了测试方便,将私有变量全部改为了公有变量。

GCC-64 位编译器中与本题相关变量大小一览(单位:字节):

TypeSpace
char1
int4
double8
char*8

估算

注:我这里将空类和空函数占用的内存空间搞混,导致估算的时候将空函数占用的空间设定为 1。同时我假设了虚函数的继承并不占用子类空间。

按照题目的描述,我做出了如下的估算:Fruit 类是基类,其包含了 3 个私有变量,2 个函数。

TypeNumberTotal Space
char11
int14
double18
empty Function22

根据上表可以做出初步估算:

Fruit 占用空间为 1 + 4 + 8 + 2 = 15

Apple 类是派生类, 因此不但要计算自身的空间占用,还要加上继承自基类的空间占用。因此其包含了 5个私有变量,2 个空函数:

TypeNumberTotal Space
char22
int28
double18
empty Function32

上表可以做出初步估算: Apple 占用空间为 2 + 8 + 8 + 6 = 24

单位大小测试

接下来根据题目要求使用 sizeof() 函数测试类对象大小。测试代码如下:

int main(int argc, char const *argv[]) 
{ 
    Fruit myFruit; 
    Apple myApple; 
    //result 
    cout << "size of Fruit class object: " << sizeof(myFruit) << endl; 
    cout << "size of Apple class object: " << sizeof(myApple) << endl; 
    return 0; 
} 

得到的结果:

size of Fruit class object: 32 
size of Apple class object: 40 

看来估算和实际相差非常大啊。接下来测试一下类中每一个部分是否和我们估算的相同。先测试一下变量:

void 
Fruit::fruitPrinter() 
{ 
   cout << "size of Fruit.no (int): " << sizeof(no) << endl; 
   cout << "size of Fruit.weight(double):" << sizeof(weight) << endl; 
   cout << "size of Fruit.key(char): " << sizeof(key) << endl; 
} 
void 
Apple::applePrinter() 
{ 
   cout << "size of Apple.size (int): " << sizeof(size) << endl; 
   cout << "size of Apple.tpye(char):" << sizeof(type) << endl; 
} 

结果如下:

size of Fruit.no (int): 4 
size of Fruit.weight(double): 8 
size of Fruit.key(char): 1 
size of Apple.size (int): 4 
size of Apple.tpye(char): 1 

我们发现 build-int 的变量测试的大小均与我们之前推测的内容一致。看来大小的不同应该是函数引起的。而本题中还包括了虚函数;因此我们可能需要更详细的对类和虚函数进行大小测试。

函数大小测试

接下来我们需要验证类的大小,空的成员函数的大小,以及虚函数的大小。测试方法是将各种元素包含进类里,再使用 sizeof 函数测试(因为 sizeof() 并不支持测试函数。用于测试的代码为:

int main(int argc, char const *argv[]) 
{ 
    Fruit myfruit; 
    cout << sizeof(myfruit) <<endl; 
    return 0; 
} 

Test.1 验证一下空类的大小:

class Fruit {}; 

输出结果为:

size of an empty class: 1 

Test.2 通过空类包含一个空函数验证一下普通空成员函数的大小:

class Fruit { 
public: 
    void print() { } 
}; 

输出结果为:

size of an empty class with a empty member function: 1 

Test.3 添加一个空函数,测试空间是否增长:

class Fruit { 
public: 
    void print() {} 
    void print2() {} 
}; 

输出结果为:

size of an empty class with a empty member function: 1 

Test.4 测试虚函数的大小:

class Fruit { 
public: 
    virtual void process() { } 
}; 

输出结果为:

size of an empty class with a empty virtual function: 8 

Test.5 测试虚函数是否占用子类空间:

class Fruit { 
public: 
    virtual void process() { } 
}; 
class Apple { 
};

输出结果为:

size of Apple with a empty virtual function: 8 

但这里通过 Apple 类进行虚函数的重写发现,重写并不会增加虚函数所占用子类的空间,因此可以得出结论:虚函数只占用 8个字节的内存空间。

通过上面的测试过程,我们发现:

  • 空类占用 1 字节的空间。
  • 空函数并不占用空间。
  • 虚函数会占用 8 字节的空间。
  • 子类如果重写虚函数并不会占用额外的内存空间。

根据上述测试结果,我们可以把估算的结果修正一下:

Fruit 占用空间为 1 + 4 + 8 + 8 = 21
Apple 占用空间为 2 + 8 + 8 + 8 = 26

很显然,这跟我们在第二步得出的测试结果还是有差距的。类成员所占的内存空间已经完整验证;我们只能推测:类成员在类中的存储并不是连续的。因此,我们需要进行第四步:地址的连续性测试。

内存地址测试

我们通过以下代码测试类和各个成员的地址:

cout << "Fruit = " << &myFruit << endl; 
cout << "Fruit.no = " << &myFruit.no << endl; 
cout << "Fruit.weight = " << &myFruit.weight << endl; 
cout << "Fruit.key = " << (void*)&myFruit.key << endl; 
cout << "Apple = " << &myApple << endl; 
cout << "Apple.no = " << &myApple.no << endl; 
cout << "Apple.weight = " << &myApple.weight << endl; 
cout << "Apple.key = " << (void*)&myApple.key << endl; 
cout << "Apple.size = " << &myApple.size << endl; 
cout << "Apple.type = " << (void*)&myApple.type << endl; 

测试结果为:

Fruit = 0xffffcbf0 
Fruit.no = 0xffffcbf8 
Fruit.weight = 0xffffcc00 
Fruit.key = 0xffffcc08 
/-----------------------/ 
Apple = 0xffffcbc0 
Apple.no = 0xffffcbc8 
Apple.weight = 0xffffcbd0 
Apple.key = 0xffffcbd8 
Apple.size = 0xffffcbdc 
Apple.type = 0xffffcbe0 

通过结果我们可以看出来问题了:类成员在内存中的存放并不是连续性的。比如 Fruit.noint 类型,应占 4 个字节,但从它的起始地址和结束地点来看,他其实占了 8 个字节的的内存。

相关解释

通过上面的一系列测试,有三个问题需要解释:

  • 为什么空类会占用 1 个字节的内存空间?
  • 为什么虚函数会占用 8 个字节的内存空间?
  • 为什么类成员在内存中的排列不是连续的?

空类的内存占用

空类大小为 1 个字节的解释来自于 C++ 标准。 C++ 标准规定了:

No object shall have the same address in memory as any other variable.

即任何不同的对象都不能拥有相同的内存地址。试想一下,如果空类大小为 0,如果我们声明一个这个类的对象数组,那么数组中的每个对象都拥有了相同的地址了;这显然违背了标准。

为什么会有这样的标准呢?

举个例子,我们可以通过数组地址相减来得到地址之间的长度。但单从地址上相减,是得不到长度的;编译器一定会做一个除以 sizeof(T) 的操作来得到单位。如果我们允许不同对象拥有相同地址,那么对于类中的对象数组,每一个数组的位置都是相同的。我们根本没办法通过指针来得到不同对象之间的长度;另外,如果允许类大小为 0,那么我用 sizeof() 对类对象使用,得到的结果也是 0。此时对于求两个空类对象之间的距离,就是除零操作,这是非常危险的。因此,C++ 设定了不允许任何类型的大小为 0,从而避免了这个问题。

虚函数的内存占用

要理解为什么虚函数占用了 8 个字节,首先我们需要理解虚函数的工作原理。

首先,虚函数存在着重写。因此,我们需要额外的信息来判断我们需要调用哪一个虚函数。 C++ 中使用了 vptr (Virtual table pointer, 虚函数表指针) 来存放此类信息。vptr 指向一个被称为 vtbl (Virtual table, 虚函数表) 的函数指针数组。通过 vptr,我们将每一个包含有虚函数的类都关联到了 vtbl

当我们调用一个虚函数的时候,我们通过查询对应对象的 vptr,就可以在 vtbl 里寻找合适的虚函数了。

因此我们可以发现, vptr 对于 vtbl 实际上是多对一个关系;我们需要每个有虚函数的对象都维护一个自己的 vptr,以便编译器顺利的在 vtbl 里找到该对象对应的虚函数。而这个指针,正是 8 个字节。不难想象到,这就是为什么虚函数会在类对象中占 8 个字节的原因。

同时需要注意的是,vtbl 对于每个类是唯一的,是这个类所有的对象公有的。 每当创建一个包含有虚函数的类时,编译器就会为这个类创建一个虚函数表 vtbl 保存该类所有虚函数的地址。这个 vtbl 是可以被继承的。

vtpr 在内存里的位置大致如下图所示:
1325722060.png

类成员的不连续排列

之所以会出现这样的情况,是因为 C++ 中有内存对齐(alignment)的机制。

内存对齐出现的原因

为什么会出现内存对齐这个约定概念?

主要的原因就是为了速度。在这一点上,内存对齐是一种典型的用空间换时间的做法。

常见的 CPU 在读取数据的时候,按 字长(32 位机器上是 4 字节,64 位机器上是 8 字节)来读取(方便物理逻辑)。假设是 32 位的处理器下,我们需要读取一个 int 类型的数据。而这个数据的位置恰好又如图所示:

527952938.png
我们发现一个 32 位的 CPU 必须经过两次读操作才能拿到这个数据:先读第一个字长里的三个字节,再读第二个字长里的一个字节。这样的内存存储结构,也被称为未对齐的存储结构。显然的是,地址不对齐会导致 CPU 访问变慢。

对于 CPU 来说,访问内存的速度实际上是非常慢的。而如果我们对地址未对齐的内存进行访问,不但会降低访问速度,同是也会增加 CPU 设计的难度。CPU 必须要针对这样的存储结构设计处理逻辑。而如果我们把数据都设置成一个兼容一致的模型,那么 CPU 就可以通过统一的逻辑去读取数据了;而这个逻辑恰好非常简单(只需读取首地址 + 字长的数据,然后在出口处移位就可以)。

为了实现这个逻辑,数据的偏移量 (offset) 必须要能够末除自身的长度,如果不是,就必须补齐。举个例子:比如一个 double 的数据在 32 位的电脑中,CPU 必须要读取两次才能读完这个数据。我们看到这个数据的长度对于单个字长(4 字节/ 32位)来说,偏移量为 4 字节;而 4 mod 4 == 0; 因此完美的满足了上述的要求。

上述的对齐可以成为数据成员的内存对齐。

类中的内存对齐规则

结构体 / 类 作为多个数据的结合体,是讨论内存对齐的一个很好的例子。多个数据应该设置怎样的对齐长度(padding)才能满足内存对齐的条件?

来看看下面的例子:

struct A { 
    char ca; 
    int ia; 
    short sia; 
}; 

struct B { 
   short sib; 
   char cb; 
   int ib; 
}; 

如果对这两个结构体进行 sizeof() 运算,得到的结果是:

sizeof(A) = 12 
sizeof(B) = 8 

我们需要着重的来看看为什么 A 是 12。来看看下面的的类在内存中的空间占用图:
2942777813.png
从上图我们可以看出为什么 A 必须是 12,而 B 为什么大小只有 8 的原因。相信大家也明白为什么结构体 / 类在所有成员自行对齐以后,还必须在末尾再对齐一次:这样最大的确保了在添加新元素时与原来处理方法的一致性。而 B 则是一种我比较喜欢的优化方法:又减少了内存对齐造成的空间浪费,效率还要比 A 高。这说明除了靠编译器,我们自行安排类中元素的排列顺序也是非常重要的。

当然不管方法如何,我们必须保证整个结构体占用内存大小是结构体内最大数据成员的最小整数倍。只有这样,才能保证在下一次安排新成员的时候,首地址位于字节单位的首部。

对其模数

不同的编译器会按照自身默认的对齐模数来进行对齐。下面的测试是在 64 位的 VS2015 和 GCC 编译器下测试的结果:

/* VS 2015 */ 
size of Fruit class object: 32 
size of Apple class object: 40 
size of Fruit.no (int): 4 
size of Fruit.weight(double):8 
size of Fruit.key(char): 1 
size of Apple.size (int): 4 
size of Apple.tpye(char):1 
Fruit = 008FFB70 
Fruit.no = 008FFB78 
Fruit.weight = 008FFB80 
Fruit.key = 008FFB88 
Apple = 008FFB18 
Apple.no = 008FFB20 
Apple.weight = 008FFB28 
Apple.key = 008FFB30 
Apple.size = 008FFB38 
Apple.type = 008FFB3C 
请按任意键继续. . . 
/* GCC 5.4.0 */ 
size of Fruit class object: 32 
size of Apple class object: 40 
size of Fruit.no (int): 4 
size of Fruit.weight(double):8 
size of Fruit.key(char): 1 
size of Apple.size (int): 4 
size of Apple.tpye(char): 1 
Fruit = 0xffffcbf0 
Fruit.no = 0xffffcbf8 
Fruit.weight = 0xffffcc00 
Fruit.key = 0xffffcc08 
Apple = 0xffffcba0 
Apple.no = 0xffffcba8 
Apple.weight = 0xffffcbb0 
Apple.key = 0xffffcbb8 
Apple.size = 0xffffcbbc 
Apple.type = 0xffffcbc0 

当然这两次测试结果差不多,说明 GCC 和 VS2015 的默认模数是一致的。

我们也可以通过以下预处理命令对其模数进行修改:

#pragma pack(n) 

总结

通过上述的学习,可以总结的是:

类的大小影响因素有:

  • 类为空的时候,默认大小为 1, 为了保证类对象地址不重复。
  • 空函数不占用类空间。
  • 虚函数占用指针长度的空间,子类重写不会增加空间。
  • 内存对齐对空间影响很大。

内存对齐的规则:

  • 自我对齐:数据的对于字长的偏移量对字长求模应等于 0;如果不等于 0 则需要在该字长内补足余下字节。
  • 结构体对齐:结构体的大小必须是最大成员大小的整数倍,否则在最后一个成员末尾补齐余下字节。
  • 不同编译器通过自身默认的对其模数对齐;对齐模数可以通过预处理命令 pragma pack 修改。

附表.结构图和测试程序

Download