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

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

引言

内存分配是编程语言运行时最核心的基础设施之一。Go 的内存分配器继承自 Google 的 TCMalloc(Thread-Caching Malloc),采用三级缓存架构来平衡分配速度与内存碎片。理解这套机制,对排查内存泄漏、优化高频对象分配、读懂 pprof heap 图都有直接帮助。

本文先梳理架构设计,再通过 4 组实验观察不同场景下的分配行为。


一、核心设计思想

1.1 为什么不用 libc malloc?

维度libc mallocGo 自研分配器
锁竞争全局锁 / 细粒度锁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< 16BTiny allocator,合并微对象
Small16B ~ 32KB按 Size Class 从 mcache/mcentral/mheap 分配
Large> 32KB直接从 mheap 分配,不经过 mcache/mcentral

2.3 代码实验:观察 Size Class 与分配行为

以下代码申请不同大小的对象,通过 runtime.ReadMemStats 观察堆变化:

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 对象

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 复用

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 观察堆内存回落

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 Class67 个 class,覆盖 ≤32KB;>32KB 为大对象直接走 mheap
Tiny Allocator<16B 无指针对象合并到 16B slot,减少分配次数
sync.Pool应用层对象池,无锁、GC 自动清空,适合高频临时对象复用
大对象阈值32KB 是分界线,超过后不走 mcache/mcentral

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值