1. Small 对象分配概述
摘要:本文深入解析 Go 语言 Small 对象(16B~32KB)分配机制。核心路径通过 mcache.alloc 数组按 spanClass 索引直接获取 mspan,利用 allocCache 64位补码位图和 CPU CTZ 指令实现 O(1) 空闲槽查找。分配分为 nextFreeFast 快速路径(无锁)和 nextFreeIndex 慢速路径(可能触发 refill)。mcache 采用 per-P 无锁设计,仅 refill 时接触 mcentral 锁。文章还介绍了实验性特性 mallocgcSmallNoscanReuse 可重用链表机制,允许 noscan 对象立即复用以减少 GC 压力。
当对象大小在 16B ~ 32KB 之间时,走 Small 分配路径。它与 Tiny 路径的最大区别是:每个对象独占一个槽位,从对应的 size class span 中分配。
分界常量来自
internal/runtime/gc/sizeclasses.go:MaxSmallSize = 32768(32KB,sizeclasses.go:86)。实际判据是size > MaxSmallSize - MallocHeaderSize = 32760(见 04 篇),"超过 32KB"是近似说法——超过 32760 字节的请求就走mallocgcLarge直接分配整页。
Small 分配的核心链路:
mallocgc 查 size class 表 c.alloc[spc] nextFreeFast(快速)
失败
c.nextFree nextFreeIndex c.refill
分配的核心就一句话:找到该 size class 的空闲槽位。难点在于如何又快又准地找。
2. mcache 结构详解:alloc 数组
mcache 是 Small 分配的主战场,定义在 mcache.go:20-66:
type mcache struct {
// 以下字段每次 malloc 都访问,故分组放在前面以获得更好的缓存局部性
nextSample int64 // 触发堆采样还需分配多少字节 (25)
memProfRate int // 缓存的 mem profile rate,用于检测变化 (26)
scanAlloc uintptr // 已分配的需扫描字节数 (27)
tiny uintptr // 当前 tiny block 起点 (41)
tinyoffset uintptr // tiny block 内偏移 (42)
tinyAllocs uintptr // tiny 分配计数 (43)
alloc [numSpanClasses]*mspan // 按 spanClass 索引的分配 span 数组 (48)
reusableNoscan [numSpanClasses]gclinkptr // 可重用 noscan 对象链表 (57)
stackcache [_NumStackOrders]stackfreelist // 栈缓存 (59)
flushGen atomic.Uint32 // 上次 flush 时的 sweepgen (65)
}
— mcache.go:20-66

