mspan不是内存分配执行者,而是管理堆上固定大小页块的中间结构;它不存用户数据,仅存储元信息(如起始地址、页数、spanclass),通过alloccache快速切分空闲对象槽供mcache使用。

runtime.mspan 不是内存分配的执行者,而是 Go 运行时管理堆上固定大小页块的中间结构。它本身不存用户数据,也不直接响应 make 或 new 调用——真正干活的是 mcache→mcentral→mheap 这条链路,mspan 只负责“登记”和“切分”。
mspan 怎么对应实际内存页
每个 mspan 管理一段连续虚拟内存,长度为 npages × 8KB(64 位系统下 page 固定为 8KB)。它的 startAddr 和 limit 字段框出这块地址范围;elemsize 决定里面能塞多少个等长对象;nelems = (npages × 8192) / elemsize,必须是整数。
常见误区是认为 “1 个 mspan = 1 个 page”,其实不是:spanclass 编码了对象大小等级和是否含指针,而该等级对应的 npages 是查表决定的。比如 class 10(144 字节对象)默认占 1 个 page,但 class 40(3072 字节)就占 3 个 page(24576 / 3072 = 8 个对象)。
- 小对象(≤32KB)走
spanclass查表 → 找到匹配的mspan池 → 切分出elemsize大小的空闲槽 - 大对象(>32KB)绕过
mspan,直接由mheap_.allocSpan按整页申请,不进mcentral管理 -
allocCache是一个 64 位位图,用于 O(1) 定位下一个空闲对象索引,比遍历链表快得多
为什么不能在代码里访问或修改 mspan
mspan 是 runtime 内部未导出结构,所有字段(如 freeindex、allocCount)都不对外暴露。Go 不提供任何公开 API 枚举、获取或强制释放某个 mspan。
试图通过反射或 unsafe 强制读写会触发以下问题:
- 不同 Go 版本中字段偏移可能变动,
dlv里printruntime.mheap_.allspans的输出在 1.21 和 1.22 中结构体布局已不兼容 -
allspans是全局只读 slice,修改其元素会导致 runtime panic:"write to Go pointer in system stack" - 即使成功拿到某个
mspan地址,也无法安全调用其方法(如sweep),因为缺少mspan所需的锁状态和 GC 周期上下文
spanclass 如何影响分配行为和 GC 效率
spanclass 是一个 uint8,高 7 位是 sizeclass(0–67),最低位是 noscan 标志。它决定了三件事:该 mspan 能服务哪类分配、GC 是否扫描其中对象、以及它在 mcentral 中归属哪条空闲链表。
例如:
-
spanclass=48→ sizeclass=24(对应 144 字节)、noscan=0→ GC 必须扫描每个对象找指针 -
spanclass=49→ sizeclass=24、noscan=1→ GC 跳过整块 span,省下大量标记时间 - 结构体中把所有指针字段集中放在开头或结尾,可能让逃逸分析判定为“可 noscan”,从而倾向分配到
spanclass奇数值的 span
注意:spanclass 是运行时根据 mallocgc 参数自动推导的,你无法用 unsafe.Sizeof 或 reflect 控制它,只能间接影响。
生产环境里怎么观察 mspan 的真实状态
别指望在程序里跑代码打印 mspan 列表——这既不可靠也不安全。可行路径只有两条:
- 用
GODEBUG=gctrace=1启动程序,看 GC 日志里类似scvg<x>: inuse: Y, idle: Z, sys: W, released: V</x>的行,其中inuse和idle差值除以 8192 可估算活跃 span 数量级 - 用
dlvattach 进程后执行:print *(**runtime.mspan)(unsafe.Pointer(&runtime.mheap_.allspans)),再循环 inspect,但要注意:allspans是[]*mspan,长度不等于当前活跃 span 数,很多是 nil 或已被 sweep 清零
真正容易被忽略的是:哪怕某个 mspan 里所有对象都已回收,只要它还在 mcentral.partial 链表里,就算作 HeapInuse,不会返还给 OS。这不是 bug,是设计使然——避免频繁 mmap/munmap 开销。











