本文从最高层的Arena到中层的Span/MSpan,再到线程级别的mcache逐层拆解Go的内存分配器。这是Go Runtime最核心的组件之一,与GC、goroutine调度器一同构成三大根基。
一、Go的Heap结构总览
Go的Heap是分层设计的:
bash
┌───────────────────────────────────┐
│ Arena │ ------ 模块化的巨大地址空间
├───────────────────────────────────┤
│ Span │ ------ 内存管理的基本单元 (8KB ~ 数MB)
├───────────────────────────────────┤
│ SpanClass (size class) │ ------ 67 种固定大小桶
├───────────────────────────────────┤
│ mcentral │ ------ 每种 size class 的共享池
├───────────────────────────────────┤
│ mcache │ ------ 每个 P 的本地缓存(无锁)
└───────────────────────────────────┘
最终分配路径是:
bash
new(T)
→ mallocgc()
→ mcache
→ mcentral
→ mheap
→ arena
二、Arena是Go Heap的地基
Arena 不是一次性全部分配,而是 按需向操作系统 mmap 一大片虚拟地址空间。Go会提前保留一块区域作为未来堆,这块空间被分为:
-
page:最小分配单位8KB
-
bitmap:标记GC信息
-
spans:page到Span的映射
Go的一个Page等于8KB
三、Span(MSpan):Heap的基本管理单元
Span 是内存分配的核心结构,它管理若干个 page:
bash
Span = NPAGES * PAGE_SIZE
//举例
NPAGES = 1 → 1 × 8KB = 8KB
NPAGES = 32 → 32 × 8KB = 256KB
Span 是 GC 的根本工作对象:GC 是按 Span 扫描,而不是按对象。
Span 维护的信息包括:
-
起始地址
-
有多少对象
-
空闲对象链表
-
mark 位图
-
是否为大对象
-
是否被 mcache 使用
四、Size Class (67种固定桶)
Go 将对象大小划分为 67 种 Size Class:
bash
8 bytes
16 bytes
32 bytes
...
32KB
每种 size class 都有一个 spanClass → 管理这一类对象的 span。
为什么固定桶?
-
降低碎片
-
允许 mcache 无锁快速分配
-
提高 locality
如果对象落入某个 size class,则:
bash
小对象:落入某个固定尺寸的 Span
大对象:独立一个 Span
五、小对象和大对象的分配逻辑
bash
对象 > 32KB → 大对象
32KB 以内 → 小对象
5.1 小对象:走 size class + mcache
流程:
-
- mallocgc 计算对象尺寸 → size class
-
- 尝试从当前 P 的 mcache 中分配
-
- 如果该 mcache 的 span 用完 → 向 mcentral 申请新的 span
-
- mcentral 再从 mheap 获取新的 span
特点:
-
极快(无锁)
-
分配只在 mcache 中操作 O(1)
-
避免争用
六、大对象(>32KB)直接mheap分配
大对象不会走 size class,也不会进入 mcache。
例:
-
一个 200KB 的切片 buffer → 申请 200KB 大小的 span
-
一个 1MB 的结构 → 独占一个 span
特点:
-
更容易导致碎片
-
GC 会更慢扫描这个 span
-
对象越大,逃逸到堆上的代价越大
七、mcache,每个P的本地分配缓存
mcache 提供每种 size class 的一个 已部分使用的 Span。
每个调度器 P 都有独立 mcache:
bash
P0 → mcache0
P1 → mcache1
Pn → mcachen
所以小对象分配:
-
无锁
-
只需要在 span 中移动一个指针
-
极快,大约 2~3ns
当 mcache 用完一个 span:
-
从 mcentral 拉取新的 span
-
空 span 返回 mcentral
八、mcentral,所有P的共享Span池
mcentral:
-
每种 size class 有一个
-
管理半空 / 半满 span 的双链表
-
使用锁(但访问频率低)
mcentral 从哪里拿 span?
-
→ mheap
-
mheap 不够时
-
→ 从 arena 申请 page
九、mheap,真正从操作系统申请内存的地方
mheap 是 Go 的最终分配器:
-
当 mcentral 需要新 span → mheap.alloc
-
当 GC 需要扩堆 → mheap.grow
-
当需要缩堆 → mheap.freeSpan
mheap 的行为:
-
使用 mmap() 从 OS 申请大块内存
-
管理 arena 映射
-
维护 free list
十、mallocgc的内部流程(敲黑板)
bash
//伪代码
func mallocgc(size uintptr) {
if small object {
span := mcache[sizeClass]
obj := span.alloc()
return obj
}
// if large
span := mheap.alloc(size)
return span.base()
}
真实 mallocgc 会更复杂,包括:
-
write barrier
-
pointer 扫描
-
zeroing new object
-
调整 heap goal
-
GC assist
十一、常见误区与真相
误区1:Go 每次分配都会加锁 → 错
- 小对象分配绝大部分来自 mcache(无锁)
误区2:Go heap 会碎片化 → 部分正确
-
小对象不容易碎,固定 size class
-
大对象更容易碎片化
误区3:对象逃逸代价不大 → 错
-
堆上对象会增加 GC 负担
-
大对象会直接占 span
误区4:大切片重分配很便宜 → 错
-
可能多次大对象重新分配
-
可能触发 GC assist
十二、如何写出更高效的Go代码
避免频繁创建大对象
bash
//256KB 缓冲区
//大 struct
//巨大 slice
解决办法:使用 sync.Pool
复用对象 ------ 能走栈不要走堆
bash
buf := make([]byte, 32*1024) // 刚好 32KB,依然是"小对象"
做好逃逸分析
避免生成大量含指针的小对象
bash
指针对象会给GC巨大的压力
不要频繁创建 map / slice
bash
会分配到堆上
会触发rehash和resize
预估容量是关键
Go 内存分配器就像一个巨大仓储物流中心:
小物品用标准货架(small object → size class),大物品直接分配专仓(large object → big span)。
工人(mcache)本地拿货无需排队,大型操作才去中央仓房(mcentral / mheap)。
*源码地址*
1、公众号"Codee君"回复"每日一Go"获取源码
2、pan.baidu.com/s/1B6pgLWfS...
如果您喜欢这篇文章,请您(点赞、分享、亮爱心),万分感谢!