Go struct memory alignment and usage efficiency

Go struct 内存对齐如何提升内存利用效率

前言

在学习 Go 时会了解到 struct 本质上就是一组自定义的数据结构,而其中字段的「顺序」将直接影响它在内存中实际占用的大小,甚至影响程序的运行速度。

为什么需要内存填充?

理论上 CPU 可以从任何一个地址读取数据,但实际上为了提高硬件的访问效率,CPU 读取内存时通常并不是以单个 Byte 为单位读取,而是以字长(Word Size)为单位进行读取。通常来说:

  • 在 32-bit 系统中,字长为 4 Bytes
  • 在 64-bit 系统中,字长为 8 Bytes

假设在 64-bit 系统上,一个 int64(8 Bytes)的变量刚好跨越了两个字长,CPU 就必须执行两次读取操作,并将结果拼接起来才能得到完整的数值。这不仅会造成额外的运算,在某些硬件架构上甚至可能直接抛出异常。

main.c
char data[10]; // char 只有 1 byte,因此它可以放在任何位置
int *p = (int *)&data[1]; // 假设 &data[1] 是 0x1001(奇数地址)
int val = *p; // 在严格对齐的架构(如 ARM、SPARC)上有机会直接崩溃并触发 SIGBUS
Alignment Fault (Signal 7 - SIGBUS)
0x1000 0x1001 0x1002 0x1003 0x1004
[---------- int ----------]

为了避免这种情况,编译器会自动介入,确保数据的起始地址符合特定规则,这就是「内存对齐」。而为了达成对齐,编译器会在数据之间插入空白的 Byte,这个动作称为「内存填充」。

设计良好的内存布局

不好示例
type BadStruct struct {
A int8 // 1 Byte
B int64 // 8 Bytes
C int16 // 2 Bytes
}
func main() {
var bad BadStruct
fmt.Printf("BadStruct Size: %d Bytes\n", unsafe.Sizeof(bad)) // 24 Bytes
}

在不好的示例中,int64 必须从 8 的倍数地址开始,因此自动填充了 7 Byte 的内存空间。而 1 + 7 + 8 + 2 = 18 并不是 8 的倍数,因此还会在结尾再填充 6 Byte,最终大小为 24 Byte。

好的示例
type GoodStruct struct {
B int64 // 8 Bytes
C int16 // 2 Bytes
A int8 // 1 Byte
}
func main() {
var good GoodStruct
fmt.Printf("GoodStruct Size: %d Bytes\n", unsafe.Sizeof(good)) // 16 Bytes
}

在好的示例中,字段总大小只有 11 Bytes,但 struct 本身仍需满足最大对齐需求(此例为 int64 的 8 Bytes 对齐),因此结尾会再填充 5 Bytes,最终大小为 16 Bytes。

以上示例仅通过调整字段顺序,就节省了 24 - 16 = 8 Bytes(接近 33%)的内存空间。如果该 struct 会被频繁初始化,节省下来的内存空间也会相当可观。

空结构体与填充

type StructWithEmpty struct {
A int32
B struct{} // 放在最后
}

如果空结构体被放在另一个结构体的最后一个字段,它仍然会占用内存。为了避免产生越界指针,Go 编译器会为作为最后一个字段的空结构体进行填充。

检测内存对齐

Terminal window
go vet -fieldalignment
fieldalignment -fix ./...

总结

了解内存对齐的概念除了「节省空间」之外,也有助于打造“更好的缓存局部性(Cache locality)”。

  1. 将相同或相近大小的字段定义在一起,通常按照字段大小由大到小(或由小到大)排序。
  2. 不要将空结构体放在最后一个字段。
  3. 内存对齐固然重要,但它只是开发中的其中一个方面,应避免过度工程化。

延伸阅读