4.1 size class 与 mcache/mcentral/mheap
make([]byte, 33) 申请 33 字节,运行时会给你多少?答案不是 33,而是 48。这个多出来的 15 字节不是「浪费」,而是 Go 内存分配器用**固定尺寸档位(size class)**换取的分配速度:只要对象尺寸落在某个档位内,分配就退化成「从一个已经切好的 span 里摘一个空位」——一次指针运算,没有锁、没有搜索、没有元数据查找。
理解这套档位,能直接回答三个工程问题:为什么把结构体从 33 字节压到 32 字节会省掉整档内存;为什么大量 1 字节的小对象反而不占 1 字节;以及什么时候分配会绕过本地缓存、去抢全局锁。
本节要回答:一次小对象分配到底走了哪几级结构、实际占用多少字节?结论是:小对象按 size class 向上圆整(33→48、17→24),1 字节以下的 noscan 小对象走 tiny 分配器被合并进 16 字节块,路径是「每 P 的 mcache 空闲链表 → 全局 mcentral → 页级 mheap」,只有缓存链表耗尽时才需要触碰 mcentral 的锁。 与
/posts/golang/既有运行时文章的分工:那几篇讲「分配器是什么」,本节只写增量——实测的圆整数字、1.26 引入、1.27 基线默认开启的 size-specialized malloc 分派表,以及可以直接照做的选型决策表。
4.1.1 实验:一次分配到底占多少字节
复现基线:
- Go 工具链
go version go1.27.0 darwin/arm64(GOTOOLCHAIN=go1.27.0) - 机器:Apple M1 Pro,10 核,32 GiB(
sysctl -n hw.ncpu= 10) - 未开启
-race;GOGC默认(100),GOMAXPROCS默认(10) - 每次测量:先
runtime.GC(),用runtime.MemStats.TotalAlloc前后差值除以分配次数 - 每个尺寸重复分配 500000 次;程序跑 3 遍取稳定值
测量程序的关键点是不要用接口装箱:把 &b[0] 存进预分配的 []unsafe.Pointer,这样每次循环只产生「一个请求 size 字节的堆对象」,外加一个 8 字节的指针槽(可预测、可扣除)。
package main
import (
"fmt"
"runtime"
"unsafe"
)
func measure(n, size int) float64 {
runtime.GC()
var before, after runtime.MemStats
runtime.ReadMemStats(&before)
hold := make([]unsafe.Pointer, n)
for i := 0; i < n; i++ {
b := make([]byte, size)
b[0] = byte(i)
hold[i] = unsafe.Pointer(&b[0])
}
runtime.ReadMemStats(&after)
runtime.KeepAlive(hold)
return float64(after.TotalAlloc-before.TotalAlloc) / float64(n)
}
实测输出(每行「实测总分配B」= size class 尺寸 + 8 字节指针槽):
$ GOTOOLCHAIN=go1.27.0 go run .
请求B sizeclass 实测总分配B 相对浪费
1 8 9.03 700.0 %
8 8 16.01 0.0 %
9 16 24.01 77.8 %
16 16 24.01 0.0 %
17 24 32.03 41.2 %
24 24 32.01 0.0 %
25 32 40.02 28.0 %
32 32 40.01 0.0 %
33 48 56.01 45.5 %
40 48 56.01 20.0 %
48 48 56.01 0.0 %
49 64 72.01 30.6 %
64 64 72.01 0.0 %
65 80 88.01 23.1 %
96 96 104.01 0.0 %
97 112 120.02 15.5 %
128 128 136.01 0.0 %
129 144 152.01 11.6 %
读这张表有三个必须注意的点:
- 圆整是向上取最近档位。请求 33 字节落在 48 档(相对浪费 45.5%),请求 17 字节落在 24 档(41.2%)。档位表不是线性等距的,而是「小对象密、大对象疏」:8、16、24、32、48、64、80、96、112、128……所以尺寸刚好压到档位边界(32、48、64、96、128)时收益最大。
- 请求 1 字节实得约 1 字节(9.03 − 8 = 1.03)。这不是 size class 表算错了,而是 tiny 分配器把多个小于 16 字节的 noscan 对象合并进同一个 16 字节块,因此摊到每个对象上接近 1 字节。这也是为什么「大量短字符串」的分配开销远低于直觉。
- 8 字节的偏移是测量脚手架(
hold指针槽),不是分配器开销。把它减掉,实测值与sizeclass列逐条吻合——这反过来验证了 size class 表就是分配的真实档位。
4.1.2 源码:三级结构与 1.27 的分派表
一次小对象分配的入口在 src/runtime/malloc.go:mallocgc。go1.27.0 在这里走一条按尺寸分派的快路径(goexperiment.SizeSpecializedMalloc 于 1.26 引入、当时默认关闭,1.27 基线默认开启):
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
if size == 0 {
return unsafe.Pointer(&zerobase)
}
if sizeSpecializedMallocEnabled && size < uintptr(len(mallocNoScanTable)) {
if typ == nil || !typ.Pointers() {
if size >= maxTinySize {
return mallocNoScanTable[size](size, typ, needzero)
}
return mallocgcTinySC2(size, typ, needzero)
}
...
}
mallocNoScanTable 是一张长度 81 的函数指针表(src/runtime/malloc_tables_generated.go),索引就是请求尺寸——一次数组索引代替一串分支,这是 1.27 相对 1.26 在分配路径上的主要改动(该表在 1.26 中已存在,只是默认关闭)。判定条件写死在 src/runtime/malloc.go:
const sizeSpecializedMallocEnabled = goexperiment.SizeSpecializedMalloc && GOOS != "plan9" &&
!asanenabled && !raceenabled && !msanenabled && !valgrindenabled
注意 !raceenabled:开 -race 时分派表被禁用,回落到通用路径。
小对象的取用发生在每 P 的 mcache 上。src/runtime/malloc.go:nextFreeFast 用 allocCache 位图找下一个空位,纯位运算、无锁:
func nextFreeFast(s *mspan) gclinkptr {
theBit := sys.TrailingZeros64(s.allocCache) // Is there a free object in the allocCache?
if theBit < 64 {
result := s.freeindex + uint16(theBit)
if result < s.nelems {
freeidx := result + 1
if freeidx%64 == 0 && freeidx != s.nelems {
return 0
}
s.allocCache >>= uint(theBit + 1)
s.freeindex = freeidx
s.allocCount++
return gclinkptr(uintptr(result)*s.elemsize + s.base())
}
}
return 0
}
allocCache 耗尽后进入 src/runtime/malloc.go:mcache.nextFree,它调用 s.nextFreeIndex();若返回 s.nelems(当前 span 已满),就调用 src/runtime/mcache.go:mcache.refill:
func (c *mcache) refill(spc spanClass) {
s := c.alloc[spc]
if s.allocCount != s.nelems {
throw("refill of span with free space remaining")
}
...
if s != &emptymspan {
mheap_.central[spc].mcentral.uncacheSpan(s)
...
}
s = mheap_.central[spc].mcentral.cacheSpan()
if s == nil {
throw("out of memory")
}
...
c.alloc[spc] = s
}
这一行 mheap_.central[spc].mcentral.cacheSpan() 就是「跨级」发生的地方——它需要拿 mcentral 的锁。src/runtime/mcentral.go:mcentral.cacheSpan 优先从「已清扫的部分空闲 span」取,取不到才清扫,再取不到才向 mheap 要新页:
func (c *mcentral) cacheSpan() *mspan {
spanBytes := uintptr(gc.SizeClassToNPages[c.spanclass.sizeclass()]) * pageSize
deductSweepCredit(spanBytes, 0)
...
spanBudget := 100
sg := mheap_.sweepgen
if s = c.partialSwept(sg).pop(); s != nil {
goto havespan
}
...
spanBudget := 100 是关键的工程取舍:最多清扫 100 个 span 找空闲位,超了就宁可要一块新页,把「找空闲」的最坏时间摊薄——注释里明说这是为了把空间开销限制在 1% 左右。
mcentral 再往上是 mheap,即页级分配器。它管理的是 8 KiB 的 page(pageSize),src/runtime/mheap.go:mheap.allocSpan 负责切出一整块 span 并登记到 arena 映射里。mcache 与 mcentral 都只是 mheap 之上的「缓存层」,真正的物理内存由 mheap 向操作系统要。这条路径在小对象稳态下几乎不会被触发——这也解释了为什么「分配 1 亿个 32 字节对象」和「分配 100 万个」在 CPU 时间上不是线性关系。
tiny 分配器(src/runtime/malloc.go:mallocgcTiny)的合并策略值得单独看一眼,因为它解释了 4.1.1 里「1 字节对象只占 1 字节」的现象:
c := getMCache(mp)
off := c.tinyoffset
// Align tiny pointer for required (conservative) alignment.
if size&7 == 0 {
off = alignUp(off, 8)
} else if size&3 == 0 {
off = alignUp(off, 4)
} else if size&1 == 0 {
off = alignUp(off, 2)
}
if off+size <= maxTinySize && c.tiny != 0 {
// The object fits into existing tiny block.
x := unsafe.Pointer(c.tiny + off)
c.tinyoffset = off + size
c.tinyAllocs++
...
}
maxTinySize = 16(gc.TinySize)。同一个小块里能塞几个对象,取决于尺寸:8 字节对象两个一组,1 字节对象最多 16 个——块内对象越小,摊薄越明显。注释里还给了设计取舍:块大小 16 字节对应「最坏 2 倍浪费」,8 字节「完全无浪费但合并机会少」,32 字节「机会多但最坏 4 倍浪费」。这是 4.1.3 决策表里「含指针小对象不走 tiny」的由来——tiny 块必须是 noscan,否则无法整块回收。
档位表本身在 src/internal/runtime/gc/sizeclasses.go,是 mksizeclasses.go 生成的,注释即表头:
// class bytes/obj bytes/span objects tail waste max waste min align
// 1 8 8192 1024 0 87.50% 8
// 2 16 8192 512 0 43.75% 16
// 3 24 8192 341 8 29.24% 8
// 4 32 8192 256 0 21.88% 32
// 5 48 8192 170 32 31.52% 16
max waste 一列正是 4.1.1 那张表里「相对浪费」的理论上界。注意 class 5(48 字节)的 max waste 是 31.52%,而单个对象实测最坏能到 45.5%(请求 33 字节)——max waste 是整块 span 的加权浪费,不是单对象浪费,两者不要混用。
最后,spanClass 把「size class」和「是否含指针」压进一个字节(src/runtime/mheap.go):
type spanClass uint8
func makeSpanClass(sizeclass uint8, noscan bool) spanClass {
return spanClass(sizeclass<<1) | spanClass(bool2int(noscan))
}
所以 mcache 的 alloc[] 数组长度是 NumSizeClasses << 1——同一档位下,含指针对象与纯字节对象分开存放,GC 才能只扫描含指针的那些 span。
4.1.3 决策:按 size class 选对象尺寸
由上面的表与源码,可以落成一张可直接用的选型决策表:
| 观察到的现象 | 根因(源码位置) | 决策 |
|---|---|---|
| 对象 33 字节,内存账单按 48 计 | size class 向上圆整(sizeclasses.go) | 把结构体压到 ≤32 字节,单对象省 16 字节;100 万实例省 16 MB |
| 尺寸在 32/48/64/96/128 之间「悬空」 | 档位不等距 | 优先把热结构体调到档位边界值,压到边界就是省整档 |
| 大量 1–15 字节 noscan 小对象不占 1 字节 | tiny 分配器合并进 16 字节块(mallocgcTiny) | 短字符串、小 []byte 放心用;但含指针的小对象不走 tiny(typ.Pointers() 判定) |
分配偶发变慢、runtime.mcentral 出现在 profile 里 | mcache.refill 抢 mcentral 锁(mcentral.cacheSpan) | 让热路径的分配尺寸集中在少数几个档位,减少 mcentral 争用面 |
开 -race 后分配变慢 | sizeSpecializedMallocEnabled 含 !raceenabled | 性能结论不要用 -race 构建测;race 构建会回落到通用路径 |
| 想要 0 分配的热路径 | mallocgc 对 size == 0 返回 zerobase | 零大小结构体(如 struct{})不占堆;用 struct{} 当信号量、集合成员 |
一条经验规则:先测 size class,再改字段顺序。同一个结构体,把字段从「33 字节」重排到「32 字节」带来的内存收益,往往比换一个更省内存的数据结构更直接——因为它跨过了一整个档位。
阅读导航:上一节:3.3 调度相关的性能决策 · 下一节:4.2 逃逸分析与分配决策 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。