《Go 语言运行时原理》5.3 内存布局优化实测

把 5.2 的对齐规则落到一个真实结构体上:100 万个实例的 HeapAlloc 从 48.0 B/对象降到 40.0 B/对象(省 8 MB),并诚实报告「[]struct 遍历 vs []*struct 遍历」在 M1 Pro 上没有可辨识差异,附 GC 扫描面(/gc/scan/heap)的实测数字与为什么它不能被简单归因。

5.3 内存布局优化实测

5.2 讲了规则,本节讲收益。一个结构体的字段重排,在单对象层面只值几个字节;但在「100 万个实例」的规模上,它决定的是几十 MB 的常驻内存、GC 每次要扫多少对象、以及遍历时 CPU 缓存够不够用。

本节把布局优化拆成三个可独立验证的问题:字段顺序省多少内存(可精确测量)、[]struct 和 []*struct 谁更快(结论可能反直觉)、以及指针字段如何影响 GC 的扫描面(要小心归因)。

本节要回答:内存布局优化到底能带来多少可量化的收益、哪些「优化」其实没有收益?结论是:字段重排让 100 万实例的堆占用从 48.0 B/对象降到 40.0 B/对象(省 8 MB),这是可稳定复现的收益;而「[]struct 遍历一定快过 []*struct」在本机 M1 Pro 上测不出可辨识差异(两组区间高度重叠);指针字段确实会扩大 GC 扫描面,但用 /gc/scan/heap:bytes 归因时必须先对齐 GC 轮数。 本节只写增量:不重复 5.2 的对齐规则,也不重复 4.1 的 size class 表,只给端到端的实测数字与归因陷阱。

5.3.1 实验一:字段重排省下的真实字节数

复现基线:

  • Go 工具链 go version go1.27.0 darwin/arm64(GOTOOLCHAIN=go1.27.0)
  • 机器:Apple M1 Pro,10 核,32 GiB(sysctl -n hw.ncpu = 10);缓存行 128 字节
  • 未开启 -race;GOGC 默认,GOMAXPROCS 默认(10)
  • 每个变体分配 100 万个对象,用 runtime.MemStats 的 HeapAlloc 与 HeapObjects 前后差值测量;每个数字跑 3 遍,波动在 1% 以内

测量脚手架本身很短,关键是在测量前后各读一次 MemStats,并让 runtime.KeepAlive 阻止结果被优化掉:

func heapDelta(f func(int) any, n int) (uint64, uint64) {
	runtime.GC()
	var before, after runtime.MemStats
	runtime.ReadMemStats(&before)
	x := f(n)
	runtime.ReadMemStats(&after)
	runtime.KeepAlive(x)
	return after.HeapAlloc - before.HeapAlloc, after.HeapObjects - before.HeapObjects
}

沿用 5.2 的两个结构体(Bad 32 字节、Good 24 字节),每个对象额外带一个 *int 字段指向的 8 字节整数:

func allocBad(n int) []*Bad {
	s := make([]*Bad, n)
	for i := range s {
		s[i] = &Bad{B: int64(i), D: int32(i), E: new(int)}
	}
	return s
}

实测输出:

$ GOTOOLCHAIN=go1.27.0 go run .
类型     字节/对象    HeapAlloc增量B   HeapObjects增量
Bad    32       48030240       1500034
Good   24       40009088       1500012
Bad 每对象实测 48.0 B, Good 每对象实测 40.0 B

逐项拆解这 48.0 与 40.0:

  • Bad 每个对象:结构体 32 字节(class 32)+ new(int) 8 字节(class 8)+ 切片里的指针槽 8 字节 = 48 字节。
  • Good 每个对象:结构体 24 字节(class 24)+ 8 + 8 = 40 字节。

结构体本身省的 8 字节,一比一变成了堆占用省的 8 字节——因为 32 与 24 恰好都落在 size class 档位上,没有二次浪费(这正是 5.2 决策表里强调的「跨过档位边界」)。100 万个对象,省 8 MB。

HeapObjects 增量也值得看一眼:两者都约 150 万(100 万结构体 + 50 万个 tiny 块——100 万个 8 字节 int 被 tiny 分配器两两合并进 16 字节块),对象数几乎一样——说明这次优化省的是「每对象字节数」,不是「对象个数」。这个区分很重要:GC 的开销同时取决于对象数与每对象大小,省字节对 GC 的压力改善小于省对象数。

5.3.2 实验二:[]struct 遍历 vs []*struct 遍历

一个流传很广的说法是「[]struct 遍历一定快,因为内存连续;[]*struct 要跳指针」。本机实测这个说法不成立。被测结构体 64 字节,Tail 字段落在偏移 56:

type Row struct {
	Key  int64    // 热字段
	Val  float64  // 热字段
	Pad  [40]byte // 冷字段(模拟大对象)
	Tail float64  // 需要访问的尾部字段
}

