
导读:这篇文章不讲"三级缓存是什么",那种文章你早看够了。我想讲的,是每一层设计到底在跟什么讨价还价:mcache 用缓存换锁、mcentral 用锁换碎片、大小类用空间换时间。看懂这盘账,你才能看懂自己的 alloc 热点为什么长那样。
先看一行代码。
u := &User{Name: "Alice"} // 这个对象,分配在堆上
它平凡到你不会多看一眼。但如果你问我"这次分配到底经历了什么",答案能写五千字——而且每一层都在做一笔交易。
对象从堆上拿到内存,走的是 Go 运行时内置的分配器。它叫 malloc,但和你熟悉的 C 的 malloc 不是一回事。Go 的分配器借了 TCMalloc 的骨架,改成了自己的样子,然后往里面塞了三个核心设计:三级缓存、span、大小类。
面试八股会告诉你这三个词是什么。但那不解决问题。你背完"mcache 是每 P 的缓存,mcentral 是全局的,mheap 管虚拟内存",关上浏览器就忘干净了。
我写这篇,是想把另一层讲清楚:每一个设计,都在讨价还价。缓存快,但碎片贵;锁少,但全局竞争;空间浪费,但时间省下来。分配器不是一堆名词,是一盘账。
为了把这盘账算明白,我自己写了个简化版 allocator,跑了几组实验。后面你会看到:无缓存、有缓存、带大小类,三种设计在合成混合负载下快了近 1.8 倍;一个结构体从 32 字节涨到 33 字节,分配开销涨约 66%(第五章详述)。
先别急,我们从"一次分配"开始。
一、一次分配,走三条路

上面这张图,就是一次 &User{...} 分配的全部路径。它有三条分支,对应三个问题:多小算小、多快算快、没了怎么办。
第一条路:小于 16 字节。 Go 有个叫 tiny allocator 的机制,会把很多个小对象塞进同一个 16 字节的槽位里。想象一个抽屉,你放钥匙、放硬币、放名片,全部塞进去——抽屉本身只被创建一次。代价是这些对象不能被单独释放,只能跟着槽位一起被回收。这是一个典型的"用管理粒度换分配次数"的交易。
第二条路:16 字节到 32KB。 这是绝大多数对象的归宿。分配器先找当前 P 的本地缓存 mcache(每个处理器 P 都有一个,私有的),不需要加锁。命中就直接从缓存里拿一块,全程无锁。miss 了才去中心缓存 mcentral 要,mcentral 是全局的,按大小类分成 136 个 span class(68 个大小类 × scan/noscan 各一),每类独立锁。再 miss,才落到 mheap。mheap 负责从操作系统要页(mmap),然后把连续内存组织成 span 交给上层。
第三条路:超过 32KB。 直接走 mheap 的大对象路径,一次性从 OS 拿,简单粗暴,不走缓存。因为大对象不值得缓存。
你看,一次分配已经拆出三条路。每一条路,都是一次权衡的结果。为什么是 16 字节?为什么是 32KB?为什么是 68 个大小类?为什么 mcache 要每 P 私有?
这些"为什么",才是这篇要回答的。先别急着记住答案,我带你跑一组实验,看看没有这些设计,世界会变成什么样。
二、没有缓存的世界:一次锁的战争
我写了一个简化版分配器,模拟三种设计。它不精确(真实 Go 分配器有几千行代码,我这里每个版本只有几十行),但三种设计的核心权衡,可以原样复现:
- v0:无缓存。一个全局链表 + 一把全局锁。每次分配都加锁,从链表头部取。
- v1:每 P 缓存。每个 P 有自己的本地链表,命中直接取;miss 了才碰全局。
- v2:大小类 + 每 P 缓存。在 v1 基础上,把对象按大小分成 4 个类,每类一个链表。
跑两种负载:混合模式(分配一半、释放一半,模拟典型业务)和纯分配(只分配不释放,模拟突发分配)。
=== 混合分配+释放(8 goroutines × 1000000 次分配)===
v0 无缓存(全局锁): 1.043529708s
v1 每P缓存(无分类): 784.262709ms
v2 大小类+每P缓存: 570.100875ms
v1/v0 加速比: 1.3x
v2/v0 加速比: 1.8x
=== 纯分配(锁竞争最激烈)===
v0 无缓存(全局锁): 775.475875ms
v1 每P缓存(无分类): 915.727125ms
v2 大小类+每P缓存: 940.659166ms
实测 Go 1.26.2 darwin/arm64,Apple M4 Pro,简化 allocator 模拟,复现代码见 GitHub
两行数据,一个反直觉的发现。
混合模式下,缓存的价值一目了然:无缓存 1.04 秒,加缓存 0.78 秒,再分大小类 0.57 秒,快了近一倍。这就是三级缓存存在的意义:把"每次分配都要全局锁"变成"大多数分配根本碰不到锁"。
但纯分配模式下,事情反过来了:无缓存的 v0 反而最快(0.78 秒),两个带缓存的版本都更慢。
为什么?因为在纯分配模式下,缓存永远命中不了。每个对象分配完就被丢弃,缓存里没东西可复。所有请求都 miss,miss 就得碰全局锁,比 v0 直接分配新块多走了一层。缓存成了负担而非加速。
(说明:这里有个模型差异:我的模拟器是对象级缓存,miss 就碰全局锁。真实 Go 的 mcache miss 是从 mcentral 换整根 span(一次拿几百个对象),不会每次碰全局锁。所以真实 Go 里"纯分配"不会被全局锁拖累。这个实验的价值在于演示缓存复用与否的差异,不是复现真实 Go 的锁竞争。)
这个反直觉的结果,展示的是缓存设计的通用逻辑:缓存的价值,取决于命中率。命中率高,缓存是神器;命中率低,缓存是累赘。真实 Go 分配器敢用缓存,是因为真实业务的对象大部分短生命周期、被反复分配释放,命中率天然高。这是一笔建立在 workload 统计上的交易。
(你可能会问:那纯分配怎么办?真实场景里,纯分配意味着对象死了,GC 会来收。GC 的成本是另一笔账,第五章真相二会讲到。)

