Go 内存分配器底层源码走读:mcache、mcentral、mheap 与页分配器
·
Go 内存分配器底层源码走读:mcache、mcentral、mheap 与页分配器

在现代高并发后端服务中,内存分配的速度与碎片控制直接决定了系统的并发吞吐上限。
如果每个 Goroutine 分配一个小对象都要向操作系统发起一次内核态的 malloc 或 brk/mmap 系统调用,不仅会有严重的内核上下文切换开销,多线程并发争抢全局锁还会让性能瞬间瘫痪。
Go 语言在用户态实现了一套极其精密的 TCMalloc(Thread-Caching Malloc)衍生内存分配器。
在 Go 运行时内部:
- 内存分配被划分为 微对象($<16\text{B}$)、小对象($16\text{B}\sim 32\text{KB}$)与大对象($>32\text{KB}$) 三级流水线;
- 架构上构建了
mcache(P 本地无锁缓存) $\rightarrow$mcentral(全局跨 P 共享中心) $\rightarrow$mheap(全局大堆与页分配器) 的三级物理分层金字塔。
今天我们深入 Go 官方运行时源码(src/runtime/malloc.go, mcache.go, mcentral.go, mheap.go),深度走读这套工业级内存分配器的运转核心。
一、Go 内存分配器的三级金字塔架构全景图
flowchart TD
G[Goroutine 发起 new / make 内存分配] --> SizeCheck{对象大小分类}
SizeCheck -->|微对象 < 16B 且无指针| TinyAlloc[mcache.tiny 微对象无锁合并分配]
SizeCheck -->|小对象 16B ~ 32KB| SmallAlloc[查找 67 种特定 SpanClass]
SizeCheck -->|大对象 > 32KB| LargeAlloc[直接穿透至 mheap 页分配器分配]
SmallAlloc --> P_Local[1. 访问当前 P 绑定的 mcache (完全无锁, 纳秒级极速!)]
P_Local -- mcache 中该规格 Span 空闲链表用尽 --> Central[2. 加锁访问全局 mcentral 批量补充 Span]
Central -- mcentral 亦无空闲 Span --> Heap[3. 向全局 mheap 申请空闲物理内存页 (PageAlloc)]
Heap -- mheap 物理内存不足 --> OS[4. 调用 mmap 向操作系统申请虚拟内存空间]
二、核心三大组件物理数据结构剖析
1. mcache(绑定到每个 P 的本地私有缓存)
打开 src/runtime/mcache.go:
- 每个逻辑处理器
P独占一个mcache; - 内部维护了一个大小为
numSpanClasses = 136(67 种不同大小规格 $\times$ 2,包含有指针与无指针标记)的alloc [numSpanClasses]*mspan数组; - 核心物理收益:当前 P 上的所有 Goroutine 在申请小对象时,直接从本地
mcache取出,完全不需要加任何互斥锁,单次分配仅耗费 2~5 个 CPU 周期!
2. mcentral(全局跨 P 的中枢分发池)
打开 src/runtime/mcentral.go:
- 全局共有 136 个
mcentral实例,每种规格对应一个; - 内部维护了两个
mspan链表:partial:包含尚有空闲对象可供分配的 Span 列表;full:所有对象均已被占满的 Span 列表;
- 当某个 P 的
mcache消耗完毕时,会向对应规格的mcentral加锁请求补充一批mspan。
3. mheap 与现代 PageAlloc(基数树页分配器)
打开 src/runtime/mheap.go:
- 代表 Go 运行时的整个堆空间,管理着连续的 8KB 物理页(Page);
- Go 1.14+ 引入了基于基数树(Radix Tree)的 PageAlloc 页分配器:使用高效的位图与多级基数树,将大内存块的空闲查找与合并(Buddy Allocation)复杂度从 $O(N)$ 压低至严格的 $O(\log N)$!
三、微对象分配器(Tiny Allocator)的内存极致压榨
对于小于 16 字节且不包含指针的极小变量(如 int8、小字符串头、空结构体):
Go 不会为每个小变量单独分配一个完整的内存块,而是采用 tiny 块合并机制:
mcache中维护了一个 16 字节的tiny内存块游标;- 多个小对象(如两个 4 字节整数)会被紧凑地拼装塞进同一个 16 字节的物理槽位中;
- 这一机制直接消除了大量小对象对堆内存造成的严重碎片化浪费!
// src/runtime/malloc.go 片段
// 微对象分配快速路径
if size <= maxSmallSize {
if noscan && size < maxTinySize {
// 进入 Tiny 内存合并无锁快速通道
off := c.tinyoffset
// 内存对齐计算并紧凑打包...
}
}
四、对象大小与 67 种 SizeClass 分级映射表
Go 运行时将 0 到 32KB 的小对象精细划分为 67 个固定级别(Size Classes):
| Class ID | 单对象大小 (Bytes) | 单个 mspan 包含的页数 (8KB/页) | 单个 mspan 容纳的对象总数 | 最大内部碎片率 |
|---|---|---|---|---|
| 1 | 8 字节 | 1 页 (8KB) | 1024 个对象 | 0.00% |
| 2 | 16 字节 | 1 页 | 512 个对象 | 0.00% |
| 3 | 24 字节 | 1 页 | 341 个对象 | 0.44% |
| ... | ... | ... | ... | ... |
| 66 | 28,672 字节 | 7 页 (56KB) | 2 个对象 | 1.83% |
| 67 | 32,768 字节 | 4 页 (32KB) | 1 个对象 | 0.00% |
- 为什么做这种分级? 无论申请多大尺寸的对象,都会向上取整吸附到最近的 SizeClass 上,将整个堆内存的内部碎片率死死控制在 12.5% 以下!
五、生产级高性能开发启示
- 避免对象跨越 32KB 边界:32KB 以内的对象享受
mcache本地无锁分配的极速性能;一旦超过 32KB(如make([]byte, 32769)),必须直接向全局mheap加锁申请页,分配开销激增数十倍! - 善用
sync.Pool避免频繁穿透到mcentral:虽然mcache很高效,但在高并发突发涌入时,本地 Span 耗尽仍需去mcentral加锁竞争。合理复用缓冲区对象可让内存分配始终保持在栈与本地 CPU 缓存中; - 结构体属性按字节对齐排列:通过紧凑排列结构体字段,减少 Padding 填充,让对象体积落入更小的 SizeClass 规格内。
把 Go 内存分配器的三级分层与页分配器底层摸透,在高吞吐、极低延迟的架构设计中,你才能真正拥有把硬件性能压榨到极致的技术底气。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐
所有评论(0)