bigywater.tech
Go · 24

结构体对齐与填充:调整字段顺序能省多少内存

结构体里字段的顺序会影响大小:每个字段要放在自己对齐的地址上,整个结构体的大小还要补齐到最大对齐。这篇用 reflect 打印每个字段的偏移与填充,对比两种字段顺序,量出差别,再用 fieldalignment 工具自动发现这类问题。

32 → 16
同样 5 个字段,按 bool、int64、bool、int32、bool 排列占 32 字节,重排后占 16 字节
50.0%
100 万个元素的切片省下的内存
0 差别
顺序读 d 字段求和的耗时(603978 ns vs 610045 ns)

偏移与填充

实测 · 本机执行输出
  Bad:Sizeof = 32,Alignof = 8
    a  bool   offset  0 size 1 align 1
    b  int64  offset  8 size 8 align 8 (前面填充 7 字节)
    c  bool   offset 16 size 1 align 1
    d  int32  offset 20 size 4 align 4 (前面填充 3 字节)
    e  bool   offset 24 size 1 align 1
    (末尾填充 7 字节)
  Good:Sizeof = 16,Alignof = 8
    b  int64  offset  0 size 8 align 8
    d  int32  offset  8 size 4 align 4
    a  bool   offset 12 size 1 align 1
    c  bool   offset 13 size 1 align 1
    e  bool   offset 14 size 1 align 1
    (末尾填充 1 字节)

规则是两条:每个字段的偏移必须是它对齐值的整数倍;结构体的大小必须是它最大对齐值的整数倍。Bad 里 int64 前面的 7 字节、int32 前面的 3 字节、末尾 7 字节都是填充,合计 17 字节。把大对齐的字段放前面,小字段挤在一起,填充几乎消失。

常见类型的对齐

实测 · 本机执行输出
  bool         Sizeof  1  Alignof 1
  int16        Sizeof  2  Alignof 2
  int32        Sizeof  4  Alignof 4
  int64        Sizeof  8  Alignof 8
  float32      Sizeof  4  Alignof 4
  complex128   Sizeof 16  Alignof 8
  string       Sizeof 16  Alignof 8
  []int        Sizeof 24  Alignof 8
  any          Sizeof 16  Alignof 8
  map[int]int  Sizeof  8  Alignof 8
  [3]byte      Sizeof  3  Alignof 1
  struct{}     Sizeof  0  Alignof 1

对齐值就是类型里最大成员的对齐:string、切片、接口、map 都是 8 字节对齐(它们内部是指针与整数);complex128 是 16 字节但只要 8 字节对齐;数组的对齐等于元素的对齐;struct{} 大小是 0、对齐是 1。

末尾的零大小字段

实测 · 本机执行输出
  ZeroTail:Sizeof = 16,Alignof = 8
    a  int64  offset  0 size 8 align 8
    z  struct {} offset  8 size 0 align 1
    (末尾填充 8 字节)
  ZeroHead:Sizeof = 8,Alignof = 8
    z  struct {} offset  0 size 0 align 1
    a  int64  offset  0 size 8 align 8

空结构体字段放在结构体的最后会让整个结构体多出一个对齐单位(上面 8 → 16 字节),放在前面则没有代价。我没有读编译器里这条规则的源码(合理的解释是避免指向结构体末尾之后的指针,但这只是我的推测,没有验证)。

省内存,不一定更快

实测 · 本机执行输出
  []Bad  32 字节 × 1000000 = 30.5 MiB;[]Good 16 字节 × 1000000 = 15.3 MiB;省 50.0%
  遍历 []Bad 求和 d                        中位 603978.48 ns/op(最小 601124.72 / 最大 622435.33)
  遍历 []Good 求和 d                       中位 610044.56 ns/op(最小 589078.71 / 最大 613580.00)

切片元素大小减半,100 万个元素省了 15 MiB。但“只读一个字段、顺序遍历”的循环里耗时没有区别,所以这个例子里收益只是内存与 GC 扫描量;缓存压力更大的访问模式(随机访问、遍历很多字段)可能看到差别,本文没有测。

不同平台不同结果

实测 · 本机执行输出
  GOARCH=amd64 Bad  Sizeof = 32,Alignof = 8,各字段偏移 [0 8 16 20 24]
  GOARCH=amd64 Good Sizeof = 16,Alignof = 8,各字段偏移 [0 8 12 13 14]
  GOARCH=arm64 Bad  Sizeof = 32,Alignof = 8,各字段偏移 [0 8 16 20 24]
  GOARCH=arm64 Good Sizeof = 16,Alignof = 8,各字段偏移 [0 8 12 13 14]
  GOARCH=386   Bad  Sizeof = 24,Alignof = 4,各字段偏移 [0 4 12 16 20]
  GOARCH=386   Good Sizeof = 16,Alignof = 4,各字段偏移 [0 8 12 13 14]
  GOARCH=arm   Bad  Sizeof = 24,Alignof = 4,各字段偏移 [0 4 12 16 20]
  GOARCH=arm   Good Sizeof = 16,Alignof = 4,各字段偏移 [0 8 12 13 14]

386 与 arm 上 int64 只要 4 字节对齐,所以同一个结构体的大小与偏移不同。我用标准库 go/types 按 gc 编译器的规则计算,没有真的运行 32 位程序。要在 32 位平台上对 64 位字段做原子操作,还有额外的对齐要求(sync/atomic 文档里写明),本文没有验证。

用工具发现

实测 · 本机执行输出
  go vet -vettool=fieldalignment:rc=1
  p.go:3:10: Bad has size 32 but the optimal size is 16 leading to a waste of 16 bytes (50%)
  p.go:19:12: Mixed has size 56 (allocator size class 64) but the optimal size is 40 (allocator size class 48) leading to a waste of 16 bytes (25%)

fieldalignment 是 golang.org/x/tools 里的分析器,不在 go vet 默认的检查里,需要单独安装并作为 -vettool 运行。它不只比较字节数,还会提示分配器的 size class(56 字节的结构体分配时占 64 字节)。注意工具建议的字段顺序有时会让结构体更难读,是否值得要看这个类型有多少实例。

分步演示

结构体的对齐与填充
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Go 1.27.1(brew);用 go run / go build / go test 直接编译运行,需要第三方库时经代理从 proxy.golang.org 下载;计时用 time.Now / testing.B,数字每次运行会有小幅抖动。reflect 的偏移来自 reflect.StructField.Offset;不同 GOARCH 的数字来自 go/types 的 SizesFor("gc", arch),没有运行 386 或 arm 程序;fieldalignment 是 golang.org/x/tools v0.51.0 (经代理 go install),作为 go vet -vettool 运行;遍历基准测试 7 轮取中位数
    • 没有验证:386 / arm 上的真实内存布局、sync/atomic 对 64 位字段的对齐要求、随机访问或多字段访问下的缓存影响、编译器对末尾零大小字段规则的实现与原因、带 cgo 的结构体布局
    京ICP备2026067502号-1京公网安备11010502063487号