Go 字节对齐

什么是字节对齐

现代计算机中,内存空间按照字节划分,理论上可以从任何起始地址访问任意类型的变量。但实际中在访问特定类型变量时经常在特定的内存地址访问,这就需要各种类型数据按照一定的规则在空间上排列,而不是顺序一个接一个地存放,这就是对齐。

为什么要字节对齐

  • 某些特定的平台只能从特定的地址存取,而不允许任意存放
  • 常见的情况是,如果不按照平台的要求对齐,会降低 CPU 访问数据的频率。例如 32 位的字长的机器,如果数据没有存在 4 字节的整除的内存地址处,那么 CPU 就要分两个周期访问。而且合理的字节对齐,还可以有效节省空间

结构体对齐

其实字节对齐的问题,大部分都是针对复合类型的结构体,结构体的成员变量的位置有时候会影响存储空间。

对齐准则

  • 结构体变量的首地址能够被其最宽基本类型成员的大小所整除
  • 结构体每个成员相对结构体首地址的偏移量offset都是成员大小的整数倍,如有需要编译器会在成员之间加上填充字节 internal adding
  • 结构体的总大小为结构体最宽基本类型成员大小的整数倍,如有需要编译器会在最末一个成员之后加上填充字节 trailing padding

假设有下面的结构体

1
2
3
4
5
type test struct {
a uint8
b uint64
c uint16
}

通常认为这个结构体会占 1 + 8 + 2 = 11 个字节,但是并不是的

根据准则来分析下该结构体的所占字节。这个结构体最宽的类型成员是 uint64。那么存放的地址必须是 地址 % 8 = 0, 这是编译器的行为,是不需要开发者管的,知道是这么回事就行了。

假设地址从 0x00 开始, uint8 占一个 1 字节, 那么下个成员就是从 0x01 开始,下一个成员占了 8 个字节,根据第二条准则成员相对首地址都是成员大小整数倍。很显然 uint64 这个成员从 0x08 开始填充,uint8uint64 之间的空间编译器会自动填充字节。
uint64 填充之后地址来到 0x16。uint16 占了两个字节,正好可以整除。那么 uint16 就会紧接 uint16 填充。注意,这里没有结束。最后一条准则结构体总大小必须是最大成员宽度的整数倍。所以 uint16 成员之后也会被填充字节。

那么这个结构的大小就是 8 + 8 + 8 = 24。并不是 11

来看一下实际地址

1
2
3
4
5
6
7
8
func main() {
var test test

fmt.Println(unsafe.Sizeof(test)) // 24 个字节

fmt.Printf("a: %d b: %d c: %d", unsafe.Offsetof(test.a), unsafe.Offsetof(test.b), unsafe.Offsetof(test.c))
// a: 0 b: 8 c: 16
}

go 也提供了 unsafe 包来查看结构体的成员内存布局。可以利用 unsafe.Offsetof 知道结构体的成员的相对位置的偏移量。成员 b 和 a 之间相差一个字长(这里用字长表示准确点,因为 32 位和 64 位的话结果可能就会不同了)。c 和 b 之间也是相差一个字长。

对于字节对齐而言,其实都是编译器的行为,开发者无法改变,但是可以通过调整成员变量来减少内存存储空间。对于上面的结构体而言,可以稍作调整。

1
2
3
4
5
type test struct {
a uint8
c uint16
b uint64
}

运行下

1
2
3
4
5
6
7
8
func main() {
var test test

fmt.Println(unsafe.Sizeof(test)) // 16 个字节

fmt.Printf("a: %d c: %d b: %d", unsafe.Offsetof(test.a), unsafe.Offsetof(test.b), unsafe.Offsetof(test.c))
// a: 0 c: 2 b: 8
}

调整之后,整个结构体的大小会发生改变,减少了八个字节。其实通过上面的三大准则可以推测出,这个结构实际就是 16 字节。