三、每一层,都在跟锁讨价还价
现在我们把三层拆开,逐层看它在跟什么讨价还价。
mcache:用内存换锁
每 P 一个 mcache,是分配器最激进的一笔交易:用"每个 P 一份私有内存"换"绝大多数分配无锁"。
为什么必须每 P 私有?因为如果所有 P 共享一个缓存,就得加锁。锁一加,缓存就废了。每 P 私有意味着:P 之间不需要协调,分配路径上没有任何锁。代价是什么?每个 P 都要维护自己的缓存,空闲对象分散在 14 个 P 的缓存里,利用率下降。这是一个"用空间换锁"的交易。
交易成立的前提,是 P 的数量有限(默认 GOMAXPROCS,机器核数)且每个 P 的分配模式相对稳定。如果某个 P 疯狂分配,另一个 P 闲置,它们的缓存不会自动平衡。这正是 mcentral 存在的理由。
mcentral:用锁换碎片
mcentral 是全局的中心缓存,按大小类分成 136 个 span class(68 个大小类 × scan/noscan 各一),每类独立锁。它的职责是:在 mcache 之间搬运对象,同时把同大小的对象聚在一起,减少碎片。
这里有个精妙的设计:锁的粒度 = 大小类的粒度。不同大小类的对象互不干扰,各自一把锁。如果 8 字节类和 64 字节类同时被争抢,它们用的是不同的锁,不会互相阻塞。这比"一把全局锁管所有大小"细得多。
代价是:mcache 和 mcentral 之间搬运对象需要加锁。所以 Go 做了一次交换,批量搬运:mcache 一次向 mcentral 补一整根 span(容量由 size class 与页数决定,比如 64B 类的一根 span 装 128 个对象),而不是一个个要。批内分配无锁。锁次数与每个 P 手里囤的内存随 span 容量此消彼长:span 越大,搬运频率越低、锁越少,但缓存闲置内存越多。Go 通过 size class 的页数选择与清扫/归还机制间接平衡这笔账,没有可调的批量旋钮。
mheap:面对操作系统的两次砍价
mheap 是分配器的最后一道防线,直接面对操作系统。Go 从 OS 要内存按页而非按对象要(8KB 一页),把页组织成 span 交给上层。它有两次砍价:
第一,跟系统调用砍。每次 mmap 都是一次系统调用,成本很高,所以 Go 宁可一次多要、留作缓冲。代价是进程可能持有比实际使用更多的虚拟内存,这跟 GC 的"空闲内存不还给 OS"是配套的。
第二,跟碎片砍。span 释放后会按大小类放回 mcentral 的空闲列表,等下一个同大小的对象来用。跨大小类的 span 转换(整体重登记)成本很高,Go 尽量避免。
大小类:用空间换时间
最后是大小类本身。Go 有 68 个 size class,从 8 字节到 32KB,间隔不是均匀的:
class bytes/obj objects tail waste max waste
1 8 1024 0 87.50%
2 16 512 0 43.75%
3 24 341 8 29.24%
4 32 256 0 21.88%
5 48 170 32 31.52%
6 64 128 0 23.44%
...
18 256 32 0 5.86%
...
Go 1.26.4 源码 sizeclasses.go 节选,数据见 GitHub
间隔为什么非均匀?因为这是一个碎片率 vs 查找速度的权衡。
如果间隔均匀(8,16,24,32…),8KB 的 span 就能装下更多不同大小的对象,但碎片率上升。你想分配 17 字节,得上 24 字节的类,浪费 7 字节;你想分配 30 字节,也得 32 字节,浪费 2 字节。间隔越细,浪费越少,但 68 个类要管理 136 个 span class、136 个缓存槽位,查找和管理的开销越大。
Go 选择了小类密集、大类稀疏:小对象(8-128 字节)间隔细(8-16 字节),因为小对象碎片最伤内存;大对象(1KB 以上)间隔粗(按百分比增长),因为大对象数量少、碎片影响小。这是个经验权衡,目标是把整体 max waste 控制在 30% 以内。从源码看,绝大多数档位的 max waste 确实在 10-30% 之间。
这一层的交易是:你分配 1 字节,也要付 8 字节的槽位(含指针的小对象无法走 tiny allocator 合并,只能按 size class 分配)。87.5% 的最大浪费就发生在最小的 size class 上。用空间换时间,因为一次分配的时间成本远高于那几个字节。