图 6-1:mcache 结构——alloc 数组按 spanClass 索引,指向对应的 mspan
核心数组 alloc [numSpanClasses]*mspan
// alloc contains spans to allocate from, indexed by spanClass.
alloc [numSpanClasses]*mspan
— mcache.go:47-48
这是 Small 分配最核心的数据结构:
numSpanClasses = gc.NumSizeClasses << 1(mheap.go:576),即 136 项(68 个 size class × scan/noscan 两维)- 下标 = spanClass:
spanClass = sizeclass << 1 | noscan(mheap.go:580-581),偶数索引是 scan,奇数索引是 noscan - 每个元素是一个
*mspan指针,指向该大小类当前正在"消费"的 span
一次分配发生了什么? 分配器拿着计算出的
spc,直接span := c.alloc[spc]拿到 span,再从 span 的空闲位图里取一个槽位——没有任何锁、没有任何遍历,两个操作就完成了。
alloc 数组与 mspan 的关系、以及热点字段分组,见上方图 6-1。注意源码把"每次 malloc 都访问"的字段(25-43 行)放在结构体最前面,利用 CPU 缓存行局部性——这是源码注释(so they are grouped here for better caching, mcache.go:23-24)明确说明的优化。
3. 三条分配路径:Noscan / ScanNoHeader / ScanHeader
mallocgc 在 malloc.go:1122-1164 按对象类型分派到三条 Small 路径:
if size <= maxSmallSize-gc.MallocHeaderSize {
if typ == nil || !typ.Pointers() {
// 不含指针 noscan 路径
x, elemsize = mallocgcSmallNoscan(size, typ, needzero)
} else {
// 含指针 scan 路径,按是否需要 MallocHeader 再分两支
if heapBitsInSpan(size) {
x, elemsize = mallocgcSmallScanNoHeader(size, typ) // 指针位图在 span 内
} else {
x, elemsize = mallocgcSmallScanHeader(size, typ) // 指针位图用对象头
}
}
} else {
x, elemsize = mallocgcLarge(size, typ, needzero)
}
— malloc.go:1140-1163(简化)
三条路径的分工:
| 路径 | 适用对象 | 类型信息存放 | 源码行号 |
|---|---|---|---|
mallocgcSmallNoscan | 无指针(数字、字符串 data 等) | 不需要 | malloc.go:1360-1454 |
mallocgcSmallScanNoHeader | 含指针且 heapBitsInSpan(size) 为真 | 指针位图放在 span 的 heap bits 里 | malloc.go:1505-1594 |
mallocgcSmallScanHeader | 含指针但太大,位图放不下 | 8 字节 MallocHeader 内嵌 *_type | malloc.go:1596-1687 |
MallocHeader 机制(Go ~1.24 新特性):当对象大到 span 内的 heap bits 装不下其指针位图时,改为在对象头部嵌入一个 8 字节的
*_type指针,让 GC 通过类型信息推断指针布局。分界常量MinSizeForMallocHeader = PtrSize × PtrBits(internal/runtime/gc/malloc.go:47)。heapBitsInSpan(size)判断哪种方案适用。
三条路径的公共骨架
虽然细节不同,三条路径的骨架完全一致(以 noscan 为例):
func mallocgcSmallNoscan(size uintptr, typ *_type, needzero bool) (unsafe.Pointer, uintptr) {
mp := acquirem() // ① 防抢占 (1362)
mp.mallocing = 1 // ② 标记分配中 (1374)
c := getMCache(mp) // ③ 取 mcache (1377)
// ④ 查 size class 表 (1379-1384)
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) // noscan true
span := c.alloc[spc] // ⑤ 直接取 span
// ⑥ GreenTeaGC:先看有没有可重用对象 (1389-1395)
if runtimeFreegcEnabled && c.hasReusableNoscan(spc) {
x := mallocgcSmallNoscanReuse(c, span, spc, size, needzero)
return x, size
}
// ⑦ 快速路径:nextFreeFast (1397-1400)
v := nextFreeFast(span)
if v == 0 {
v, span, checkGCTrigger = c.nextFree(spc) // ⑧ 慢速路径
}
x := unsafe.Pointer(v)
// ⑨ 按需清零 (1402-1404)
if needzero && span.needzero != 0 {
memclrNoHeapPointers(x, size)
}
// ... publicationBarrier、gcmarknewobject、采样、GC trigger 检查
return x, size
}
— malloc.go:1360-1454(注释为笔者添加)
Size class 查表算法
if size <= gc.SmallSizeMax-8 { // SmallSizeMax = 1024
sizeclass = gc.SizeToSizeClass8[divRoundUp(size, gc.SmallSizeDiv)] // 8B 步长查表
} else {
sizeclass = gc.SizeToSizeClass128[divRoundUp(size-gc.SmallSizeMax, gc.LargeSizeDiv)] // 128B 步长
}
size = uintptr(gc.SizeClassToSize[sizeclass])
— malloc.go:1379-1384
两段式查表:≤1024B 用 8 字节步长的查表(
SizeToSizeClass8),>1024B 用 128 字节步长的查表(SizeToSizeClass128)。这样既保证小尺寸的精度,又控制表的大小。详见 Wiki 03。
4. nextFreeFast:CPU CTZ 指令的 O(1) 查找
找到 span 之后,最核心的问题是:如何在 span 的几百上千个槽位中,瞬间找到一个空闲槽?
答案是 nextFreeFast(malloc.go:969-985),它用一条 CPU 指令完成:
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
}
— malloc.go:969-985
逐行拆解
sys.TrailingZeros64(s.allocCache)(malloc.go:970):CPU 的 CTZ(Count Trailing Zeros)指令,一条硬件指令返回allocCache中从低位起连续 0 的个数。因为allocCache中 bit=1 表示空闲,所以 trailing zeros 的个数恰好是第一个空闲槽在窗口内的偏移。result := s.freeindex + theBit(malloc.go:972):窗口起点freeindex加上偏移,得到绝对槽位号。result < s.nelems(malloc.go:973):槽位不能超出 span 的对象总数。freeidx%64 == 0 && freeidx != s.nelems(malloc.go:975-976):如果freeindex恰好越过了 64 位窗口边界,说明当前窗口缓存已不可用,必须交给慢速路径去重新加载(返回 0)。s.allocCache >>= theBit + 1(malloc.go:978):把这个空闲位"消费"掉(右移清除)。s.freeindex = freeidx/s.allocCount++(malloc.go:979-980):推进索引、计数。return gclinkptr(uintptr(result)*s.elemsize + s.base())(malloc.go:981):返回槽位地址 =base + 槽位号 × 槽位大小。