const N = 200_000

var (
	structs = func() []Row { /* 每个 Row 64 字节,含一个 Tail 字段 */ }()
	ptrs    = func() []*Row { /* 每个 *Row 指向独立分配的 Row */ }()
)

访问两个切片里每个元素的 Tail 字段(偏移 56),-benchtime=300x -count=5 -cpu=1:

$ GOTOOLCHAIN=go1.27.0 go test -run '^$' -bench=. -benchtime=300x -count=5 -cpu=1 -benchmem
cpu: Apple M1 Pro
BenchmarkStructs 	     300	    317772 ns/op	       0 B/op	       0 allocs/op
BenchmarkStructs 	     300	    291800 ns/op	       0 B/op	       0 allocs/op
BenchmarkStructs 	     300	    259996 ns/op	       0 B/op	       0 allocs/op
BenchmarkStructs 	     300	    263750 ns/op	       0 B/op	       0 allocs/op
BenchmarkStructs 	     300	    270613 ns/op	       0 B/op	       0 allocs/op
BenchmarkPtrs    	     300	    296587 ns/op	       0 B/op	       0 allocs/op
BenchmarkPtrs    	     300	    287367 ns/op	       0 B/op	       0 allocs/op
BenchmarkPtrs    	     300	    270064 ns/op	       0 B/op	       0 allocs/op
BenchmarkPtrs    	     300	    258956 ns/op	       0 B/op	       0 allocs/op
BenchmarkPtrs    	     300	    284185 ns/op	       0 B/op	       0 allocs/op

区间对比:

布局耗时区间差异
[]Row(连续)260–318 µs基线
[]*Row(跳指针)259–297 µs不可辨识

结论:在这台机器、这个规模、这个访问模式下,两者没有可辨识的差异。 原因很可能是:200000 × 64 字节 = 12.8 MB,已经远超 L2(4 MB),两种布局都受 DRAM 带宽限制;M1 Pro 的预取器也足以掩盖指针跳转的延迟。

这是一个必须如实报告的负结果。它提醒两件事:

  1. 「连续一定更快」是有条件的。当数据能装进缓存时,连续布局的收益明显;一旦超出缓存容量,两者都被内存带宽压平。
  2. 不要用这条理由做架构决策。真正稳定的理由是 []struct 少一次间接寻址、少一批独立分配(对象数更少、GC 扫描面更小),而不是「遍历一定更快」。

5.3.3 实验三:指针字段与 GC 扫描面

含指针的结构体会让 GC 需要扫描它。用 runtime/metrics 的 /gc/scan/heap:bytes 观察:

type withPtr struct {
	A int64
	P *withPtr
	B int64
}

type noPtr struct {
	A int64
	B int64
	C int64
}

两者都是 24 字节,各分配 200 万个;withPtr 用自引用(p.P = p)避免额外分配,保证唯一变量是「有没有指针字段」。实测输出:

$ GOTOOLCHAIN=go1.27.0 go run .
withPtr size=24 noPtr size=24
含指针字段      扫描堆字节=     44.0 MB  GC轮数=2
无指针字段      扫描堆字节=     16.0 MB  GC轮数=1

这个结果不能直接下结论,因为两次的 GC 轮数不同(2 vs 1)——/gc/scan/heap:bytes 是累计值,2 轮扫描自然会比 1 轮多。把它按轮数摊开:含指针约 22 MB/轮,无指针约 16 MB/轮,方向符合预期(指针版扫描面更大),但差异幅度受 GC 触发时机干扰,不足以给出精确倍数。

这正是本节要强调的归因纪律:看累计型指标前,先对齐分母。想严格比较扫描成本,应固定 GC 轮数(例如用 GOGC=off + 手动 runtime.GC()),再比 /gc/scan/heap:bytes 的单轮增量。

读指标用的辅助函数也只有几行:

func read(name string) float64 {
	s := []metrics.Sample{{Name: name}}
	metrics.Read(s)
	if s[0].Value.Kind() == metrics.KindUint64 {
		return float64(s[0].Value.Uint64())
	}
	return s[0].Value.Float64()
}

runtime/metrics 的接口约定值得记住:每个指标有一个固定的 Kind(KindUint64、KindFloat64、KindFloat64Histogram)。同一个 Sample 切片可以一次读多个指标,但取值前必须判 Kind——把直方图当 Float64 取会拿到无意义的值。6.2 会用同一套接口读 STW 暂停的直方图。

5.3.4 源码:字节数在哪圆整、扫描面在哪决定

三个实验背后的运行时机制,都能定位到具体函数。

第一,「24 字节」与「32 字节」在分配时如何变成 size class。src/runtime/malloc.go:mallocgcSmallNoscan 用两张查表完成:

