大佬文章置顶

1.5 在 Go 中恰到好处的内存对齐

煎鱼大佬的内存对齐文章,把学习和理解过程梳理成下文。

为什么要内存对齐

cpu从内存中读取数据的时候,并不是按单字节的方式来一个一个读取的,而是每次按照内存访问粒度进行按块读取。每一块,也就是内存访问粒度的大小可以是2/4/6/8/16等字节。

那假如数据没有进行内存对齐,cpu在读取数据的过程中就会花费一定的时间和资源对读取到的数据进行取舍和整合。

内存读取,本质上就是用空间换时间的方式,来提高内存读取的效率

空间换时间,是一种当前时代很通用的思想,通过冗余的方式来提高可用性和效率,比如高可用的架构设计,比如sync.Map, 等等等等…..

对齐相关概念

0. 大小,偏移量,对齐值

用一个实际的例子来说明这几个概念,假如现在我们有一个这样的结构体:

1
2
3
4
type demo struct{
a int8
b int16
}

大小就是当前结构体占用的内存大小,可以通过unsafe.Sizeof(demo{})获取,值为 4.

结构体和其成员变量都有自己的对齐值,编辑器也有自己默认的对齐值。可以通过unsafe.Alignof(demo{}.a)获取成员a的对齐值,为1。

偏移量是针对结构体中的成员来说的,第一个成员变量的偏移量永远为 0,可以通过unsafe.Offsetof(demo{}.b)获取成员b的偏移量,值为 2。

从类型上来说,int8占用 1 个字节,int16占用 2 个字节,

但是demo最终占用了4个字节,而不是 1+2=3,这就是因为做了内存补齐,

尽管成员a本身是int8类型,但是最终占用的内存补了1个字节,变成了2个。

结构体demo 最终占用的内存大小是由 成员a 和 b 实际占用的内存大小之和决定,

而a 和 b实际占用的内存是由结构体demo 成员a 成员b三者的对齐值以及b的偏移量最终决定的。

1. 对齐规则
  • 结构体的成员变量,第一个成员变量的偏移量为 0。往后的每个成员变量的对齐值必须为编译器默认对齐长度(#pragma pack(n))或当前成员变量类型的长度(unsafe.Sizeof),取最小值作为当前类型的对齐值。其偏移量必须为对齐值的整数倍
  • 结构体本身,对齐值必须为编译器默认对齐长度(#pragma pack(n))或结构体的所有成员变量类型中的最大长度,取最大数的最小整数倍作为对齐值

从上面两个规则可以看出,定义一个结构体最终占用内存大小和三个因素有关。

  1. 编译器默认对齐长度
  2. 结构体成员变量类型
  3. 结构体成员变量最大长度

**注意第三点,如果成员是slice/array/struct/map, 则最大长度指的不是总长度,而是成员的下级成员 **

1.1 什么是编译器默认对齐长度?

这个和操作系统的位数有关,32位的操作系统就是4个字节,64位的操作系统就是8个字节。

1.2 成员变量类型和最大长度
类型长度(字节)/ unsafe.SizeOf()
int81
int162
int324
int648
int64位系统8个字节,32位系统4个字节
bool1
slice64位系统24个字节,32位系统16个字节
array和具体的数据类型及长度有关,比如 [1]int8是1个字节,[2]int8 是两个字节
byte底层是int8, 1个字节
rune底层是int32, 4个字节

实例

假如我们现在有这样一个结构体 demo

1
2
3
4
5
6
7
type demo struct {
a bool
b int32
c int16
d int64
e int16
}

那么demo最终占用多少个字节?(64位操作系统)

对齐分为两个部分,先完成 各个成员变量的对齐,再完成结构体的对齐

64位操作系统下 默认对齐参数为 8 。

填充字节我们用 x 表示

① a

a 是首个变量,偏移量为0, 类型为bool,长度为1。对齐值为1。

1
2
3
4
5
fmt.Println("大小: ", unsafe.Sizeof(demo{}.a), "\n偏移量: ", unsafe.Offsetof(demo{}.a), "\对齐值: ",unsafe.Alignof(demo{}.a))
// 输出:
大小: 1
偏移量: 0
对齐值: 1

此时我们得到:

1
2
a
0
② b

b类型为int32, 长度为4, 对齐值为4, 偏移量4,

1
2
3
4
5
fmt.Println("大小: ", unsafe.Sizeof(demo{}.b), "\n偏移量: ", unsafe.Offsetof(demo{}.b), "\对齐值: ",unsafe.Alignof(demo{}.b))
// 输出
大小: 4
偏移量: 4
对齐值: 4

因为上一个成员变量偏移量为0,长度为1,所以需要在 a b 之间填充 3个字节。

此时我们得到:

1
2
a x x x b b b b
0 1 2 3 4 5 6 7
③ c

c 类型为int16, 长度为2, 对齐值2, 偏移量为必须为对齐值的整数倍,所以大于7且是2的整数倍的最小值为8。

1
2
3
4
5
fmt.Println("大小: ", unsafe.Sizeof(demo{}.c), "\n偏移量: ", unsafe.Offsetof(demo{}.c), "\n对齐参数: ",unsafe.Alignof(demo{}.c))
// 输出
大小: 2
偏移量: 8
对齐参数: 2

此时我们得到:

1
2
a x x x b b b b c c
0 1 2 3 4 5 6 7 8 9
④ d

d 类型为int64, 长度为8, 对齐值为8, 则偏移量为大于9且是8的整数倍,满足条件的最小值为16,

需要在c 和d 之间进行填充

1
2
3
4
5
fmt.Println("大小: ", unsafe.Sizeof(demo{}.d), "\n偏移量: ", unsafe.Offsetof(demo{}.d), "\n对齐参数: ",unsafe.Alignof(demo{}.d))
// 输出
大小: 8
偏移量: 16
对齐参数: 8

此时我们得到:

1
2
a x x x b b b b c c x  x  x  x  x  x  d  d  d  d  d  d  d  d 
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
⑤ e

e 类型为 int16, 长度为2, 对齐参数为2, 则偏移量为大于23且是2的整数倍,满足条件的最小值为24。

1
2
3
4
5
fmt.Println("大小: ", unsafe.Sizeof(demo{}.e), "\n偏移量: ", unsafe.Offsetof(demo{}.e), "\n对齐参数: ",unsafe.Alignof(demo{}.e)
// 输出
大小: 2
偏移量: 24
对齐参数: 2

此时我们得到:

1
2
a x x x b b b b c c x  x  x  x  x  x  d  d  d  d  d  d  d  d  e  e
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25

至此,我们对所有的成员变量进行了对齐操作,接下来是结构体demo的对齐。

⑥ demo

demo结构体中最大成员变量长度为 int64类型的d, 即为 8。 系统默认对齐参数为8, 根据原则

对齐值必须为编译器默认对齐长度(#pragma pack(n))或结构体的所有成员变量类型中的最大长度,取最大数的最小整数倍作为对齐值

我们得到对齐值为 8。

对于结构体来说,此时不再涉及偏移量,只涉及填充和确定最终内存大小

demo的最终大小必须为大于26(已对齐成员变量长度之和)且是8的倍数,满足条件的最小值是32。

所以需要对 e 后面进行填充

1
2
3
4
fmt.Println("大小: ", unsafe.Sizeof(demo{}),  "\n对齐参数: ",unsafe.Alignof(demo{}))
// 输出
大小: 32
对齐参数: 8

此时我们得到:

1
2
a x x x b b b b c c x  x  x  x  x  x  d  d  d  d  d  d  d  d  e  e  x  x  x  x  x  x  
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31

至此,结束。