四、Go 借了 TCMalloc 的骨架,改了三处
到这里,我们已经把三层缓存和大小类讲完了。但你可能已经意识到一个问题:这套东西听起来怎么这么像 TCMalloc?
确实。Go 分配器的骨架(三级缓存、size class、span)都来自 TCMalloc,Google 的 C++ 内存分配器。但 Go 不是照抄,它做了三处关键改造,每一处都对应 Go 特有的约束。
改造一:per-P 缓存,而不是 per-thread
TCMalloc 用的是 per-thread 缓存(Thread Cache),缓存跟 OS 线程绑定。Go 不能这么干,它的并发模型是 goroutine,一个 goroutine 与 OS 线程没有绑定关系,可以在 M 之间自由迁移。如果缓存跟线程绑定,goroutine 一迁移,缓存就白带了,命中率直接崩坏。而且 M 的数量是动态创建销毁的,per-thread 缓存难以稳定复用。
所以 Go 把缓存绑在 P(processor)上,P 是 Go 调度器的抽象,数量固定(GOMAXPROCS),每个 P 同一时刻只跑一个 goroutine。缓存跟随 P,意味着:goroutine 切走再回来,即使落在别的 P 上,每个 P 的缓存都还在、都能复用,无需重建。
这是一笔"缓存跟随调度器而非跟随执行者"的交易,是 Go 对 TCMalloc 最本质的偏离。

