Go 内存分配机制全解析:mspan、mcache、mcentral 与 mheap 三级缓存设计


引言

「Go 的内存分配器是怎么设计的?」「mspan 和 mcache 是什么关系?」「为什么说小对象分配是无锁的?」—— 这是上一期 《Go GC 垃圾回收机制全解析》 的姊妹题:GC 负责回收内存,分配器负责发放内存,两者共享同一套数据结构。再往前追溯,第 12 期 《逃逸分析》 决定了对象要不要上堆——三篇文章连起来,才是 Go 内存管理的完整拼图。

一、设计思想:来自 TCMalloc 的多级缓存

传统内存分配器(如 glibc 的 malloc)最大的痛点是锁竞争:多线程同时分配内存,都要抢一把全局锁,线程一多性能直接崩。Google 的 TCMalloc 给出的解法是多级缓存——给每个线程一块本地缓存,小对象在本地无锁分配,本地不够才去中央缓存批量取,中央缓存不够才向操作系统要。

Go 的内存分配器正是脱胎于 TCMalloc,但做了一处关键改造:TCMalloc 的 ThreadCache 挂在**线程(M)**上,Go 把它挂在了 P 上。因为 goroutine 会在 M 之间调度迁移,缓存挂在 P 上才能既无锁又稳定(这也回答了为什么 Go 调度模型是 GMP 而不是 GM,见第 11 期 《GMP 模型》)。

二、核心概念:page、mspan 与 size class

三个基础单位必须先分清:

概念 大小 作用
object 按 class 而定(8B、16B、24B……32KB) 一次分配返回给用户的内存块
page 8 KB 堆管理的最小单位,向操作系统申请内存也以页计
mspan N 个连续 page 内存管理的基本单元,内部切成 N 个等大的 slot

Go 把堆对象按大小分为三档(经典面试考点):

  • 微对象:小于 16 字节。走 tiny allocator:多个微对象被打包塞进同一个 16 字节的块里,大幅减少碎片;
  • 小对象:16 字节 ~ 32 KB。按 size class(约 67 个规格等级)归类,从对应规格的 mspan 里取一个 slot;
  • 大对象:大于 32 KB。跳过 mcache 和 mcentral,直接向 mheap 按页申请。

另外每个 size class 还会按「是否包含指针」拆成两种 spanClass(noscan 标记)。不含指针的对象 GC 扫描时直接跳过,这就是第 30 期讲的 GC 扫描能那么快的原因之一。

三、三级缓存:mcache → mcentral → mheap

1. mcache:P 的私有缓存,无锁分配

每个 P 持有一个 mcache,内部是按 spanClass 索引的 mspan 数组(另加一个 tiny 分配器)。goroutine 分配小对象时:定位 spanClass → 取出本地 mspan → 通过 freeindex/allocCache 找到一个空闲 slot → 返回。全程无锁,因为同一时刻一个 P 上只运行一个 goroutine,天然互斥。

2. mcentral:按规格加锁的中心缓存

每个 size class 对应一个 mcentral,内部维护 partial(有空闲 slot)和 full(slot 用尽)两类 span 集合。mcache 缺 span 时从这里取,需要加锁——但锁粒度只有单个规格的 mcentral,几十个规格各有一把锁,竞争被天然分散,而不是全局一把大锁。

3. mheap:全局堆,对操作系统负责

mcentral 也没有 span 时,向 mheap 申请。mheap 管理所有 page:用基数树(radix tree)索引快速查找连续空闲页,组装成新的 mspan 交给 mcentral;连 mheap 都不够时,才通过 mmap 等机制向操作系统申请新的 arena(64MB 一块)。大对象分配也直接在这里按页对齐进行——注意按页对齐意味着一个 33KB 的对象实际占 5 页(40KB),有碎片代价。

四、一次小对象分配的完整旅程

