Go 内存分配器概览:从 TCMalloc 到三级缓存架构
引言
内存分配是编程语言运行时最核心的基础设施之一。Go 的内存分配器继承自 Google 的 TCMalloc(Thread-Caching Malloc),采用三级缓存架构来平衡分配速度与内存碎片。理解这套机制,对排查内存泄漏、优化高频对象分配、读懂 pprof heap 图都有直接帮助。
本文先梳理架构设计,再通过 4 组实验观察不同场景下的分配行为。
一、核心设计思想
1.1 为什么不用 libc malloc?
| 维度 | libc malloc | Go 自研分配器 |
|---|---|---|
| 锁竞争 | 全局锁 / 细粒度锁 | P 本地缓存无锁 |
| GC 集成 | 无法移动对象 | 精确标记、并行清扫 |
| 性能 | 通用设计 | 针对 Go 的分配模式优化 |
| 堆栈统一 | 栈逃逸需 malloc | 逃逸分析 + 栈自动扩缩容 |
Go 的自研分配器与调度器、GC 深度耦合,这是用 libc 做不到的。
1.2 三级缓存架构
Goroutine → mcache (P 本地,无锁) → mcentral (全局,按 Size Class) → mheap (全局,管理所有 Span)
| 层级 | 作用域 | 锁策略 | 管理对象 |
|---|---|---|---|
| mcache | 每个 P | 无锁 | 各 Size Class 的空闲对象链表 |
| mcentral | 全局每 Size Class | 细粒度锁 | 空闲 / 非空闲 Span 链表 |
| mheap | 全局 | 大锁(已优化为索引结构) | 所有 Span、arena 区域 |
Span:一段连续的 8KB 内存页(page),是分配的基本单位。一个 Span 被切割成相同大小的对象(object),属于某个 Size Class。
二、Span 与 Size Class
2.1 Size Class 设计
Go 1.22 中定义了 67 个 Size Class,覆盖 ≤32KB 的小对象:
- Size Class 0 为占位,实际从 1 开始
- 8B, 16B, 24B ... 每个 class 对应固定的 object size
- 每个 class 还记录 每个 Span 包含多少 object 、需要多少 page
例如:Size Class 1 对应 8B 对象,1 个 page(8KB) 可容纳 1024 个 object。
2.2 对象大小分类
| 类别 | 大小范围 | 分配路径 |
|---|---|---|
| Tiny | < 16B | Tiny allocator,合并微对象 |
| Small | 16B ~ 32KB | 按 Size Class 从 mcache/mcentral/mheap 分配 |
| Large | > 32KB | 直接从 mheap 分配,不经过 mcache/mcentral |
2.3 代码实验:观察 Size Class 与分配行为
以下代码申请不同大小的对象,通过 runtime.ReadMemStats 观察堆变化:
go
package main
import (
"fmt"
"runtime"
"unsafe"
)
func printMem(label string) {
var m runtime.MemStats
runtime.GC()
runtime.ReadMemStats(&m)
fmt.Printf("[%s] HeapAlloc=%d KB, HeapObjects=%d\\n",
label, m.HeapAlloc/1024, m.HeapObjects)
}
func main() {
printMem("init")
// 实验1:8B tiny 对象
_ = make([]byte, 8)
printMem("after 8B")
// 实验2:512B small 对象
_ = make([]byte, 512)
printMem("after 512B")
// 实验3:64KB large 对象(>32KB)
_ = make([]byte, 64*1024)
printMem("after 64KB")
// 实验4:查看切片 header 大小
var s []int
fmt.Printf("slice header size=%d\\n", unsafe.Sizeof(s))
}
输出解读:
- 8B 对象可能走 Tiny allocator,实际分配可能合并到更小的 overhead
- 512B 会命中某个 Size Class,mcache 直接分配无锁
- 64KB 超过 32KB 阈值,直接从 mheap 申请 8 个 page
三、Tiny Allocator:微对象优化
3.1 原理
对于 < 16B 且无指针的对象(如小字符串、数字切片),Go 使用 Tiny allocator:
- 从 Size Class 2(16B)的对象中取一个 slot
- 将多个 tiny 对象打包到同一个 16B slot 中
- 节省约 30-50% 的微对象分配开销
3.2 实验:对比有无指针的 tiny 对象
go
package main
import (
"fmt"
"runtime"
)
func allocTinyNoPointer(n int) {
for i := 0; i < n; i++ {
_ = make([]byte, 8) // 无指针的 tiny 对象
}
}
func allocTinyWithPointer(n int) []*int {
res := make([]*int, n)
for i := 0; i < n; i++ {
v := i
res[i] = &v // 有指针,不走 tiny allocator
}
return res
}
func printMemDiff(label string, before, after runtime.MemStats) {
fmt.Printf("[%s] HeapAlloc +%d KB, HeapObjects +%d\\n",
label, (after.HeapAlloc-before.HeapAlloc)/1024,
after.HeapObjects-before.HeapObjects)
}
func main() {
var before, after runtime.MemStats
const N = 100000
// 无指针 tiny 对象
runtime.GC()
runtime.ReadMemStats(&before)
allocTinyNoPointer(N)
runtime.ReadMemStats(&after)
printMemDiff("tiny no-pointer x100k", before, after)
// 有指针对象(无法 tiny 分配)
runtime.GC()
runtime.ReadMemStats(&before)
_ = allocTinyWithPointer(N)
runtime.ReadMemStats(&after)
printMemDiff("tiny with-pointer x100k", before, after)
}
预期结论:
- 无指针 tiny 对象的 HeapObjects 增长远小于 N(因为多个对象合并到 16B slot)
- 有指针对象的 HeapObjects 增长接近 N,每个
*int独立分配
四、sync.Pool:应用层的"第四级缓存"
4.1 为什么需要 sync.Pool?
三级缓存解决的是运行时层面的对象分配,而 sync.Pool 是应用层复用对象的利器:
- 减少 GC 压力(对象复用,减少新分配)
- 无锁设计(每个 P 一个本地池)
- GC 时自动清空(防止内存膨胀)
4.2 实验:对比直接分配 vs sync.Pool 复用
go
package main
import (
"fmt"
"runtime"
"sync"
"time"
)
type Buffer struct {
Data [1024]byte
}
func main() {
const N = 100000
// 直接分配
start := time.Now()
var before, after runtime.MemStats
runtime.GC()
runtime.ReadMemStats(&before)
for i := 0; i < N; i++ {
_ = &Buffer{}
}
runtime.ReadMemStats(&after)
fmt.Printf("直接分配: %v, HeapAlloc +%d KB, Objects +%d\\n",
time.Since(start), (after.HeapAlloc-before.HeapAlloc)/1024,
after.HeapObjects-before.HeapObjects)
// sync.Pool 复用
pool := &sync.Pool{
New: func() interface{} { return &Buffer{} },
}
// 先预热
for i := 0; i < 1000; i++ {
pool.Put(&Buffer{})
}
start = time.Now()
runtime.GC()
runtime.ReadMemStats(&before)
for i := 0; i < N; i++ {
b := pool.Get().(*Buffer)
_ = b.Data[0]
pool.Put(b)
}
runtime.ReadMemStats(&after)
fmt.Printf("sync.Pool: %v, HeapAlloc +%d KB, Objects +%d\\n",
time.Since(start), (after.HeapAlloc-before.HeapAlloc)/1024,
after.HeapObjects-before.HeapObjects)
}
预期结论:
- sync.Pool 复用路径的 HeapAlloc 增量显著低于直接分配
- 但注意:GC 会清空 Pool,长期存活的对象需其他方案
五、GC 与内存回收观察
5.1 实验:触发 GC 观察堆内存回落
go
package main
import (
"fmt"
"runtime"
)
func main() {
var m runtime.MemStats
// 大量分配
data := make([][]byte, 1000)
for i := range data {
data[i] = make([]byte, 1024*1024) // 1MB each
}
runtime.ReadMemStats(&m)
fmt.Printf("分配后: HeapAlloc=%d MB\\n", m.HeapAlloc/1024/1024)
// 释放引用
data = nil
runtime.GC()
runtime.ReadMemStats(&m)
fmt.Printf("GC后: HeapAlloc=%d MB, GCCPUFraction=%.4f%%\\n",
m.HeapAlloc/1024/1024, m.GCCPUFraction*100)
}
关键观察:
HeapAlloc在 GC 后显著下降(但不一定完全归零,因 heap 不立即归还 OS)GCCPUFraction反映 GC 的 CPU 开销占比
六、小结表
| 知识点 | 核心结论 |
|---|---|
| TCMalloc 三级缓存 | mcache(P 本地无锁)→ mcentral(全局按 Size Class)→ mheap(全局 Span 管理) |
| Span | 连续 8KB page 的集合,按 Size Class 切分为等大的 object |
| Size Class | 67 个 class,覆盖 ≤32KB;>32KB 为大对象直接走 mheap |
| Tiny Allocator | <16B 无指针对象合并到 16B slot,减少分配次数 |
| sync.Pool | 应用层对象池,无锁、GC 自动清空,适合高频临时对象复用 |
| 大对象阈值 | 32KB 是分界线,超过后不走 mcache/mcentral |