图 6-2:nextFreeFast 用一条 CTZ 指令定位空闲槽位;失败时回退到 nextFreeIndex 慢速路径
为什么是 O(1)? 传统的空闲链表或逐位循环需要 O(n) 时间找到空闲位。而 CTZ 是一条硬件指令,无论窗口里有多少位、空闲槽在哪,都是固定周期完成。对 span 中大量空闲的情况,这就是平均 1-2 条指令就完成一次分配。
5. allocCache:64 位补码位图缓存
nextFreeFast 能 O(1) 的关键,在于 mspan 里的 allocCache 字段。
allocBits vs allocCache
allocBits(mheap.go:490):完整分配位图,每个对象槽位 1 bit,bit=1 表示已分配。一个 8KB 页、16B 槽位的 span 有 512 个对象,对应 512 bit = 64 字节的位图。allocCache(mheap.go:466):当前窗口的 64 位补码缓存,即allocCache = ^allocBits[freeindex ... freeindex+63]。
为什么要补码? 在
allocBits中 1=已分配、0=空闲;如果直接对 allocBits 做 trailing zeros,找到的是第一个已分配的槽,方向反了。取补码后 1=空闲,trailing zeros 找到的才是第一个空闲槽——正好是我们要的。这是利用位运算"省一次减法"的巧妙设计。
// allocCache is a cache of allocBits, with the low bits
// of the cache corresponding to freeindex. It is used
// to speed up allocation.
allocCache uint64
— mheap.go:465-467
窗口的移动
每次分配后 allocCache >>= theBit + 1(malloc.go:978),窗口内的位被逐个"消费"。当窗口耗尽(全部为 0,TrailingZeros64 返回 64)或跨越 64 位边界时,用 refillAllocCache 从 allocBits 重新加载下一段 64 位(mbitmap.go:1137、mbitmap.go:1159)。
为什么是 64 位窗口?恰好是
uint64的宽度,正好对应一条 CTZ 指令的操作数大小,也和 CPU 的寄存器宽度对齐。64 位是"一次硬件操作能处理的最大窗口"。
6. nextFree 慢速路径:nextFreeIndex 逐位扫描
当 nextFreeFast 返回 0(窗口耗尽或跨边界)时,进入 mcache.nextFree(malloc.go:996-1024):
func (c *mcache) nextFree(spc spanClass) (v gclinkptr, s *mspan, checkGCTrigger bool) {
s = c.alloc[spc]
checkGCTrigger = false
freeIndex := s.nextFreeIndex() // 逐位扫描(可能触发窗口 refill)
if freeIndex == s.nelems {
// The span is full.
c.refill(spc) // ① span 用完了 从 mcentral 换新 span
checkGCTrigger = true
s = c.alloc[spc]
freeIndex = s.nextFreeIndex()
}
v = gclinkptr(uintptr(freeIndex)*s.elemsize + s.base())
s.allocCount++
return
}
— malloc.go:996-1024
nextFreeIndex:逐位扫描 + 窗口重载
nextFreeIndex(mbitmap.go:1115-1163)是真正"找遍整个 span"的函数:
func (s *mspan) nextFreeIndex() uint16 {
...
aCache := s.allocCache
bitIndex := sys.TrailingZeros64(aCache)
for bitIndex == 64 { // 当前窗口全 0
// Move index to start of next cached bits.
sfreeindex = (sfreeindex + 64) &^ (64 - 1)
if sfreeindex >= snelems {
return snelems // 整个 span 都满了
}
whichByte := sfreeindex / 8
s.refillAllocCache(whichByte) // 从 allocBits 重新加载下一段 64 位
aCache = s.allocCache
bitIndex = sys.TrailingZeros64(aCache)
}
result := sfreeindex + uint16(bitIndex)
...
return result
}
— mbitmap.go:1115-1163
它和 nextFreeFast 的区别:nextFreeFast 只检查当前窗口(一次 CTZ,不循环);nextFreeIndex 在窗口耗尽时循环重载 allocCache,一窗口一窗口地扫完整个 span。二者共用同一套 CTZ 逻辑。
为什么有两条路径?
nextFreeFast是热路径,必须极简——一次 CTZ、几条赋值就返回。nextFreeIndex是冷路径,允许循环和内存加载。把"常见情况"和"少见情况"分离,是 Go runtime 优化的核心手法。
整个 span 都用完时:refill
当 freeIndex == s.nelems 表示当前 span 已满(malloc.go:1000-1008),调用 c.refill(spc):
- 把已满的 span 归还给 mcentral
- 从 mcentral 取一个新的有空间 span 放到
c.alloc[spc] - 置
checkGCTrigger = true,让调用方检查是否需要触发 GC(因为这是一次"重量级"分配)
refill是 Small 分配里唯一会碰到锁的地方(mcentral 的 class 级锁)。这也是为什么"慢速路径才可能加锁"——绝大多数分配都在nextFreeFast无锁完成。refill 的详细流程见 Wiki 07。
7. Per-P 的无锁设计:为什么 mcache 不需要锁
这是整个分配器性能的灵魂问题:为什么 c.alloc[spc] 可以直接读写而不加锁?
答案:mcache 与 P 一一绑定
// Per-thread (in Go, per-P) cache for small objects.
// This includes a small object cache and local allocation stats.
// No locking needed because it is per-thread (per-P).
— mcache.go:14-16
关键约束链:
- 每个 P 拥有唯一的 mcache(P 结构体里持有
mcache指针)。 - 同一时刻一个 P 上只能有一个 M 在跑(Go 调度器的 P-M 绑定关系)。
- 分配时通过
acquirem锁定当前 M,防止分配中途被抢占、P 被切换(malloc.go:1206、malloc.go:1362)。 - 因此,访问
c.alloc[spc]的线程,必然是持有该 P 的线程——不存在并发访问,自然不需要锁。
一句话理解:
mcache是"每个人口袋里的钱包",GOMAXPROCS个钱包各归各的,谁也不会抢谁的。只有钱包空了(refill)才需要去"银行的保险库"(mcentral/mheap)取钱——那里才有锁。
// No locking needed because it is per-thread (per-P).
// mcaches are allocated from non-GC'd memory, so any heap pointers
// must be specially handled.
— mcache.go:16-19
为什么不用"每线程一个缓存"?
传统 TCMalloc 是 per-thread 缓存。Go 选择 per-P 而非 per-M(线程),原因是:
- M 的数量可变(可多于 GOMAXPROCS),per-thread 缓存会导致内存浪费
- P 的数量固定(= GOMAXPROCS),缓存数量确定、内存可控
- goroutine 可在 M 之间迁移,如果缓存挂在 M 上,goroutine 换线程就换缓存,缓存命中率低
代价:P 会被调度器定时刷新(flush)。GC 标记阶段需要
releaseAll清理 mcache 中的 span(尤其是tiny指针,因为 mcache 在非 GC 内存里,mheap.go 相关处理见acquirep/releaseAll)。flushGen字段(mcache.go:65)就是用来追踪"这个 mcache 的 span 是否还新鲜"的。
无锁分配的多级防护
| 机制 | 作用 | 源码 |
|---|---|---|
| P 独占 mcache | 数据结构层面的隔离 | mcache.go:14-16 |
acquirem/releasem | 分配期间禁止抢占 | malloc.go:1206 |
mp.mallocing | 标记分配中,GC 不可抢占 | malloc.go:1218 |
refill 才加锁 | 把锁限制在慢速路径 | refill 定义于 mcache.go:160,调用点 malloc.go:1006 |
8. 新特性:mallocgcSmallNoscanReuse 可重用链表
这是基于
GOEXPERIMENT=runtimeFreegc实验特性的新代码路径(GreenTeaGC 的一部分)。它让 noscan 对象在释放后可以直接复用,避免重新分配。
动机
常规分配中,对象释放后,槽位要等 GC 清扫后才会再次可用。但 noscan 对象没有指针,可以安全地立即复用——不用等 GC。这能显著减少分配次数和 GC 压力。
数据结构:reusableNoscan 链表
// reusableNoscan contains linked lists of reusable noscan heap objects, indexed by spanClass.
// The next pointers are stored in the first word of the heap objects.
reusableNoscan [numSpanClasses]gclinkptr
— mcache.go:55-57
每个 spanClass 一个无锁链表头,链表节点就是空闲的 noscan 对象本身——next 指针存在对象的第一个字里(不额外占内存)。
分配时的复用检查
// First, check for a reusable object.
if runtimeFreegcEnabled && c.hasReusableNoscan(spc) {
// We have a reusable object, use it.
x := mallocgcSmallNoscanReuse(c, span, spc, size, needzero)
return x, size
}
— malloc.go:1388-1395
func (c *mcache) hasReusableNoscan(spc spanClass) bool {
if !runtimeFreegcEnabled {
return false
}
return c.reusableNoscan[spc] != 0
}
— mcache.go:384-391
复用路径的差异
mallocgcSmallNoscanReuse(malloc.go:1456-1503)与普通分配有几处关键差异:
- 不检查
span.needzero,只检查needzero(malloc.go:1474-1478):对象曾被使用过,但当前调用方要求零值才清零。 - 补偿 GC assist 信用(malloc.go:1470-1472):复用不是"新分配",但
mallocgc入口已扣除过 assist 信用,这里要加回来,保持记账平衡。 - 不做
freeIndexForScan更新、不做 profile、不检查 GC trigger(malloc.go:1483-1484):这些是"新分配"特有的副作用,复用路径一律跳过。
实验性限制(源码 TODO 注释,malloc.go:1485-1494):复用对象不计入 allocs/frees 统计,会导致 pprof 的堆画像有偏差。这是该特性目前停留在
GOEXPERIMENT阶段、尚未转正的原因之一。
释放端:谁来把对象放进链表?
真正把 noscan 对象挂进链表的是释放入口 freegc(malloc.go:2007 附近,GOEXPERIMENT=runtimeFreegc)调用 c.addReusableNoscan(mcache.go:371-382):
// mcache.go:373-382
func (c *mcache) addReusableNoscan(spc spanClass, ptr uintptr) {
if !runtimeFreegcEnabled {
return
}
// Add to the reusable pointers free list.
v := gclinkptr(ptr)
v.ptr().next = c.reusableNoscan[spc]
c.reusableNoscan[spc] = v
}
noscan 对象被释放时,若满足条件,会被 push 进对应 spanClass 的 reusableNoscan 链表;下次同大小类的分配直接复用。而 refill(mcache.go:168-175)在换 span 时会做相反的清理:若 spc == tinySpanClass 直接清空 reusableNoscan[spc]——因为 tiny 对象是合并分配的,不能单独复用(源码 TODO 也点名了这一限制)。

2514

被折叠的 条评论
为什么被折叠?