func mallocgcSmallNoscan(size uintptr, typ *_type, needzero bool) (unsafe.Pointer, uintptr) {
	...
	var sizeclass uint8
	if size <= gc.SmallSizeMax-8 {
		sizeclass = gc.SizeToSizeClass8[divRoundUp(size, gc.SmallSizeDiv)]
	} else {
		sizeclass = gc.SizeToSizeClass128[divRoundUp(size-gc.SmallSizeMax, gc.LargeSizeDiv)]
	}
	size = uintptr(gc.SizeClassToSize[sizeclass])
	spc := makeSpanClass(sizeclass, true)
	span := c.alloc[spc]
	...
}

这里有两个细节直接对应 5.3.1 的实测:

  • size = uintptr(gc.SizeClassToSize[sizeclass])——请求尺寸在这一行被改写成 size class 尺寸。所以 unsafe.Sizeof 报 24,实际占用就是 class 24 的 24 字节;而如果结构体是 25 字节,这一行会把它抬到 32,5.3.1 省下的 8 字节就吐回去一半。
  • spc := makeSpanClass(sizeclass, true)——第二个参数 true 是 noscan。所有不含指针的对象都被标记为 noscan,GC 会整块跳过它们。这就是 5.3.3 里「无指针字段」扫描面更小的机制:不是 GC 扫得「更聪明」,而是这些 span 根本没被排进扫描队列。

第二,「含不含指针」是怎么判定的。编译期为每个类型生成一个位图;运行时的 src/runtime/mbitmap.go:heapBitsInSpan 决定这个位图放在哪里:

func heapBitsInSpan(userSize uintptr) bool {
	// N.B. gc.MinSizeForMallocHeader is an exclusive minimum so that this function is
	// invariant under size-class rounding on its input.
	return userSize <= gc.MinSizeForMallocHeader
}

gc.MinSizeForMallocHeader = goarch.PtrSize * goarch.PtrBits(src/internal/runtime/gc/malloc.go),64 位上是 64。小对象(≤ 64 字节)的指针位图被内联进 span 自身,大对象才单独存放——这也是 Green Tea GC(6.3)能对「小对象密集的堆」做批量扫描的前提。

第三,为什么 []T 比 []*T 少扫描。[]T 里每个 T 若不含指针,整块 span 是 noscan;[]*T 里每个槽位都是指针,GC 必须逐个跟进去。差别不在遍历速度(5.3.2 测不出),而在 GC 每轮要处理的指针数量。想量化它,就要像 5.3.3 说的那样固定 GC 轮数再看 /gc/scan/heap:bytes。

5.3.5 决策:布局优化该做什么、不该做什么

优化动作本机实测收益该不该做
字段按对齐值从大到小重排确定:48.0 → 40.0 B/对象(100 万省 8 MB)该做,尤其是大数组元素类型
把 []*T 改成 []T 图遍历更快未测出:区间重叠别用「更快」当理由;可用「少分配、少扫描」当理由
去掉热结构体里的指针字段方向对(扫描面变小),幅度未精确测出该做,但要固定 GC 轮数再验收
用 [N]byte 字段「对齐到缓存行」无效:对齐上限 8(RoundUp)不该做;需要手工 pad(5.2)
把结构体压到 size class 边界确定:跨档即省整档该做:优先压到 32/48/64/96/128
为省内存把多个字段塞进一个 int64 位域省字节但增加代码复杂度谨慎:先确认这块内存是瓶颈

三条验收纪律:

  1. 省内存用 HeapAlloc/B/op 验收,别用 unsafe.Sizeof。Sizeof 是编译期值,不含 size class 圆整;真正的账单看运行时。
  2. 性能用区间验收,不用单点。本节的 []struct/[]*struct 对比就是教训——单点跑一次很可能得出「A 快 10%」的假结论,跑 5 次才发现区间重叠。
  3. 归因先对齐分母。累计型指标(扫描字节、GC CPU)要按轮数或时间摊平;否则你测到的是「跑了几轮 GC」,不是「每轮多贵」。

最后回到收益量级:本节三个实验里,唯一确定且可复现的是「字段重排省 8 MB / 100 万对象」。另外两个(遍历速度、扫描面)要么测不出、要么需要更严格的对照。这不是说它们不重要,而是说布局优化里真正「稳赚」的部分很窄——把热结构体按对齐值重排、压到 size class 边界,这两件事几乎没有副作用;其余「可能更快」的改动,都得先有数字再动手。

换句话说:布局优化是「低成本、可预期、上限有限」的一类改动。它适合作为性能工作的第一刀,但不该指望它解决架构层面的瓶颈。

第 6 章开始进入 GC:布局决定了「GC 要扫多少」,而 6.1 要讲的是「GC 怎么知道该扫谁」——写屏障。

阅读导航:上一节:5.2 结构体对齐、padding 与缓存行 · 下一节:6.1 三色标记与写屏障 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练