12.5 小对象分配
小对象指尺寸在 16B 至 32KB 之间的对象(go1.26 中 maxSmallSize = 32768)。它们是 Go 程序里
最常见的一类分配:一个结构体、一个不算太大的切片底层数组、一条字符串拼接的结果,绝大多数都落在
这个区间。分配器为它们设计的路径,是 12.2 那套 mcache → mcentral → mheap
层级的主舞台,也是整套内存分配器「快路径无锁」设计(12.1)兑现性能承诺的地方。
这一节把一次小对象分配拆成三步来读:先把请求的尺寸取整到一个尺寸类,再从当前 P 的 mcache 无锁取槽,取不到才落入加锁补货的慢路径。读完会发现,常态下的小对象分配不过是「查一次表、 扫一次位、推一下游标」,慢路径只是为这条快路径兜底。
12.5.1 第一步:把尺寸取整到尺寸类
分配器不会为「恰好 37 字节」单独管理一种内存块。它把 16B 至 32KB 的尺寸空间划成 68 个尺寸类
(size class,12.1),每个尺寸类对应一个固定的对象大小(8、16、24、32、48……
直到 32768),分配时把请求向上取整到最近的尺寸类。这一步要做到 $O(1)$,靠的是两张预先生成
(由 mksizeclasses.go 算出)的查找表:
| |
为何分成两张表?尺寸类在小区间排得密、大区间排得疏(1024 以下按 8 字节一档,以上按 128 字节
一档),用两个不同步长(SmallSizeDiv = 8、LargeSizeDiv = 128)的表,能把映射压成两次
数组下标读取,避免任何循环或除法。divRoundUp 也只是一次乘加,不走真正的除法指令。
取整必然带来内部碎片:申请 33 字节会落到 48 字节的尺寸类,浪费 15 字节。尺寸类的档位是
精心调过的,目标是把最坏浪费(max waste)压在可接受范围内。表里最大的一档是 class 67,对象
和 span 都是 32768 字节、整页独占、浪费 12.5%。这 12.5% 不是巧合,而是档位设计刻意守住的上界:
尺寸类越密碎片越小,但元数据与 span 种类越多,分配器在「省内存」与「少种类」之间取的折中。
最后那行 makeSpanClass 值得一提:尺寸类还要再乘以二,区分含指针与不含指针(noscan)
两种 spanClass。后者的 span 不必被 GC 扫描(13),把它们分开存放,能让清扫和
标记跳过整段无指针内存。所以 mcache 里按 spanClass 而非 sizeclass 索引,68 个尺寸类对应
136 个 spanClass。
12.5.2 第二步:从 mcache 无锁取槽
拿到 spc 后,分配器直接取出当前 P 的 mcache 中为这个 spanClass 缓存的那个 mspan,从中摘一个
空槽。因为 mcache 每个 P 一份、同一时刻只被一个 M 持有(12.2),这一步
无需任何锁:
| |
最快的一步藏在 nextFreeFast 里。每个 mspan 用一个 allocCache(一个 uint64)缓存
freeindex 附近 64 个槽的占用情况,且存的是取反后的位图(空槽为 1)。于是「找下一个空槽」
退化成「找最低的 1 位」,一条 TrailingZeros64(数末尾连续的零,即 count trailing zeros)指令
就能定位:
| |
整个快路径就这几条指令:一次位扫描、一次移位、一次乘加算地址,全程无锁、不深入函数调用栈。
返回 0 只有两种情形:当前 64 位窗口扫完了(theBit == 64 或跨窗口边界),或这个 span 真的满了。
两种都交给慢路径处理,慢路径会负责刷新 allocCache 或换一个新 span。nextFreeFast 不刷新缓存、
不跨窗口,正是为了把它压到最短,让最热的那条路径上没有一丝多余动作。
12.5.3 第三步:慢路径补货
nextFreeFast 返回 0,就进入 nextFree。它先用 nextFreeIndex 做一次「完整」的扫描,这个版本
会跨越 64 位窗口、按需调用 refillAllocCache 把 allocBits 的下一段重新装进 allocCache,
因此能找到 nextFreeFast 略过的那些槽。只有当扫描走到 s.nelems(span 确实满了),才需要
真正的补货:
| |
refill 是补货链的第一环:把当前这个满了的 span 交还给对应尺寸类的 mcentral(uncacheSpan),
再从那里 cacheSpan 取一个有空槽的新 span 装回 c.alloc[spc]。这里访问 mcentral 是加锁的,
go1.26 还会顺手刷一批统计(已用槽数、heapLive、tiny 计数),并把新 span 的 sweepgen 标成
「已缓存」状态,防止它在缓存期间被异步清扫器抢走。
mcentral 这一层是分配与清扫(13.5)的交汇点。cacheSpan 取 span 的顺序
体现了「按清扫代分桶」的设计(12.2):
| |
deductSweepCredit 是「惰性清扫」(13.5)的执行点:GC 标记结束后并不
立刻清扫全部 span,而是把清扫摊派到后续的分配里,谁分配谁就帮忙清扫一点,避免一次 stop-the-world
式的大清扫。所以小对象分配的慢路径不只是「取一块内存」,它还顺手推进了垃圾回收。
补货链的尽头是 grow:mcentral 一个可用 span 都没有时,向全局 mheap 按页申请,切成等大槽位的
新 span。这一步又会牵出页分配器(12.7)乃至向操作系统索取 arena(12.3),
代价最大、命中最罕,与大对象分配(12.4)共用 mheap.alloc:
| |
12.5.4 span 内部:用空闲位图找槽
三步路径反复出现的核心动作,是「在一个 span 内找下一个空槽」。这件事完全由两组位图驱动,理解了 它,整条路径就连成一气。
每个 mspan 维护两层位图(12.2)。底层是 allocBits,一个 *gcBits,每位对应
一个槽,记录它是否已分配。上层是 allocCache,一个 uint64,把 freeindex 起的 64 位
allocBits 取反后缓存进来(空槽为 1),让位扫描能直接用 TrailingZeros64。refillAllocCache
负责在 freeindex 跨过 64 位窗口时,从 allocBits 装入下一段:
| |
freeindex 是这套机制的游标:扫描只从 freeindex 往后走,从不回头看它前面的槽。这把「找空槽」
从遍历整个 span 降到「在一个滑动的 64 位窗口里做位运算」,是 $O(1)$ 摊销的关键。代价是
freeindex 之前、本轮被释放出来的槽,要等到下一轮 GC 清扫重置 freeindex 后才会被重新利用。
那么 allocBits 里的 1 是何时变回 0、让槽重新可用的?答案在清扫(13.5)。
GC 标记阶段把存活对象记在 gcmarkBits 里;清扫时,运行时不去逐个清除死对象的 allocBits,
而是直接让 allocBits 指向 gcmarkBits,再给 gcmarkBits 分配一块清零的新位图。一次指针
交换,所有死对象的槽就「免清扫」地集体变回可分配。这正是 12.1 所说分配器与 GC
共生的接口:分配看 allocBits,回收写 gcmarkBits,两者通过一次指针交换衔接,分配路径
完全不必知道某个槽是「从未用过」还是「上一轮死了被回收」。
12.5.5 这样设计为何快,以及它对性能意味着什么
把三步合起来看,一次落在快路径上的小对象分配,成本是:一次尺寸类查表($O(1)$)、一次
TrailingZeros64 位扫描、一次移位、一次算地址,全程无锁。三件设计共同促成了这个「近乎免费」的
常态:
- 尺寸类把定位变成查表:固定档位让「该分多大、从哪个 span 取」都退化成数组下标,无需任何搜索。
- 每 P 的 mcache 消除争用:快路径不碰任何共享状态,多核并发分配互不阻塞,代价是每个 P 各占
一份缓存,且对象在 P 间不能直接复用(要经 mcentral 中转)。这与调度器的本地运行队列
(9.2)、
sync.Pool的每 P 分片 (11.6)是同一种「分层减争」的招式。 - 位图把取槽变成位运算:
allocBits+allocCache+freeindex把「找空槽」压到一条 位扫描指令,且与 GC 的清扫通过指针交换衔接,回收对分配几乎零成本。
越往下走,同步代价越大、命中频率越低:mcache 无锁,mcentral 加锁还要顺带清扫,mheap 要动页 分配器,再往下是系统调用。分层缓存的全部意义,就是把最热的路径做成几条无锁位运算,把昂贵的 加锁与系统调用挡在越来越冷的后方。
这条结论对写 Go 程序的人有一个直接推论:单次小对象分配已经快到几乎可以忽略,真正的成本在于
分配的「次数」与它给 GC 带来的压力。每一个逃逸到堆上的对象,最终都要被标记、被清扫;分配得
越多越频繁,GC 的工作量越大、触发越勤。所以优化内存的着力点很少是「让单次分配更快」,而是
让分配更少:靠逃逸分析(15.5)把对象留在栈上,根本
不进堆;或用 sync.Pool(11.6)复用对象,把「分配 +
回收」摊薄成「借出 + 归还」。分配器已经把自己的那一份做到了极致,剩下能省的,在调用方手里。
至此小对象的「由近及远」补货路径可以完整画出,它正是 12.1 那条补货链的演出:
flowchart TD
REQ["小对象请求 16B ~ 32KB"] --> SC["第一步:取整到尺寸类<br/>查表 O(1),得到 spanClass"]
SC --> MC["第二步:取本 P mcache 的 span(无锁)"]
MC --> FAST{"nextFreeFast<br/>allocCache 有空位?"}
FAST -->|有,位扫描取槽| DONE["返回槽地址(最快,几条指令)"]
FAST -->|本窗口无| NF["nextFree:nextFreeIndex 跨窗口扫描"]
NF -->|找到空槽| DONE
NF -->|span 已满| REFILL["第三步:refill(加锁)"]
REFILL --> CENT["mcentral.cacheSpan<br/>优先已清扫,否则就地清扫"]
CENT -->|有可用 span| BACK["装回 mcache,回到第二步取槽"]
CENT -->|一个都没有| GROW["grow → mheap.alloc 要新页"]
GROW -->|堆不足| OS["页分配器 / 向 OS 要 arena"]
GROW --> BACK
OS --> BACK
BACK --> DONE图中越靠下的分支越冷:绝大多数分配在 nextFreeFast 一步即返回,refill 之后的环节只在本地
span 用尽时才触发,grow 与系统调用更是罕见。这与微对象分配(12.6)和大对象
分配(12.4)一道,构成了 Go 分配器面对不同尺寸请求的三条主路径。
延伸阅读的文献
- The Go Authors. runtime/malloc.go(
mallocgcSmallNoscan/mallocgcSmallScanNoHeader/nextFreeFast/mcache.nextFree). go1.26. https://github.com/golang/go/blob/master/src/runtime/malloc.go - The Go Authors. runtime/mcache.go(
refill)、mcentral.go(cacheSpan/grow). go1.26. https://github.com/golang/go/blob/master/src/runtime/mcache.go - The Go Authors. runtime/mbitmap.go(
nextFreeIndex/refillAllocCache,allocBits 与 allocCache 的位扫描). go1.26. https://github.com/golang/go/blob/master/src/runtime/mbitmap.go - The Go Authors. internal/runtime/gc/sizeclasses.go(68 个尺寸类、
SizeToSizeClass8/128、 最坏 12.5% 浪费). go1.26. https://github.com/golang/go/blob/master/src/internal/runtime/gc/sizeclasses.go - Sanjay Ghemawat, Paul Menage. TCMalloc: Thread-Caching Malloc. https://google.github.io/tcmalloc/design.html (尺寸类 + thread cache 的思想原型)
- 本书 12.2 组件、12.6 微对象分配、 13.5 清扫与位图(allocBits ← gcmarkBits 的交换).
- 本书 15.5 逃逸分析、 11.6 sync.Pool(减少分配次数的两条途径).
许可
© 2018-2026 The golang.design Initiative Authors. Licensed under CC-BY-NC-ND 4.0.