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 的预取器也足以掩盖指针跳转的延迟。
这是一个必须如实报告的负结果。它提醒两件事:
- 「连续一定更快」是有条件的。当数据能装进缓存时,连续布局的收益明显;一旦超出缓存容量,两者都被内存带宽压平。
- 不要用这条理由做架构决策。真正稳定的理由是
[]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 位域 | 省字节但增加代码复杂度 | 谨慎:先确认这块内存是瓶颈 |
三条验收纪律:
- 省内存用
HeapAlloc/B/op验收,别用unsafe.Sizeof。Sizeof是编译期值,不含 size class 圆整;真正的账单看运行时。 - 性能用区间验收,不用单点。本节的
[]struct/[]*struct对比就是教训——单点跑一次很可能得出「A 快 10%」的假结论,跑 5 次才发现区间重叠。 - 归因先对齐分母。累计型指标(扫描字节、GC CPU)要按轮数或时间摊平;否则你测到的是「跑了几轮 GC」,不是「每轮多贵」。
最后回到收益量级:本节三个实验里,唯一确定且可复现的是「字段重排省 8 MB / 100 万对象」。另外两个(遍历速度、扫描面)要么测不出、要么需要更严格的对照。这不是说它们不重要,而是说布局优化里真正「稳赚」的部分很窄——把热结构体按对齐值重排、压到 size class 边界,这两件事几乎没有副作用;其余「可能更快」的改动,都得先有数字再动手。
换句话说:布局优化是「低成本、可预期、上限有限」的一类改动。它适合作为性能工作的第一刀,但不该指望它解决架构层面的瓶颈。
第 6 章开始进入 GC:布局决定了「GC 要扫多少」,而 6.1 要讲的是「GC 怎么知道该扫谁」——写屏障。
阅读导航:上一节:5.2 结构体对齐、padding 与缓存行 · 下一节:6.1 三色标记与写屏障 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。