Go 内存分配机制全解析:mspan、mcache、mcentral 与 mheap 三级缓存设计
引言
「Go 的内存分配器是怎么设计的?」「mspan 和 mcache 是什么关系?」「为什么说小对象分配是无锁的?」—— 这是上一期 《Go GC 垃圾回收机制全解析》 的姊妹题:GC 负责回收内存,分配器负责发放内存,两者共享同一套数据结构。再往前追溯,第 12 期 《逃逸分析》 决定了对象要不要上堆——三篇文章连起来,才是 Go 内存管理的完整拼图。
一、设计思想:来自 TCMalloc 的多级缓存
传统内存分配器(如 glibc 的 malloc)最大的痛点是锁竞争:多线程同时分配内存,都要抢一把全局锁,线程一多性能直接崩。Google 的 TCMalloc 给出的解法是多级缓存——给每个线程一块本地缓存,小对象在本地无锁分配,本地不够才去中央缓存批量取,中央缓存不够才向操作系统要。
Go 的内存分配器正是脱胎于 TCMalloc,但做了一处关键改造:TCMalloc 的 ThreadCache 挂在**线程(M)**上,Go 把它挂在了 P 上。因为 goroutine 会在 M 之间调度迁移,缓存挂在 P 上才能既无锁又稳定(这也回答了为什么 Go 调度模型是 GMP 而不是 GM,见第 11 期 《GMP 模型》)。
二、核心概念:page、mspan 与 size class
三个基础单位必须先分清:
| 概念 | 大小 | 作用 |
|---|---|---|
| object | 按 class 而定(8B、16B、24B……32KB) | 一次分配返回给用户的内存块 |
| page | 8 KB | 堆管理的最小单位,向操作系统申请内存也以页计 |
| mspan | N 个连续 page | 内存管理的基本单元,内部切成 N 个等大的 slot |
Go 把堆对象按大小分为三档(经典面试考点):
- 微对象:小于 16 字节。走 tiny allocator:多个微对象被打包塞进同一个 16 字节的块里,大幅减少碎片;
- 小对象:16 字节 ~ 32 KB。按 size class(约 67 个规格等级)归类,从对应规格的 mspan 里取一个 slot;
- 大对象:大于 32 KB。跳过 mcache 和 mcentral,直接向 mheap 按页申请。
另外每个 size class 还会按「是否包含指针」拆成两种 spanClass(noscan 标记)。不含指针的对象 GC 扫描时直接跳过,这就是第 30 期讲的 GC 扫描能那么快的原因之一。
三、三级缓存:mcache → mcentral → mheap
1. mcache:P 的私有缓存,无锁分配
每个 P 持有一个 mcache,内部是按 spanClass 索引的 mspan 数组(另加一个 tiny 分配器)。goroutine 分配小对象时:定位 spanClass → 取出本地 mspan → 通过 freeindex/allocCache 找到一个空闲 slot → 返回。全程无锁,因为同一时刻一个 P 上只运行一个 goroutine,天然互斥。
2. mcentral:按规格加锁的中心缓存
每个 size class 对应一个 mcentral,内部维护 partial(有空闲 slot)和 full(slot 用尽)两类 span 集合。mcache 缺 span 时从这里取,需要加锁——但锁粒度只有单个规格的 mcentral,几十个规格各有一把锁,竞争被天然分散,而不是全局一把大锁。
3. mheap:全局堆,对操作系统负责
mcentral 也没有 span 时,向 mheap 申请。mheap 管理所有 page:用基数树(radix tree)索引快速查找连续空闲页,组装成新的 mspan 交给 mcentral;连 mheap 都不够时,才通过 mmap 等机制向操作系统申请新的 arena(64MB 一块)。大对象分配也直接在这里按页对齐进行——注意按页对齐意味着一个 33KB 的对象实际占 5 页(40KB),有碎片代价。
四、一次小对象分配的完整旅程
你写下 obj := &Config{},编译器先做逃逸分析;确定要分配堆内存后,进入 runtime.mallocgc:
- 按对象大小算出 size class 和 spanClass;
- 进当前 P 的 mcache,对应槽位还有空闲 slot → 直接分配,结束(绝大多数分配止步于此);
- mspan 满了 → 向对应 mcentral 申请一个新 span 补上,再分配;
- mcentral 也空了 → 向 mheap 要空闲页组装新 span;
- mheap 没页了 → 向操作系统申请新的 arena。
一条清晰的降级路径:本地无锁 → 中心细粒度锁 → 全局锁 → 系统调用。层级越往下越慢,但命中的比例也指数级下降。
五、与 GC 的联动:bits 与 sweep
mspan 内部维护两张位图:allocBits 记录哪些 slot 已分配,gcmarkBits 记录 GC 标记阶段哪些对象存活。GC 完成后两张图交换,原本存活的位变成新的 allocBits,未标记的 slot 被「清扫(sweep)」回收——清除阶段也因此可以与用户代码并发执行。若某个 span 的 slot 被全部回收,它还会被归还 mheap,极端情况下内存可以还给操作系统。
六、用代码验证:观察内存分配统计
以下程序用 runtime.ReadMemStats 观察分配行为(均为稳定 API):
package main
import (
"fmt"
"runtime"
)
func main() {
runtime.GOMAXPROCS(4) // 4 个 P → 4 套 mcache
var m runtime.MemStats
runtime.ReadMemStats(&m)
fmt.Printf("分配前:已分配 %d KB,累计分配 %d KB\n", m.Alloc/1024, m.TotalAlloc/1024)
// 分配 10 万个 24 字节的小对象(同一 size class)
objs := make([]*byte, 0, 100000)
for i := 0; i < 100000; i++ {
b := make([]byte, 24)
objs = append(objs, &b[0])
}
runtime.ReadMemStats(&m)
fmt.Printf("分配后:已分配 %d KB,累计分配 %d KB,GC 次数 %d\n",
m.Alloc/1024, m.TotalAlloc/1024, m.NumGC)
}
线上排查时更常用的是 GODEBUG=allocfreetrace=1(追踪每次分配)和 go tool pprof 的 alloc_space 采样——定位「哪个函数在疯狂制造垃圾」,再结合第 25 期 《sync.Pool》 做对象复用。
七、面试速查清单
- 设计思想:脱胎 TCMalloc,多级缓存消灭全局锁;
- 三级结构:mcache(P 本地,无锁)→ mcentral(按规格加锁)→ mheap(全局,对 OS);
- 三档对象:微 <16B(tiny 打包)、小 16B~32KB(size class)、大 >32KB(直达 mheap);
- page = 8KB,mspan = 连续 page 的集合,spanClass = size class × 是否含指针;
- 分配路径是逐级降级,绝大多数分配在 mcache 无锁完成;
- 为什么缓存挂在 P 而不是 M:goroutine 会跨 M 调度,P 是稳定的执行上下文。
结语
回答这道题的标准骨架:Go 内存分配器参考 TCMalloc,采用 mcache → mcentral → mheap 三级缓存结构;page(8KB)组成 mspan,按 size class 切槽,小对象在 P 私有的 mcache 里无锁分配,不足再逐级向下申请,大对象直接找 mheap。再补一句 tiny 分配器、noscan spanClass 和与 GC 的 bits 交换机制,就是超出平均水平的答案。
本文是「Go语言面试题」系列第 31 篇。 相关阅读:Go GC 垃圾回收机制全解析 · Go 逃逸分析:对象什么时候会逃到堆上 · Go 调度器 GMP 模型详解 · sync.Pool 深度解析 · 面试题系列总目录
- 《Go 程序设计语言》(Go 语言圣经,机械工业出版社) —— Go 官方团队出品,公认入门第一书
- 《Go 语言实战》(异步图书,人民邮电出版社) —— 实战导向,面试前巩固工程手感
- 《Go 语言编程之旅:一起用 Go 做项目》(博文视点,电子工业出版社) —— 项目驱动入门,从零写出完整应用
- Keychron K1Max 矮轴机械键盘(Mac/Win 双系统,QMK/VIA 开源改键) —— 轻薄矮轴 + 开源固件,程序员桌面常客
- 罗技 MX Master 3S 无线鼠标(Flow 跨屏,静音电磁滚轮) —— 人体工学设计,写代码一整天手不酸
以上链接含京东联盟推广代码;通过链接购买本站可能获得少量佣金,不影响你的购买价格。