结构体内存对齐
结构体内存对齐是什么?
结构体内有一个或者多个成员变量,这些成员变量是要“对齐”的。这么说可能有点抽象,我们先来了解一下内存对齐的规则,以及几个概念。
每个成员变量都有一个“对齐数”,这个对齐数等于其自身大小和默认对齐数的较小值。
举个例子:
struct S { int a; char c; double d; ];
对于上面这个结构体,有3个成员变量,每个成员变量都有一个对齐数。默认对齐数是什么,我们下一条规则再说,先假设默认对齐数是4。
- a的大小是4,默认对齐数是4,取两者的较小值,得到a的对齐数是4。
- c的大小是1,默认对齐数是4,取两者的较小值,得到c的对齐数是1。
- d的大小是8,默认对齐数是4,取两者的较小值,得到d的对齐数是4。
默认对齐数是由编译器决定的。如:VS的默认对齐数是8,gcc没有默认对齐数。如果没有默认对齐数,每个成员变量的对齐数就是其自身大小(或者也可以假设默认对齐数是正无穷,取自身大小和默认对齐数的较小值,也是自身大小)。关于如何修改默认对齐数,本篇博客后面会讲解。
每个成员变量都有一个“偏移量”。这个偏移量指的是,该成员变量的起始地址与结构体的起始地址相差了几个字节。成员变量会按照声明的顺序,地址从低到高变化。第一个成员变量的偏移量是0,也就是说,第一个成员变量的起始地址和结构体的起始地址相同。或者也可以这样理解,对于一个结构体的空间内,每一个地址都对应一个偏移量,这个偏移量就是该地址和结构体的起始地址相差的字节数。
从第二个成员变量开始,偏移量是其对齐数的整数倍。
还是上面的结构体,我再写一遍,大家就不用向上翻了。
struct S { int a; char c; double d; ];
假设默认对齐数是8,根据前面的计算,a、c、d的对齐数分别是4、1、4。
- a作为第一个成员变量,偏移量是0。由于其大小是4,会占用偏移量是0、1、2、3的地址处。
- 接下来可用的位置是偏移量为4的地址处,由于4就是c的对齐数的整数倍,故c会占用偏移量是4的位置,并且只占用这一个字节,因为c的大小是1个字节。
- 接下来可用的位置是偏移量为5的地址处,注意,由于5不是d的对齐数的整数倍,接下来的6、7也不是,所以d的偏移量是8,并且占用8个字节,因为d的大小是8个字节,占用了偏移量为8、9、10、11、12、13、14、15的地址处。
综上所述,a占用偏移量为0、1、2、3的地址处,c占用偏移量为4的地址处,偏移量为5、6、7的地址处被浪费了,接下来d占用偏移量为8、9、10、11、12、13、14、15的地址处。由于有的位置被浪费了,这种浪费一定的空间,使得每个成员变量的偏移量都对齐到其对齐数的整数倍处的现象,就是内存对齐。
此时,这个结构体的大小是多少呢?看起来,这些成员变量占用了偏移量从0~15的地址处,总大小应该是16,但是究竟是不是16呢?这就要看下一条规则:
结构体的总大小是其所有成员变量的最大对齐数的整数倍。
由于以上结构体的成员变量的对齐数分别是4、1、4,最大对齐数是4,而16就是4的整数倍,所以它的大小就是16。注意,这是一种巧合,如果16不是最大对齐数的整数倍,还要继续“浪费”空间,最终大小是最大对齐数的整数倍。
假设根据前面偏移量的计算,最后一个成员变量的偏移量是16~19,此时总体的偏移量是0~19,总大小是不是20呢?假设所有成员变量的最大对齐数是8,那么20就不是最终的大小,21也不是,22也不是,23也不是,24是8的整数倍,所以最终算出来的结构体大小是24。
如果有嵌套的结构体呢?
如果有嵌套的结构体,最终的大小是所有成员变量(包括嵌套的结构体的成员变量)的最大对齐数的整数倍。
如果有的成员变量是数组呢?
数组在内存中是连续存放的,只需要计算其首元素的偏移量即可。注意,数组的对齐数只是一个元素的对齐数,也就是说,只需要计算一个元素的大小和默认对齐数的较小值。
如何快速计算结构体的大小?
在一些笔试面试的问题中,会要求计算结构体的大小。此时我们的计算步骤是:
- 计算每个成员变量的对齐数(自身大小和默认对齐数的较小值)。
- 计算每个成员变量占用的地址的偏移量。
- 计算最终大小。
下面我再换一个结构体演示一下这几个步骤。
struct S { char c1; double d; char c2; int i; };
假设默认对齐数是8。
- 计算每个成员变量的对齐数(自身大小和默认对齐数的较小值),我用括号里的数表示该成员变量的对齐数。
struct S { char c; // (1) int i1; // (4) double d; // (8) int i2; // (4) char ch[2]; // (1) };
- 计算每个成员变量占用的地址的偏移量。我用中括号表示浪费的空间,用大括号表示占用的地址的偏移量。
struct S { char c; // (1) {0} int i1; // (4) [1 2 3] {4 5 6 7} double d; // (8) {8 9 10 11 12 13 14 15} int i2; // (4) {16 17 18 19} char ch[2]; // (1) {20 21} };
- 计算最终大小。
最大对齐数是8。从22往后数,直到数到8的倍数。22、23、24,停!没错,最终大小就是24。你学会了吗?
如何利用内存对齐节省结构体占用的内存空间?
观察到,只要把小的成员变量都放到一起,本来有可能会被浪费的空间就有可能被这些小的成员变量利用。感觉上,本来有些空间由于内存对齐的原因被浪费了,产生了空隙,而小的成员变量,就像沙子一样,可以填补这些空隙,这样就减少了空间的浪费,提高了空间的利用率。
为什么结构体要内存对齐?
主要有2个原因。
有些硬件只能访问对齐的位置的地址,如果没有内存对齐,可能有些数据是没办法访问的。
以空间换时间。如果没有内存对齐,可能需要访问2次才能读取一个数据,对齐之后,可能1次访问就能读取到数据,效率更高。举个例子:假设结构体内只有2个成员变量,分别是一个char数据和一个int数据,如果不对齐,char数据和int数据是挨着放的,只占用5个字节的空间,如果想要读取到int数据,假设一次只能读取4个字节,并且只能从对齐的位置(char数据所在的位置)开始读取,第一次只能读取到int数据的前3个字节,第二次读取才能读到int数据的最后一个字节,需要读取2次。但是如果对齐的话,就能直接从int数据的起始地址开始读,一次读取就能读取到整个int数据,效率就提升了,但是浪费了空间。所以说这是一种以空间换时间的策略。
如何修改默认对齐数?
可以用#pragma pack()来修改默认对齐数。比如:
#pragma pack(4)
以上代码就把默认对齐数修改为4。
#pragma pack()
以上代码,由于括号内没有数值,会把默认对齐数重置为编译器的默认值。
总结
结构体的内存对齐指的是,通过某些规则,“浪费”掉一定的空间,把每个成员变量的偏移量对齐到其对齐数的整数倍处。
可以通过3个步骤快速计算结构体大小,分别是:先计算每个成员变量的对齐数,再计算每个成员变量占用的地址的偏移量,最后计算整个结构体的大小。
把较小的成员变量放到一块,可以减小空间的浪费。
结构体的内存对齐是为了适应一些硬件,同时以空间换时间。
使用#pragma pack()来修改默认对齐数。