拆解 Go 内存分配器:从 mspan 结构到三级缓存的运作机制
在现代编程语言中,内存分配器的性能直接决定了整个运行时的吞吐量。Go 语言的内存分配器源自 Google 的 Thread-Caching Malloc(TCMalloc)算法,并针对 Go 的垃圾回收(GC)和并发模型(GMP)进行了深度的定制与优化。
Go 内存分配的核心思想极其明确:多级缓存、大小分级、以及动静分离。
本文将自底向上,深度拆解 Go 内存分配器中最核心的实体 mspan,以及它如何在 mcache、mcentral 和 mheap 组成的三级缓存架构中高效协同运作。
最小分配单元的载体:mspan
在 Go 运行时(Runtime)中,直接向系统申请的内存是以 Page(页,大小为 8KB) 为单位的。然而,程序中绝大多数对象都远小于 8KB。为了避免内存碎片并提高分配效率,Go 将多个连续的 Page 组合成一个更大的内存块,这就是 mspan(内存跨度)。
mspan 是 Go 内存管理的最基本单位。
+-------------------------------------------------------------+
| mspan |
| +--------+--------+--------+--------+ ... +--------+ |
| | Element| Element| Element| Element| | Element| |
| +--------+--------+--------+--------+ ... +--------+ |
+-------------------------------------------------------------+
|<----------- sizeclass 对应的大小 (如 8B, 16B...) --------->|
1. mspan 的内部结构
在源码 runtime/mheap.go 中,mspan 是一个双向链表结构:
type mspan struct {
next *mspan // 链表后驱
prev *mspan // 链表前驱
startAddr uintptr // 该 span 起始地址
npages uintptr // 包含的 Page 数量
manualFreeList gclinkptr // 手动释放的单向链表
freeindex uintptr // 标记下一次分配空闲对象的起始索引
nelems uintptr // 这个 span 中能容纳的对象总数
allocCache uint64 // 位图缓存,加速寻找空闲对象
allocBits *gcBits // 标记哪些对象已被分配的位图
gcmarkBits *gcBits // GC 标记阶段使用的位图
spanclass spanClass // 对应的跨度类(大小与类型标记)
// ... 省略部分统计和状态字段
}
2. 关键设计:Span Class 与对象分级
Go 默认定义了 67 种不同大小的级别(Size Class),从小到 8 字节,大到 32KB。
每个 mspan 在初始化时都会被赋予一个特定的 spanClass。
spanClass 是一个 uint8 类型的数值,其生成逻辑如下:
- 前 7 位:对应 1 到 67 种大小级别(Size Class)。其中 Class 0 比较特殊,表示大于 32KB 的大对象。
- 最低 1 位:
noscan标志位。1表示该 span 存放的是不包含指针的对象(如string、[]byte等),GC 时无需扫描;0表示存放包含指针的对象,GC 必须扫描。
通过将 scan 和 noscan 物理隔离在不同的 mspan 中,Go 大幅减少了垃圾回收时的 CPU 扫描开销。这也是 Go 相比普通 TCMalloc 的重要改进。
协同枢纽:三级缓存架构
为了在多核并发环境下实现高吞吐,Go 设计了三层内存屏障,层层分流分配请求,最大程度降低锁竞争。
+---------------------------------------------------+
| M (Goroutine 执行上下文) |
+---------------------------------------------------+
|
v
+---------------------------------------------------+
| mcache (线程本地,无锁) |
| [spanClass 0][spanClass 1] ... [spanClass 135] |
+---------------------------------------------------+
|
(若 mcache 不足,向 central 申请)
v
+---------------------------------------------------+
| mcentral (全局共享,分段锁) |
| [spanClass 0] [spanClass 1] ... [spanClass 135]
+---------------------------------------------------+
|
(若 mcentral 不足,向 heap 申请)
v
+---------------------------------------------------+
| mheap (全局堆内存,全局锁/Radix) |
| - arena / pageAlloc (页分配器) |
+---------------------------------------------------+
1. 第一级:mcache(线程本地缓存)
mcache 绑定在 GMP 模型中的 P(Processor) 上。
因为在同一时刻,一个 P 只能被一个 M(系统线程)绑定并运行 Goroutine,所以 Goroutine 在 mcache 上进行分配时是完全无锁的。
- 内部结构:
mcache持有一个大小为 136(即 68 * 2,包含 Class 0)的*mspan数组。 - 分配逻辑:当 Goroutine 申请内存时,计算出对象的大小对应的
spanClass,直接从mcache.alloc[spanClass]中找到对应的mspan,通过allocCache位图快速找到空闲位置并返回。
2. 第二级:mcentral(中央缓存)
当 mcache 中某个 spanClass 的 mspan 已经被占满,无法继续分配时,P 就会向全局的 mcentral 申请一个新的、拥有空闲空间的 mspan。
mcentral 收集了所有相同 spanClass 的 mspan。为了避免全局单锁导致的竞争,Go 设计了 136 个独立的 mcentral 结构体,每个 mcentral 只负责一种 spanClass。
type mcentral struct {
spanclass spanClass
partial [2]spanSet // 存在空闲空间的 mspan 集合
full [2]spanSet // 已经被分配满、或被 mcache 占用的 mspan 集合
}
- 分段存储:
partial和full数组大小为 2,这是为了配合 GC 标记清除阶段。一个对应已清理(swept)的,另一个对应未清理(unswept)的。 - 锁粒度:当 P 访问某个
mcentral时,只需要锁住该mcentral实例,不同spanClass之间的分配完全并发,互不干扰。
3. 第三级:mheap(全局堆)
当某个 mcentral 也没有多余的 mspan 时,它会向 mheap 申请。mheap 管理着整个 Go 进程的虚拟内存空间,其内部通过页分配器(Page Allocator,使用基数树 Radix Tree 实现)来检索和分配空闲的物理页。
如果 mheap 空间也不足,它会直接向操作系统(通过 mmap 或 VirtualAlloc)申请新的内存页,补充到全局管理中。
内存分配的全景链路
当我们在代码中执行 new(T) 或 make([]T, n) 时,Go 运行时会根据对象的大小,走不同的分配链路。
Go 将分配的对象分为三类:
- Tiny(微对象):大小 < 16B 且无指针。
- Small(小对象):大小在 16B 到 32KB 之间,或者 < 16B 但有指针。
- Large(大对象):大小 > 32KB。
对象分配请求
|
+---------------+---------------+
| < 16B 且无指针 | 16B ~ 32KB | > 32KB
v v v
[ 微对象分配 ] [ 小对象分配 ] [ 大对象分配 ]
| | |
合并放入 mcache 计算 spanClass 直接绕过 cache
的 tinySpan 从 mcache 获取 向 mheap 申请
| | |
| (若 mcache 满) |
| v |
+--------> 从 mcentral 补给 <---+
|
(若 mcentral 满)
v
从 mheap 申请
|
(若 mheap 满)
v
向操作系统 OS 申请
1. 微对象分配器(Tiny Allocator)
对于极小的对象(如一个小 int、一个小 struct),如果每个都分配一个 mspan 槽位,会产生大量的内部碎片。
Go 的 mcache 中有一个专门的 tiny 字段,它是一个 16 字节的缓冲区。
- 多个 Tiny 对象会被合并打包存放进同一个 16 字节的区域中。
- 只有当上一个 16 字节用完,或者无法对齐放下新对象时,才会分配新的 16 字节。
2. 小对象分配链路(Trace 追踪)
- 计算大小与 Class:根据对象字节数,计算出对应的
sizeclass和spanClass。 - 本地无锁尝试:访问当前 P 的
mcache.alloc[spanClass]。- 如果该
mspan还有空闲空间,通过allocCache快速定位并修改位图,返回地址。整个过程无锁,耗时纳秒级。
- 如果该
- 中央缓存补充:若
mcache中该mspan满了:mcache将这个满的mspan退还给mcentral的full队列。mcache向该spanClass对应的mcentral申请一个有空闲空间的mspan。mcentral锁住自身,从partial列表中取出一个可用的mspan交付给mcache。
- 全局堆分配:若
mcentral对应的partial也为空:mcentral向全局mheap申请特定数量的 Page。mheap使用页分配器进行高效检索,如果找到,则将其组装成一个新的mspan返回给mcentral。
- 系统调用:若
mheap彻底没有可用页,调用sysAlloc向 OS 申请新内存。
3. 大对象分配链路
大对象(> 32KB)的分配极其简单直接。因为大对象的分配频率通常较低,且不适合塞进固定尺寸的 mspan。
- 绕过
mcache和mcentral。 - 直接根据对象大小向上对齐到 Page 整数倍。
- 锁住
mheap,直接从页分配器中划出对应的 Page 集合,并创建一个定制的mspan返回。
深度总结:Go 内存分配器的高效秘诀
Go 内存分配器之所以能支撑高并发的 Goroutine 频繁创建与销毁,主要得益于以下三点:
零锁开销(Lock-Free Path):
将mcache绑定在 P 上,使 99% 的中小对象分配都在本地无锁链路上完成,极大地释放了多核 CPU 的并发性能。极低的 GC 扫描成本(Zero-Scan Separation):
通过spanClass的最低位,在物理内存层面将指针对象与非指针对象(noscan)隔离开。垃圾回收器在标记阶段,可以直接跳过整块noscan的mspan,极大缩短了 STW(Stop-The-World)的时间。精细的内存规整(Bitmaps & Radix Tree):
抛弃了传统的链表空闲块搜索,在mspan内部使用uint64位的allocCache位图,配合 CPU 的位运算指令(如CTZ,Count Trailing Zeros),实现 $O(1)$ 的空闲槽位查找;在mheap层面使用 Radix Tree 页面分配器,大幅提高了大块内存的检索与合并效率。