1. 介绍
CPU把内存当成是一块一块的,块的大小可以是2,4,8,16字节大小,因此CPU在读取内存时是一块一块进行读取的。块大小成为memory access granularity(粒度)
。
为保证程序顺利高效的运行,编译器会把各种类型的数据安排到合适的地址,并占用合适的长度,这就是内存对齐
示例:
假设cpu的memory access granularity =8 byte, 内存不对齐的情况下
,cpu 要从抵制1开始读8字节的数据,如下图所示:
此时cpu 需要读取两次,
- 第一次读取0-7 地址段
- 第二次读取8-15 地址段
- 两个地址段拼接,读取1-8地址的数据
相反的,如果在内存对齐的情况下, 1- 8 地址段的数据,将会直接存储在8-15 地址段,cpu 就可以一次获取到对应的字段。 但是之前的1-7地址段的内存就会被浪费了
总结来说,内存对齐的原因有如下两点:
- 平台(移植性)原因:不是所有的硬件平台都能够访问任意地址上的任意数据。例如:特定的硬件平台只允许在特定地址获取特定类型的数据,否则会导致异常情况
- 性能原因:若访问未对齐的内存,将会导致 CPU 进行两次内存访问,并且要花费额外的时钟周期来处理对齐及运算。而本身就对齐的内存仅需要一次访问就可以完成读取动作
2. 对齐边界/ 对齐系数
在不同平台上的编译器都有自己默认的 对齐系数(对齐边界)
,一般来讲,我们常用的平台的系数如下:
32 位系统:4byte(可以理解为cpu的memory access granularity=4)
64 位系统:8byte (可以理解为cpu的memory access granularity=8)
在go 语言中,每个数据类型,也有自己对应的对齐系数,我们可以通过官方的unsafe
包中的unsafe.Alignof
函数获取不同数据类型的对齐边界
func main() {
fmt.Printf("bool align: %d\n", unsafe.Alignof(bool(true)))
fmt.Printf("int32 align: %d\n", unsafe.Alignof(int32(0)))
fmt.Printf("int8 align: %d\n", unsafe.Alignof(int8(0)))
fmt.Printf("int64 align: %d\n", unsafe.Alignof(int64(0)))
fmt.Printf("byte align: %d\n", unsafe.Alignof(byte(0)))
fmt.Printf("string align: %d\n", unsafe.Alignof("EDDYCJY"))
}
- 对于数组类型 []elementType,
对齐系数 = unsafe.Alignof(elementType)
- 对于
slice , map,指针类型
, 对齐系数 = 8(64位操作系统) -
对于结构体, 首先要确定每个成员的对齐边界,然后取其中最大的,这就是这个结构体的对齐边界
注意:
所有类型的对齐系数不会超过编译器设定的对齐系数, 例如在64 位操作系统中的默认对齐系数是8 ,所有的类型对齐系数不会超过8
3. 对齐规则
- 存储这个结构体的起始地址,是对齐边界的倍数。假设从0开始存,结构体的每个成员在存储时,都要把这个起始地址当作地址0,然后再用相对地址来决定自己该放在哪里。
- 结构体整体占用字节数需要是类型对齐边界的倍数,不够的话要往后扩张一下
-
示例:
结构体T1 的两个成员的bool 和i6的大小分别占1byte 和2byte, 对应的对齐系数分别是1byte 和2byte, 此时结构体T1的对齐系数是Max{1,2}=2byte
type T1 struct {
bool bool
i16 int16
}
func main() {
fmt.Printf("bool size:%v, bool align:%v\n", unsafe.Sizeof(bool(true)), unsafe.Alignof(bool(true)))
fmt.Printf("int16 size:%v, int16 align:%v\n", unsafe.Sizeof(int16(0)), unsafe.Alignof(int16(0)))
fmt.Printf("T1 size:%v, T1 align:%v\n", unsafe.Sizeof(T1{}), unsafe.Alignof(T1{}))
return
}
在内存未对齐的状态下 T1 的大小为3字节, 但是通过内存对齐后,内存占4 字节,如图1 所示:
i16并没有直接放在bool的后面,而是在bool中填充了一个空白后,放到了偏移量为2的位置上。如果i16从偏移量为1的位置开始占用2个字节,根据对齐原则1:构体变量中成员的偏移量必须是成员大小的整数倍,套用公式 1 % 2 = 1,就不满足对齐的要求,所以i16从偏移量为2的位置开始
以 结构体T2,T3 为例
type T2 struct {
i8 int8
i64 int64
i32 int32
}
type T3 struct {
i8 int8
i32 int32
i64 int64
}
i8,i32,i64 的大小分别是1字节,4字节,8字节,对齐系数1字节,4 字节,8字节,所以T2和T3 的对齐系数也都是8字节, 通过unsafe.Sizeof 函数计算得到T2 和T3类型的数据大小为24字节和16字节
func main() {
t2 := T2{}
fmt.Println(unsafe.Sizeof(t2)) // 24 bytes
t3 := T3{}
fmt.Println(unsafe.Sizeof(t3)) // 16 bytes
// 通过offset 函数来确认成员的偏移量
fmt.Printf("t2 i8 offset:%v,i64 offset:%v,i32 offset:%v\n", unsafe.Offsetof(t2.i8), unsafe.Offsetof(t2.i64), unsafe.Offsetof(t2.i32))// 0,8,16
fmt.Printf("t3 i8 offset:%v,i32 offset:%v,i64 offset:%v\n", unsafe.Offsetof(t3.i8), unsafe.Offsetof(t3.i32), unsafe.Offsetof(t3.i64))//0,4,8
}
具体解析如下图所示:
以T2结构体为例,实际存储数据的只有13字节,但实际用了24字节,浪费了11个字节
以T3结构体为例,实际存储数据的只有13字节,但实际用了16字节,浪费了3个字节:
可以看到合理的结构体内的成员的排布可以减少对内存的消耗, 提高内存的使用效率
- 结构体嵌套
type T4 struct {
i32 int32
b bool
t T1
}
type T1 struct {
i16 int16
bool bool
}
结构体T4 嵌套T1,前面已经计算过T1类型的对齐边界为2字节, 字节长度为4字节。 通过unsafe.Sizeof和Alignof函数可得T4 类型的字节长度为12字节和对齐系数为4字节
- 首先来分析下T4类型的对齐系数:
int32 的对齐系数为4字节,bool的对齐系数为1字节,T1的对齐系数为2字节
对齐系数= max{4,1,2}=4
-
其次分析T4 类型是如何实现内存对齐的:
T4 内存对齐后的分布如下图所示:
从起始地址开始前四位地址为数据i32,接下来b的对齐系数为1,所以直接接在后面,即在5号位置,接下来就是t类型数据,因为对齐系数为2, 此时在6号位置6%2==0 成立,此时t的位置就在6号位放置,6-7 号为t.i32, 8号为t.bool, 9号为t类型的pad , 因为T4的对齐系数是4, 此时T4 的总长度为9, 9%4!=0, 需要在10-11 补充pad 。最终12%4==0, 所以结构4 所占的字节长度为12