改造二:tiny allocator
TCMalloc 的最小 size class 是 8 字节,对象小于 8 字节也要占一个槽位。Go 更激进:小于 16 字节的 noscan 对象,会合并到同一个 16 字节槽位里,多个小对象共享一个槽位,而不是各占一个。
这个收益直接体现在 GC 的对象数上。我实测了一下(实测 Go 1.26.2,复现代码见 GitHub):分配 100 万个 4 字节对象(如 struct{a,b int16}),堆上的实际对象数只有约 25 万个。tiny allocator 把 4 个 4B 对象合并进一个 16B 槽位,GC 需要扫描/跟踪的对象数减少了 4 倍。
这个收益为什么重要?因为 GC 的成本大头是扫描对象,每个对象都要检查它有没有引用别的对象。对象数少 4 倍,GC 每轮扫描的工作量就少 4 倍。对一个大量使用小对象的服务(比如坐标点、小配置项、ID 对),tiny allocator 是免费的 GC 加速。

代价是什么?合并分配的对象不能被单独释放。你无法 free 一个 4 字节对象,它只能跟着 16B 槽位一起被 GC 回收。而且只有不含指针的对象能走这条路(含指针的要保持引用可追踪)。这是一笔"牺牲释放粒度,换 GC 扫描量"的交易。
(为什么是 16B?太小(如 8B)浪费少但合并机会也少;太大(如 32B)浪费多。16B 是"合并效率 vs 最坏浪费"的经验折中。)
改造三:scan/noscan 分离
TCMalloc 不管对象里有没有指针,统一按字节管理。Go 不行,Go 有 GC,GC 需要知道哪些对象含指针(需要扫描),哪些不含(不用扫)。所以 Go 把每个 size class 又分成两半:scan(含指针,GC 要扫描)和 noscan(不含指针,GC 跳过)。
这解释了为什么 68 个 size class 在 mcache 里实际是 136 个槽位(68×2)。每个类都有 scan/noscan 两个变体。GC 扫描时,noscan 的对象直接跳过,省掉大量扫描时间。这是一个"用双倍缓存槽位,换 GC 扫描效率"的交易。

五、代价落到你头上:alloc 热点的真相
前面四章讲的是分配器内部的账。这一章,把账算到你写的代码上。因为分配器的每一个设计决策,最终都体现在你的 alloc 热点里。
真相一:对象大小,决定了分配成本
回到第二章的结论。我构造了 32 字节和 33 字节两个结构体,它们只差一个字节,但跨过了 size class 边界(32→48 类,因为 32 和 48 之间的 33-47 都没有中间类):
type S32 struct { a, b, c, d int64 } // 32 字节
type S33 struct { a, b, c, d int64; x int8 } // 33 字节(对齐后 40 字节)
实测(Go 1.26.2 实测,复现代码见 GitHub):
BenchmarkAlloc32-14 4830662 262.7 ns/op 3200 B/op
BenchmarkAlloc33-14 3117436 437.4 ns/op 4096 B/op
BenchmarkAlloc64-14 2857401 423.9 ns/op 6528 B/op
BenchmarkAlloc65-14 1771927 717.1 ns/op 8192 B/op
32→33 字节:分配慢 66%,内存多占 28%。 64→65 字节:慢 69%,多占 25%。只加一个字段,分配成本增加近七成。因为对象跨进了更大的 size class:32→48 类,一个 8KB span 能装的对象从 256 个降到 170 个(容量约 -33%),单次分配还要零初始化更多字节,GC 记账成本也跟着涨。
这不是玄学,是 size class 边界的硬规则。你的结构体大小,决定了它在哪个 size class 里,而那个 size class 决定了分配的单价。

