内存对齐
CPU 要想从内存读取数据,需要通过地址总线把地址传输给内存,内存准备好数据后输出到数据总线交给CPU。
1
2
地址总线 数据总线
CPU —————————> RAM —————————> CPU
如果地址总线只有8根,那这个地址就只有8位,可以表示256个地址。因为表示不了更多的地址,就不能使用更大的内存,所以256就是8根地址总线最大的寻址空间。
要使用更大的内存,就要用更宽的地址总线。例如32位地址总线,就可以寻址4GB的内存了。
每次操作1字节太慢,那就加宽数据总线。要想每次操作4字节,至少需要32位数据总线。若要每次操作8字节则需要64位数据总线。这里每次操作的字节数,就是所谓的机器字长。
内存条并不是一个大的内存矩阵,为了实现更高的访问效率,典型的内存布局包含了多个chip,而一个chip有包括8个bank。在bank这里才可以通过行和列来定位一个内存地址。
由于不是所有的硬件平台都能访问任意地址上的任意数据,编译器会把各种类型的数据安排到合适的地址,并占用合适的长度,这就是内存对齐。每种类型的对齐值就是它的对齐边界。
经过内存对齐之后,CPU的内存访问速度大大提升。
对齐边界
内存对齐要求:数据存储的起始地址以及占用的字节数都要是它对齐边界的倍数。
现在的问题是怎么确定每种类型的对齐边界呢?这和平台架构有关,比如386、amd64、arm、arm64…。
常见的32位平台,指针宽度和寄存器宽度都是4字节。64位平台上都是8字节。而被Go语言称为寄存器宽度的这个值就可以理解为机器字长,也是平台对应的最大对齐边界。
数据类型的对齐边界是取类型大小与平台最大对齐边界中较小的那个,不过要注意同一个类型在不同的架构平台上大小可能不同。
64位平台架构下:
| 类型 | 类型大小 | 平台最大对齐边界 | 对齐边界 |
|---|---|---|---|
| int8 | 1byte √ | 8byte | 1byte |
| int16 | 2byte √ | 8byte | 2byte |
| int32 | 4byte √ | 8byte | 4byte |
| int64 | 8byte √ | 8byte | 8byte |
| string | 16byte | 8byte √ | 8byte |
| slice | 24byte | 8byte √ | 8byte |
| … |
32位平台架构下:
| 类型 | 类型大小 | 平台最大对齐边界 | 对齐边界 |
|---|---|---|---|
| int8 | 1byte √ | 4byte | 1byte |
| int16 | 2byte √ | 4byte | 2byte |
| int32 | 4byte √ | 4byte | 4byte |
| int64 | 8byte | 4byte √ | 4byte |
| string | 8byte | 4byte √ | 4byte |
| slice | 12byte | 4byte √ | 4byte |
| … |
类型边界会这样选择是为了减少浪费,提高性能。Go语言中的类型占用大小可以调用 unsafe.SizeOf 查看。
结构体
再来看下怎么确定一个结构体的对齐边界。
对于结构体而言,首先要确定每个成员的对齐边界, 然后取出其中最大的。
1
2
3
4
5
6
type T struct {
a int8 1byte
b int64 各成员对齐值 8byte 取最大值
c int32 ——————————> 4byte ———————————> 8byte
d int16 64位 2byte
}
然后来存储这个结构体变量,看看它怎么对齐。内存对齐第一个要求:存储这个结构体的起始地址是对齐边界的倍数。
假设从索引0处开始,结构体的每个成员在存储时,都要把这个地址当做起始地址。
1
2
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
|a| | | | | | | |b|b| b| b| b| b| b| b| c| c| c| | d| d| | |
- 第一个是成员a:从0开始,占1个字节。
- 第二个是成员b:它要对齐到8字节,但是接下来的地址1%8并不等于零,所以b要往后移动。
- 第三个是成员c:它要对齐到4字节,接下来的地址是16,16%4=0,所以存到索引16处就行。
- 最后是成员d:它要对齐到2字节,接下来的地址是20,20%2=0,所以直接存到索引20处。
所有成员都放好还不算完,别忘了内存对齐的第二个要求,结构体整体占用字节数需要是类型对齐边界的倍数,不够的话要往后扩张一下,所以它要扩充到相对地址23这里。最终这个结构体类型的大小就是24字节。
至于为什么要限制类型大小等于对齐边界的整数倍,可以这样理解:如果不扩充到类型对齐边界的整数倍,这个结构体类型的大小就是22字节,如果要有个数组含有两个这个结构体,这个数组就会占用44字节的内存。问题出现了,第二个元素并没有内存对齐,所以只有每个结构体的大小都是对齐值的整数倍,才能保证数组中每个元素都是内存对齐的。
需要注意的是:有些结构体可以通过调节字段顺序,优化内存对齐边界,从而能够节约占用的空间。
unsafe.Alignof函数可获取内存对齐值,unsafe包中函数的调用都是在编译时刻估值的。
如果是在运行时刻一个T类型的t值,可以调用reflect.TypeOf(t).Align()获取T的对齐值,reflect.TypeOf(t).FieldAlign()获取T的字段对齐值。对于当前的Golang标准编译器reflect.TypeOf(t).Align()和reflect.TypeOf(t).FieldAlign()是相同的。