Go 内存分配器概览:从 TCMalloc 到三级缓存架构

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

相关推荐
得物技术1 小时前
企业级 MultiAgent 落地:Plan 模式与主子 Agent 协作|得物技术
java·程序员·架构
字节跳动的猫1 小时前
开源电商系统技术架构演进与选型方法论|2026 行业趋势深度解析
架构
vivo互联网技术2 小时前
软件不是从数据开始,而是从现实开始 | KDC 系列 01
设计模式·架构·领域驱动设计
2401_833269302 小时前
RecyclerView的缓存复用机制
缓存
程序员清风2 小时前
从单体应用到微服务:后端系统拆分的基本方法
微服务·架构·wpf
Johnston_man2 小时前
golang 安装 protoc、protoc-gen-go、protoc-gen-go-grpc、protoc-gen-grpc-gateway
后端·golang
ttwuai2 小时前
Go 后台接入单点登录后,JWT、菜单权限和接口 403 怎么验?
开发语言·后端·golang
Escalating_xu2 小时前
【Linux mmap 文件映射】从虚拟地址到页缓存:读写文件、匿名映射与极简 malloc
linux·缓存