(注:128→129 字节的差异几乎为零。因为 128→144 的 size class 相对增幅只有 12.5%(128B 是 class 10,129B 对齐后落 class 11 的 144B),span 容量只降 12.5%,效应淹没在测量噪声里。而 32→40 是 +25% 的跳跃。同为跨类,敏感度取决于相对增幅,这反而印证了第三章"小类密集、大类稀疏"的设计逻辑:小类间隔细,跨一步代价大;大类间隔宽,跨一步无所谓。)
(另注:实测的"内存多占 28%“含内存采样误差(B/op 按 4KB 页圆整)。按 size class 理论,32B 对齐到 40B 是每对象 +25%,64B 对齐到 80B 也是 +25%。量级一致,方向明确。)
真相二:分配快不快,一半看 GC
很多人在 pprof 里看到 alloc 热点,第一反应是"分配器太慢”。这是个误读。
分配本身其实很快——Go 的分配器平均每次分配几十纳秒。但分配产生的垃圾,要 GC 来还。我跑了组对照(实测 Go 1.26.2,复现代码见 GitHub):同样做 1000 万次"创建一个 16 字节对象"的逻辑,每次分配新对象的方案耗时 81ms、触发 6 次 GC;复用预分配对象池的方案只耗时 12ms、零 GC。
同样的逻辑,分配 vs 复用差了 6.5 倍。分配本身只占一小部分,真正的开销是 GC 每轮扫描那几百万个刚变成垃圾的对象。

所以排查 alloc 热点,先问一句:这些对象是短命还是长命?短命 → GC 压力,考虑对象复用(对象池、sync.Pool);长命 → 分配成本,考虑结构体大小和 size class。
真相三:一个真实的排障现场
讲个缩影故事(人名为虚构,场景基于工程常识,数字为可复现的推算)。
去年我调过一个搜索服务,P99 延迟从 45ms 飙到 200ms,CPU 打满。pprof 一看,alloc 热点第一行:make([]Entry, 0, 512),占了 40% 的分配。
第一反应是"这个切片分配太多了,要复用"。但复用方案改了三次,延迟纹丝不动。后来我把 Entry 的 unsafe.Sizeof 打出来,每个 Entry 是 64 字节,make([]Entry, 0, 512) 一次性分配了 512 × 64 = 32768 字节(32KB),越过了 32760(32KB - 8 字节头)的边界,直接走了大对象路径。而大对象不走缓存,每次分配都要碰 mheap 的全局路径,不经任何缓存兜底。
复用解决不了问题,因为问题不在"分配次数",在"单次分配的路径"。32768 字节的对象每次都要走 mheap 的大对象路径,那是全分配器竞争最激烈的路径。
(注:大/小对象的分界并非正好 32KB,实际是 32KB - 8 字节(MallocHeaderSize),即 32760。判定只看总大小是否越过这个阈值,与是否含指针无关。上面这个案例里 512×64=32768 > 32760,恒定走大对象路径。)
最后把 cap 从 512 改成 384(384×64 = 24576,退回小对象路径),分配开销直接降了 60%,P99 回到 60ms。
这个案例想说的是:alloc 热点的真相,往往不在"分配了多少次",而在"分配走了哪条路"。而那条路,是由对象大小和 size class 边界决定的。这就是分配器设计与你的代码之间那笔账。

结语:讨价还价,是分配器的全部
现在回到开头那句话:每一个设计,都在讨价还价。
- mcache 用每 P 私有内存,换无锁分配;
- mcentral 用批量搬运,换碎片控制;
- mheap 用虚拟内存缓冲,换系统调用次数;
- 大小类用空间(87.5% 的最大浪费),换时间(几十纳秒的分配);
- tiny allocator 用释放粒度,换内存密度;
- scan/noscan 用双倍槽位,换 GC 扫描效率。
没有免费的午餐,分配器里的每一个"快",都有一笔账要付。理解这盘账,你才算真的看懂了分配器,而不是记住了三级缓存的名字。
下次再看到 pprof 里的 alloc 热点,先别急着"优化分配次数"。先问自己三个问题:
- 这些对象多大? 是不是恰好卡在 size class 边界上(32/33、64/65、1KB/1KB+1)?
- 它们是短命还是长命? 短命看 GC,长命看分配路径。
- 分配走了哪条路? 大对象(越过 32KB 边界,实际是 32760 字节)每次都要碰全局锁,值得检查。
这三个问题,就是分配器设计教给你的排障框架。它不玄,就是这盘账的收银小票。
(你在 pprof 里见过最诡异的 alloc 热点是什么?评论区聊聊。)
原文发布于 止语Lab