想理解 Go 内存分配器,先问锁在哪
去看 Go runtime 的内存分配源码,很少有人能一眼记住 mcache、mcentral、mheap 这三层到底怎么配合。原因不是 Go 文档写得差,而是这三级分配器深度绑定了 runtime 的调度和 GC,脱离实际性能问题去读,很容易变成看一堆结构体定义。

我第一次真正理清这条链路,是在排查一个 Go 微服务的内存分配耗时的时候。QPS 不低,但业务逻辑简单,理想情况下扩容 GOMAXPROCS 应该带来近乎线性的吞吐提升。结果从 4 个 P 增加到 8 个 P,延迟反而出现了明显长尾。用 pprof 抓 CPU profile,发现大量时间花在 runtime.lock 上。顺着源码往下看,最终定位到 mcentral 某个 size class 的锁竞争。从那以后,我再看 Go 的内存分配,都会先问一句:这一次分配,到底会碰到哪一层。
为什么是三级而不是一个大池子
任何内存分配器都要面对并发与本地性之间的矛盾。如果所有人都从一个全局列表里拿内存,那么再快的内存池也会被锁拖垮。如果每个线程都单独管理全部内存,内存碎片和缓存一致性成本又会失控。
Go 选择了一条经典的路径:把内存分成不同大小级别,再按线程本地、全局共享、系统内存三层组织。所有小对象分配先尽量在 P 本地持有的 mcache 里完成,只有 mcache 缺货时才向上申请。这样设计的目的只有一个:高频路径上尽量避免锁和内存屏障。
| 层级 | 作用域 | 锁粒度 | 核心职责 |
|---|---|---|---|
| mcache | 逻辑处理器 P | 无锁 | 小对象无锁分配,按 size class 维护空闲链表 |
| mcentral | 全局,按 size class 拆分 | 每个 mcentral 一把锁 | 为 P 补充和回收 mspan |
| mheap | 全局 | 全局锁与 heap arena 锁 | 管理系统内存页、span、GC 元数据 |
mcache:每个 P 的无锁快车道
mcache 是 Go runtime 为每个逻辑处理器 P 维护的内存缓存结构。由于 Go scheduler 保证了同一个时刻一个 P 最多只会执行一个 goroutine,mcache 内部的操作不需要加锁。这意味着每次创建短生命周期的小对象,大概率只是从空闲链表上摘一个节点或回写一个节点,代价远低于系统调用和锁操作。
但 mcache 本身并不持有太多内存。它里面按 size class 维护空闲对象链表,以及一个小对象分配器。当一条链表取空时,它不会自己向 OS 申请内存,而是去 mcentral 找 mspan。这里特别容易误解的一点是:mcache 既不是 goroutine 级别,也不是线程级别,它跟随 P 存在。goroutine 从操作系统线程被调度到 P 上之后,才使用这个 P 的 mcache。
mcentral:按 size class 分散的共享池
mcentral 可以理解成某一类大小对象的专用中转池。Go 把不超过 32KiB 的对象划分成多个 size class,每个 size class 对应一个独立的 mcentral。为什么要拆这么细?因为不同大小对象的分配频率差异极大,如果把 256 字节和 4 字节的分配混在同一个锁下面,低一端的分配会不停被另一端拖住。
每个 mcentral 维护 partial 和 full 两组 span 集合。span 不是单个对象,而是一整块连续内存页。mcentral 平时做的事情,是把带有空闲对象的 span 交给 mcache,当 mcache 上的 span 里对象全空了、或者 GC 之后需要回收时,mcentral 再把它们收回来统一管理。
这个机制的隐患也在这里:当分配的对象大小高度集中时,很多 P 的 mcache 可能同时缺货,于是大家集中向同一个 mcentral 申请,同一个锁上就开始排队。举一个很实际的例子:一个解析 JSON 的服务,每次请求会产生大量长度接近的临时字符串,这些对象恰好落在同一个 size class 上。GC 之后 mcache 被清空,所有 P 同时 refill,mcentral 的这把锁就会成为瞬时热点。
mheap:真正的“地主”
如果 mcentral 是中转站,mheap 就是持有“地契”的一方。它管理所有由 OS 分配的内存页,负责把 8K 对齐的内存页组合成不同大小的 span。当 mcentral 发现没有可用 span 时,会向 mheap 申请;mheap 还会处理 span 的合并、拆分和内存归还。
大对象(一般指超过 32KiB 的对象)不会经过 mcache 和 mcentral,而是直接从 mheap 分配。这样做绕过了本地缓存,单次开销更大,但因为大对象分配频率不高,又可以避免大块对象打散 mcache 的本地缓存,整体上是划算的。
mheap 里还保存了大量 GC 元数据:每个内存页对应的 GC bitmap、对象类型标记、heap arena 的映射关系等。GC 标记阶段的很多扫描逻辑都会在这些数据结构上遍历。所以 mheap 不只在分配时参与,在回收时也会牵动全局。
从一次分配看三级协作
抛开源码细节,可以用一段伪代码来把握分配路径:
func mallocgc(size uintptr) unsafe.Pointer {
if size <= maxSmallSize {
c := getMCache()
cls := sizeToClass(size)
if obj := c.alloc[cls].popFree(); obj != nil {
return obj
}
// mcache 缺货,从 mcentral 拿一个 span
c.refill(cls)
return c.alloc[cls].popFree()
}
// 大对象直接找 mheap
sp := mheap.allocSpan(sizeToPages(size))
return sp.base()
}
这段伪代码隐藏了两个重要细节:一是 refill 内部会锁到对应 mcentral,可能产生等待;二是 mheap.allocSpan 遇上空闲 span 不足时会向 OS 扩展地址空间,这也会成为瞬时慢路径。
几个容易搞错的认知
理解三层结构后,很多关于 Go 内存分配的疑问会有答案,但仍有几个误区在工程中反复出现。
- mcache 是 goroutine 级别的吗?不是。它是 P 级别的私有缓存。goroutine 不会长期占用一个 P,所以同一个 goroutine 在不同时间分配内存,可能使用不同 P 的 mcache。
- mcentral 是一个对象池吗?不是。它管理 span,对象只是在 span 上切分出来的结果。
- 减少分配次数就能避开锁竞争吗?不一定。锁竞争发生在缓存缺货后的 refill 路径上;如果对象大小集中、GC 后同步 refill,竞争很容易被放大。
- GC 之后内存会立刻归还 OS 吗?不会。mheap 会留下空闲 span 以便后续使用,是否归还由 scavenger 和运行时内存限制共同决定。
真正排查时看什么
如果你在线上遇到内存分配相关的问题,建议从 runtime.MemStats 和 pprof 入手。MemStats 里几个字段可以快速定位状态:
| MemStats 字段 | 含义 | 关注点 |
|---|---|---|
| HeapAlloc | 已分配的堆对象字节数 | GC 后是否合理回落,是否持续增长 |
| HeapInuse | 正在使用中的 span 占用内存 | 比 HeapAlloc 高很多,说明 span 内存在碎片 |
| HeapIdle | 闲置 span 占用内存 | RSS 高但 HeapIdle 高,说明 runtime 保留内存备用 |
| HeapReleased | 已归还 OS 的内存量 | 长期偏低不一定是泄漏,可能是 mheap 复用策略 |
定位高频分配点,可以用 pprof 的 alloc_objects 采样:
go tool pprof -sample_index=alloc_objects http://localhost:6060/debug/pprof/heap
这个 profile 统计的是程序自开始累计分配对象数,能帮你找到谁在制造高频分配。如果热点集中在较小的 size class 上,且 CPU profile 里 runtime.lock 明显,就需要怀疑 mcentral 层排队。
落地时可以从这几件事做起:
- 用 sync.Pool 池化高频临时对象,减少 mcache 向 mcentral 补充 span 的频率。
- Go 1.20 以上的高吞吐服务,合理设置 GOMEMLIMIT,让 runtime 在接近限制前主动控制堆增长,避免被 OOM 推着走。
- 减少对象逃逸,让短周期对象留在栈上,从源头降低堆分配。
- 不要盲目调整 GOMAXPROCS。先确认 lock 等待是否随 P 数量增加而增长,再决定是否收敛并发。
不要把 HeapIdle 高直接当作内存泄漏。空闲 span 被 mheap 保留是一种设计选择,真正的异常是 HeapInuse 持续增长且 GC 后不回落。
归根到底,这套设计换来的是什么
回到开头的问题:Go runtime 为什么要把内存管理做得这么复杂?不是为了增加阅读难度,而是为了让“高频场景无锁、中频场景低竞争、低频场景集中管理”成为可能。
mcache 用空间换时间,让大多数小对象分配不碰锁;mcentral 用 size class 拆分并发现场,把竞争限制在特定大小段;mheap 掌握系统内存和 GC 元数据,在极端情况下兜底。理解这条链路后,你再看到 Go 服务的内存上涨、GC 频繁或 lock 等待过高时,就不会只在指标外围打转,而是能顺着分配路径一层层找到真正的原因。
原创文章,作者:fudengji,如若转载,请注明出处:https://fudengji.cn/article/552/