Go内存分配器采用mcache→mcentral→mheap三级缓存结构,对象按大小分为微对象(<16B)、小对象(16B~32KB)和大对象(>32KB),分配路径直接影响延迟与锁竞争,size class划分导致相邻字节可能触发不同分配层级。

Go内存分配器不是靠“背概念”理解的,而是靠观察分配行为、看运行时指标、结合runtime调试工具反推出来的。多级缓存(mcache→mcentral→mheap)不是抽象模型,它直接体现在你每次make、new、甚至字符串拼接时的延迟差异和锁竞争上。
怎么确认当前分配走的是mcache还是mcentral
最直接的方式是触发GC并观察runtime.ReadMemStats中几个关键字段的变化节奏:
-
Mallocs:每次从mcache成功分配都计数(不含微对象合并) -
HeapAlloc稳定增长但HeapSys几乎不动 → 说明在复用mcache或mcentral已有span -
NumGC没变,但PauseTotalNs突增 → 很可能mcentral加锁抢span时发生了争抢(尤其在高并发小对象分配场景) - 用
go tool trace打开trace文件,在“Goroutine”视图下找runtime.mallocgc调用栈,如果看到runtime.(*mcentral).cacheSpan出现在栈顶,就说明已脱离mcache,落到mcentral了
size class如何影响mcache命中率
Go把[1, 32768]字节的对象按大小分成了约67个size class(具体数量随版本微调),每个class对应一个固定span大小(如16B对象用spanClass=2,实际span为1页/4KB,能塞256个)。关键点在于:
- 分配
make([]int, 10)(80B)和make([]int, 11)(88B)可能落入不同class → 后者可能触发一次mcentral申请 - 结构体字段顺序会影响对齐填充,从而改变总大小 → 可能跨class,导致原本能
mcache复用的分配变成mcentral加锁 -
tiny allocator只处理struct{a,b byte}),且所有请求共享同一块16B缓冲区;一旦有指针(哪怕只是*int),立刻退出tiny路径,走常规size class分配
为什么mheap申请会卡顿,而mcache不会
mcache是每个P私有、无锁、预分配好span的缓存;mheap则要跟操作系统打交道,涉及页表映射、内存清零(Linux下默认mmap+MAP_ANON+MAP_NORESERVE)、以及大块内存切分。常见卡点:
立即学习“go语言免费学习笔记(深入)”;
- 分配>32KB对象(如
make([]byte, 33000))直接绕过mcache/mcentral,调用runtime.sysAlloc→ 可能触发缺页中断 -
mheap向OS申请内存以64MB arena为单位,但首次申请arena时需初始化整个bitmap和span数组 → 单次延迟可达百微秒级 - 当
mheap内存碎片化严重(比如大量32KB边界附近的对象反复分配释放),runtime.(*mheap).grow可能需要合并空闲span或触发scavenger回收 → 表现为偶发性分配延迟尖峰
真正容易被忽略的不是三级结构本身,而是size class划分与实际对象大小之间的“隐式跃迁”——差1个字节就可能从毫秒级无锁分配变成微秒级加锁,再差一点就掉到毫秒级系统调用。调试时盯着runtime.MemStats里的SmallMallocs和LargeMallocs比值,比读源码更快定位瓶颈。


