你写下 obj := &Config{},编译器先做逃逸分析;确定要分配堆内存后,进入 runtime.mallocgc:

  1. 按对象大小算出 size class 和 spanClass;
  2. 进当前 P 的 mcache,对应槽位还有空闲 slot → 直接分配,结束(绝大多数分配止步于此);
  3. mspan 满了 → 向对应 mcentral 申请一个新 span 补上,再分配;
  4. mcentral 也空了 → 向 mheap 要空闲页组装新 span;
  5. mheap 没页了 → 向操作系统申请新的 arena。

一条清晰的降级路径:本地无锁 → 中心细粒度锁 → 全局锁 → 系统调用。层级越往下越慢,但命中的比例也指数级下降。

五、与 GC 的联动:bits 与 sweep

mspan 内部维护两张位图:allocBits 记录哪些 slot 已分配,gcmarkBits 记录 GC 标记阶段哪些对象存活。GC 完成后两张图交换,原本存活的位变成新的 allocBits,未标记的 slot 被「清扫(sweep)」回收——清除阶段也因此可以与用户代码并发执行。若某个 span 的 slot 被全部回收,它还会被归还 mheap,极端情况下内存可以还给操作系统。

六、用代码验证:观察内存分配统计

以下程序用 runtime.ReadMemStats 观察分配行为(均为稳定 API):

package main

import (
	"fmt"
	"runtime"
)

func main() {
	runtime.GOMAXPROCS(4) // 4 个 P → 4 套 mcache

	var m runtime.MemStats
	runtime.ReadMemStats(&m)
	fmt.Printf("分配前:已分配 %d KB,累计分配 %d KB\n", m.Alloc/1024, m.TotalAlloc/1024)

	// 分配 10 万个 24 字节的小对象(同一 size class)
	objs := make([]*byte, 0, 100000)
	for i := 0; i < 100000; i++ {
		b := make([]byte, 24)
		objs = append(objs, &b[0])
	}

	runtime.ReadMemStats(&m)
	fmt.Printf("分配后:已分配 %d KB,累计分配 %d KB,GC 次数 %d\n",
		m.Alloc/1024, m.TotalAlloc/1024, m.NumGC)
}

线上排查时更常用的是 GODEBUG=allocfreetrace=1(追踪每次分配)和 go tool pprof 的 alloc_space 采样——定位「哪个函数在疯狂制造垃圾」,再结合第 25 期 《sync.Pool》 做对象复用。

七、面试速查清单

  • 设计思想:脱胎 TCMalloc,多级缓存消灭全局锁;
  • 三级结构:mcache(P 本地,无锁)→ mcentral(按规格加锁)→ mheap(全局,对 OS);
  • 三档对象:微 <16B(tiny 打包)、小 16B~32KB(size class)、大 >32KB(直达 mheap);
  • page = 8KB,mspan = 连续 page 的集合,spanClass = size class × 是否含指针;
  • 分配路径是逐级降级,绝大多数分配在 mcache 无锁完成;
  • 为什么缓存挂在 P 而不是 M:goroutine 会跨 M 调度,P 是稳定的执行上下文。

结语

回答这道题的标准骨架:Go 内存分配器参考 TCMalloc,采用 mcache → mcentral → mheap 三级缓存结构;page(8KB)组成 mspan,按 size class 切槽,小对象在 P 私有的 mcache 里无锁分配,不足再逐级向下申请,大对象直接找 mheap。再补一句 tiny 分配器、noscan spanClass 和与 GC 的 bits 交换机制,就是超出平均水平的答案。


本文是「Go语言面试题」系列第 31 篇。 相关阅读:Go GC 垃圾回收机制全解析 · Go 逃逸分析:对象什么时候会逃到堆上 · Go 调度器 GMP 模型详解 · sync.Pool 深度解析 · 面试题系列总目录

🛒 站长推荐(京东联盟)

以上链接含京东联盟推广代码;通过链接购买本站可能获得少量佣金,不影响你的购买价格。

wx

关注公众号

©2017-2026 鲁ICP备17023316号-1 Powered by Hugo · 隐私政策