前言
在学习 Go 时会了解到 struct 本质上就是一组自定义的数据结构,而其中字段的「顺序」将直接影响它在内存中实际占用的大小,甚至影响程序的运行速度。
为什么需要内存填充?
理论上 CPU 可以从任何一个地址读取数据,但实际上为了提高硬件的访问效率,CPU 读取内存时通常并不是以单个 Byte 为单位读取,而是以字长(Word Size)为单位进行读取。通常来说:
- 在 32-bit 系统中,字长为 4 Bytes
- 在 64-bit 系统中,字长为 8 Bytes
假设在 64-bit 系统上,一个 int64(8 Bytes)的变量刚好跨越了两个字长,CPU 就必须执行两次读取操作,并将结果拼接起来才能得到完整的数值。这不仅会造成额外的运算,在某些硬件架构上甚至可能直接抛出异常。
char data[10]; // char 只有 1 byte,因此它可以放在任何位置int *p = (int *)&data[1]; // 假设 &data[1] 是 0x1001(奇数地址)int val = *p; // 在严格对齐的架构(如 ARM、SPARC)上有机会直接崩溃并触发 SIGBUS0x1000 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 编译器会为作为最后一个字段的空结构体进行填充。
检测内存对齐
go vet -fieldalignmentfieldalignment -fix ./...总结
了解内存对齐的概念除了「节省空间」之外,也有助于打造“更好的缓存局部性(Cache locality)”。
- 将相同或相近大小的字段定义在一起,通常按照字段大小由大到小(或由小到大)排序。
- 不要将空结构体放在最后一个字段。
- 内存对齐固然重要,但它只是开发中的其中一个方面,应避免过度工程化。
延伸阅读
- What does it mean by word size in computer? - Stack Overflow
- WHY IS THE STACK SO FAST? - Core Dumped