Go 专家(08):Go 内存分配器 —— mcache、mcentral、mheap
更新时间:2026-09-01。本文是
languages/go/expert/专家层第 08 篇,接 GMP 调度深入。Go 的内存分配器基于 tcmalloc 设计,多级缓存、无锁分配、按大小分类。理解 Go 的内存分配器,才能写出 GC 友好、分配高效的代码。
本文要回答的问题
- Go 内存分配器的三级结构是什么?为什么是多级缓存?
- mcache 为什么是无锁分配?怎么保证线程安全?
- 对象大小怎么分类?tiny、small、large 分别怎么分配?
- 内存分配触发的 GC 时机是什么?
一、三级分配结构
Go 内存分配器有三层,从快到慢:
goroutine 分配
↓
mcache(P 级别的缓存,无锁) ← 最快,大部分分配在这里
↓
mcentral(全局的中心缓存,需要锁) ← 次快,mcache 不够时从这拿
↓
mheap(堆,页分配,需要锁) ← 最慢,向 OS 申请内存
↓
OS(mmap / VirtualAlloc)二、mcache:P 的本地缓存
每个 P 都有一个 mcache,无锁分配:
type mcache struct {
alloc [numSpanClasses]*mspan // 每个 size class 的 span
}- 每个 P 的 mcache 是独立的,所以分配不需要加锁
- mcache 里按大小分类缓存了多个 mspan(内存块)
- mspan 是连续的页,切成固定大小的对象
分配流程:
- 从 mcache 里找对应 size class 的 mspan
- 如果 mspan 有空闲对象,直接返回
- 如果 mspan 满了,从 mcentral 取新的 mspan
三、size classes:对象大小分类
Go 把对象大小分为约 70 个 size class:
// 常见的 size class(示例)
// 1: 0 bytes
// 2: 8 bytes
// 3: 16 bytes
// 4: 32 bytes
// ...
// 67: 32768 bytes分配时,对象大小会被向上取整到最近的 size class:
// 分配 23 字节 → 取 32 字节的 size class
// 分配 100 字节 → 取 112 字节的 size class
// 分配 32769 字节 → 直接大对象分配,走 mheap四、小对象(tiny)分配
对于非常小的对象(< 16 字节),Go 有特殊优化——tiny allocator:
// 多个 tiny 对象合并到一个 16 字节块里
// 比如:bool、int8、byte 等type mcache struct {
tiny uintptr // 指向当前 tiny 块的指针
tinyoffset uintptr // tiny 块里的偏移
}好处: 减少内存碎片,减少 GC 扫描的对象数量。比如 4 个 byte 变量可以存到同一个 16 字节块里,只需要 1 个对象,GC 扫描也少。
五、mcentral:中心缓存
type mcentral struct {
partial [2]spanSet // 有空闲对象的 span
full [2]spanSet // 没有空闲对象的 span
spanclass spanClass
}- mcentral 是全局的,每个 size class 一个
- mcache 从 mcentral 取 mspan,用完放回
- 操作 mcentral 需要加锁
当 mcache 的 mspan 满了,从 mcentral 取新的;当 mcache 的 mspan 空闲了,还给 mcentral(不还到 mheap,减少 OS 交互)。
六、mheap:堆
type mheap struct {
arenas [1<<arenaL1Bits]*[1<<arenaL2Bits]*heapArena
central [numSpanClasses]mcentral
free treap // 空闲页
}- 从 OS 申请内存(mmap),按 arena 管理(64 MB 一块)
- 大对象(> 32 KB)直接从 mheap 分配,不走 mcache
- GC 标记后,空闲的内存返回给 mheap
七、GC 与内存回收
GC 触发后:
- 标记所有可达对象
- 不可达的对象被标记为可回收
- 可回收的 mspan 被放回 mcentral 或 mheap
- 内存还给 OS 或留在 mheap 备用
GC 后,内存不一定还给 OS: 如果 mheap 的页空闲超过 5 分钟,会被还给 OS。大部分分配的内存会留在 mheap 里,下次分配直接用,不用再向 OS 申请。
八、优化建议
// 1. 预分配,避免多次扩容
s := make([]int, 0, 1000) // 一次分配,不用多次扩容
// 2. 对象复用,减少分配
var bufPool = sync.Pool{
New: func() any { return make([]byte, 1024) },
}
// 3. 避免大对象频繁分配
// 大对象直接走 mheap,分配慢,GC 压力大
// 4. 用 struct{} 做 set
set := make(map[int]struct{}) // struct{} 不占内存九、常见坑对照
| 坑 | 现象 | 对策 |
|---|---|---|
| 频繁分配小对象 | GC 压力大,STW 时间长 | 用 sync.Pool 复用 |
| 不预分配 slice | 多次扩容,多次分配 | 预分配容量 |
| 大对象频繁分配 | mheap 压力大 | 优化结构体大小,减少大对象 |
| 不释放全局变量 | 内存一直被引用,GC 无法回收 | 及时置 nil 或重新赋值 |
相关与延伸
下一篇:GC 三色标记深入 —— 并发标记、写屏障、调优;Go 内存分配器基于 tcmalloc,和 C 的 malloc 对比,见 C malloc 底层。
一句话总结
Go 内存分配器:三级结构——mcache(P 本地无锁)→ mcentral(全局中心缓存,加锁)→ mheap(页分配,向 OS 申请);大小对象按 70 种 size class 分类,小对象向上取整;tiny allocator 合并 < 16 字节的对象,减少碎片和 GC 压力;大对象(> 32 KB)直接走 mheap;GC 回收后内存不一定还给 OS,留在 mheap 备用;优化建议:预分配、sync.Pool 复用、减少大对象。