mspan是go运行时管理堆上固定大小页块的真实结构体,对应连续虚拟内存(8kb倍数),仅存元信息(如起始地址、elemsize、allocbits),不存用户数据;小对象按size class查表分配,大对象(>32kb)绕过mspan直连mheap。

span 是 Go 运行时内存管理的物理基础单位,不是抽象概念,而是真实存在的结构体 mspan。它直接对应操作系统页(8KB),且必须连续。理解它,才能看懂为什么 make([]int, 100) 不一定逃逸、为什么 sync.Pool 要按 size class 预热、为什么 unsafe.Sizeof 和实际分配字节数常不一致。
span 是什么:不是“跨度”,是内存块容器
很多人把 span 当作一个泛泛的“范围”概念,这是误导。它在 Go 源码中是具体类型 runtime.mspan,每个实例管理若干个 8KB 的 page,并负责将这些 page 切成固定大小的“元素”供分配。
关键点:
-
span本身不存储用户数据,只维护元信息:比如allocBits(位图标记哪些元素已用)、elemsize(该 span 只服务一种对象大小)、npages(占几个 8KB 页) - 一个
span只属于一个size class,比如 class 3 的span中每个元素固定 32 字节,不管你要存的是struct{a,b int}还是[4]int - class 0 是特例:表示大对象(≥32KB),此时
span不切分,整个 span 就是一个对象
size class 决定 span 分配行为,不是你写的字节数
当你写 new(bytes.Buffer) 或 &MyStruct{},Go 不按结构体真实大小找 span,而是查表——找到第一个 ≥ 该结构体对齐后大小的 bytes/obj 对应的 class。
例如:
-
struct{a int8; b int8}占 2 字节 → 对齐到 8 字节 → 落入 class 1 → 从 class 1 的span中取 8 字节元素 -
struct{a [17]byte}占 17 字节 → 对齐到 32 字节 → class 3 → 实际分配 32 字节,浪费 15 字节 - 这个对齐规则由编译器在 build 时确定,运行时不可更改
MCache → MCentral → MHeap 的三级流转,本质是缓存失效链
分配不是每次都走系统调用。小对象优先从 MCache 拿,但 MCache 是 per-P 的,没锁;一旦某 class 元素耗尽,就触发向 MCentral 申请新 span;MCentral 若无可用 span,才向 MHeap 申请 page 并构造新 span。
容易被忽略的细节:
-
MCache不会主动归还空闲元素给MCentral,除非该 class 的空闲元素超过某个阈值(约 2×当前需求),否则一直留着——这是为了减少反复申请开销 -
MCentral的span是带锁的,高并发下多个 P 同时抢同一 class 的span会形成争抢热点,这就是为什么压测时看到runtime.mcentral.cacheSpan函数频繁出现在火焰图里 - GC 回收的对象,其所在
span不会立刻销毁,而是清空allocBits后放回MCentral的 nonempty 列表,等待下次复用
如何观察 span 行为:用 GODEBUG=gcstoptheworld=1 + go tool trace
想验证某个变量是否触发了新 span 分配,光看逃逸分析不够。真正影响性能的是 span 级别的分配频率和碎片状态。
实操建议:
- 启动时加
GODEBUG=mcsched=1,会在 GC 日志里打印每轮回收后各 class 的span数量变化 - 用
go tool trace打开 trace 文件,在 “Goroutine” 视图里找runtime.mcache.refill事件,它代表一次MCache缺货补货,就是 span 流转的信号点 -
runtime.ReadMemStats中的MSpanInuse字段是当前活跃span总数,但它不区分 class;要查具体 class 使用情况,得用debug.ReadGCStats配合 pprof heap profile 的 raw data 解析
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











