C++内存对齐
300 Words | Read in about 2 Min | View times
Overview
现代计算机的处理器为了高效地处理数据的存取,会以内存存取粒度为单位进行。为了让数据在内容中能够以内存存取粒度为单位进行排列,就需要内存对齐技术。本节内容将介绍内存对齐的意义和内存对齐规则。
本系列文章将包括以下领域:
本章其他内容请见 《现代C++》
我们在《位域》这篇文章中提到过位域的对齐规则,本质上也属于内存对齐的一种情形。位域的对齐是对小于一个字节的数据进行压缩,是以位数为单位进行的;而本文所要讲述的内存对齐,是针对超过一个字节的数据类型进行压缩,是以字节为单位进行的。
什么是内存对齐
操作系统有64位和32位之分。64位操作系统意味着CPU有更强的寻址能力。理论上讲,性能会比32位操作系统提升1倍。
程序中的数据类型的字节数大小,其实和操作系统是多少位没有关系,而是由编译器决定的。数据类型占用的字节数取决于编译时选择的编译器是64位还是32位。在不同位数的编译器下基本数据类型的字节数大小如下:
32位编译器(单位:字节) | 64位编译器(单位:字节) | |
---|---|---|
char | 1 | 1 |
char* | 4 | 8 |
short | 2 | 2 |
int | 4 | 4 |
int* | 4 | 8 |
unsigned int | 4 | 4 |
float | 4 | 4 |
double | 8 | 8 |
long | 4 | 8 |
long | 8 | 8 |
unsigned long | 4 | 8 |
可以看出,主要差别在指针和long
的大小:32位指针大小是4,64位指针大小是8;32位long
大小是4,64位long
大小是8。
为了保证每个对象拥有彼此独立的内存地址,C++空类的内存大小为1字节,而非空类的大小与类中非静态成员变量和虚函数表的多少有关。其中,类中非静态成员变量的大小则与编译器的位数以及内存对齐的设置有关。类中的成员变量在内存中并不一定是连续的,它是按照编译器的设置,按照内存块来存储的,这个内存块大小的取值,就是内存对齐。
为什么要内存对齐
- 平台原因
不是所有的CPU都能访问任意地址上的任意数据的,有些CPU只能在某些地址处取某些特定类型的数据,否则抛出硬件异常。
- 性能原因
数据结构应该尽可能地在自然边界上对齐,如果为了访问未对齐的内存,处理器需要作两次内存访问,而对齐的内存访问仅需要一次访问。CPU处理器把内存当作一块一块去读取,块的大小可以是2、4、8、16字节大小,这个大小称为内存存取粒度。假设当前处理器的内存存取粒度为4,对于一个int
变量(大小4字节),分两种情况讨论:
- 数据从第0字节开始存放(已内存对齐)
CPU只需要访存一次,就可以把4字节数据读完,然后存入寄存器。
- 数据从第1字节开始存放(没有内存对齐)
数据不处于自然边界上,CPU需要分两次访存,第一次先访问[0, 3]字节进入寄存器,第二次访问[4, 7]字节进入寄存器,然后剔除第0、5、6、7字节,仅留第1、2、3、4字节数据进入寄存器。对于未内存对齐的数据,显然大大降低了CPU的处理性能。这种未对齐的情况有些CPU甚至直接开摆。
内存对齐规则
不同特定平台上的编译器都有自己的默认对齐系数。
C/C++中,可以通过预编译代码修改对齐系数:
用预编译命令#pragma pack(n)
用来指定对齐系数,单位字节。n
的取值范围为1, 2, 4, 8, 16
。gcc默认对齐系数是4
,msvc默认对齐系数是8
。
用预编译命令#pragma pack()
用来取消自定义的对齐系数,恢复为默认值。
1//#pragma pack(4) //默认对齐系数是4
2#pragma pack(1) //修改对齐系数为1
3
4...
5
6#pragma pack() //取消自定义的对齐系数,恢复为默认值4
假设在一个结构体中,最大数据类型长度为m
,编译器对齐系数是n
,则min(m, n)
叫做对齐单位s
。所以当设置的对齐系数n
大于类中最大数据类型长度,该设置是不起作用的。当n
等于1
时,整个类的大小为所有成员长度之和。
了解了以上概念后,我们来看具体的内存对齐规则:
-
每个成员的对齐规则:类中第一个成员的偏移量(offset)为
0
,以后每个成员(该成员的数据类型长度为k
)相对于结构体首地址的offset为min(k, s)
的整数倍。 -
如果一个类里有结构体成员,则结构体成员要从其内部最宽基本类型成员的整数倍地址开始存储。
-
整体对齐规则:整个结构体的大小应是对齐单位
s
的整数倍。
接下来我们通过一些例子来体会内存对齐规则。
case 1
1struct Data1 {
2 char a;
3 int b;
4};
5
6std::cout << sizeof(Data1) << std::endl; //8
case 2
1struct Data2 {
2 char a;
3 double b;
4};
5
6std::cout << sizeof(Data2) << std::endl; //16
case 3
1struct Data3 {
2 char a;
3 int b;
4 char c;
5};
6
7std::cout << sizeof(Data3) << std::endl; //12
case 4
1struct Data4 {
2 char a;
3 char b;
4 int c;
5};
6
7std::cout << sizeof(Data4) << std::endl; //8
case 5
1struct BigData {
2 char array[9];
3};
4
5struct Data5 {
6 BigData a;
7 int b;
8 double c;
9};
10
11std::cout << sizeof(Data5) << std::endl; //24
case 6
1struct Data6 {
2 BigData a;
3 int b;
4};
5
6std::cout << sizeof(Data6) << std::endl; //16
case 7
1struct Data7 {
2 BigData a;
3 double b;
4};
5
6std::cout << sizeof(Data7) << std::endl; //24
延伸
C++不允许一个对象的大小为0
,不同对象的地址不能具有相同的地址。
这是因为new
需要分配不同的内存地址,不能分配内存大小为0
的空间,避免除以sizeof(T)
引发除零异常。
所以一个没有数据成员的空类,编译器会为其分配1字节的内存空间,即空类的大小为1
。
空基类被继承后,如果派生类有自己的数据成员,那么空基类这1个字节不会添加到派生类中。
关于new
操作,可以阅读一下之前的文章《new表达式、operator new和placement new》
关于对象内存模型,可以阅读一下之前的文章《对象内存模型》