本文源码基线为 Go 1.26.2、Linux/amd64。文中的源码链接固定到 go1.26.2 tag;对象大小阈值、size class 表和局部快路径可能随版本变化。

Go 内存分配器要同时满足三类目标:小对象分配足够快,多 P 并发时减少共享锁竞争,GC 又能由任意堆地址快速定位对象边界和标记元数据。它采用按对象规格分级、按 P 缓存、按页统一管理的结构。

对象是否进入这里首先由编译器逃逸分析决定;本文从已经确定需要堆分配的对象开始,追踪 mallocgc 到 mcache、mcentral、mheap 和页分配器。

先确认对象是否真的进入堆

new(T) 不等于堆分配,返回局部变量地址也不必机械地等于堆分配;编译器根据值是否可能在当前栈帧结束后继续可达、地址是否暴露给未知调用等条件做逃逸分析,随后内联和标量替换还可能消除分配。

GO
package alloc

type Node struct {
	next *Node
	val  int
}

//go:noinline
func makeNode(v int) *Node { return &Node{val: v} }
BASH
go test -gcflags='all=-m=2' ./...
go test -run='^$' -bench=. -benchmem ./...

第一条解释编译器决定,第二条测量最终二进制的每次操作分配。只看源码语法或只看逃逸日志都不足以判断生产路径成本。

分配器的层级

Go 将小对象归入 size class,再按 spanClass 管理 span。spanClass 同时编码 size class 和对象是否为 noscan。

TEXT
P.mcache
  每个 P 的本地 span 缓存,常见小对象分配快路径
       |
       v
mcentral[spanClass]
  同一规格的共享 span 集合,负责给 mcache 补货
       |
       v
mheap
  全局页级资源、arena 元数据和大对象分配
       |
       v
OS virtual memory

这里缓存的是具有空槽位的 span,而不是为每种对象单独维护一个普通对象列表。

page、span与object

TEXT
heapArena
  +-- page page page page ...
          +------ mspan ------+
          | obj | obj | obj   |
          +-------------------+
  • page 是页分配器管理堆空间的基本粒度。
  • mspan 描述一段连续页,以及其中对象的规格、位图和清扫状态。
  • 小对象 span 被切成固定大小的槽位。
  • 大对象通常独占一个或多个 span。

固定规格降低了查找成本和外部碎片,但会产生向上取整的内部碎片。例如请求 24 字节时,实际槽位大小由当前 size class 表决定,可能大于 24 字节。

mallocgc主流程

多数堆分配最终进入 mallocgc 或编译器选择的特化入口。Go 1.26.2 的分派不能只写成传统三分支:

TEXT
size == 0 -> &zerobase
  |
  +-- size-specialized malloc 已启用且类型布局允许
  |     -> 按 size 和 scan/noscan 进入生成的专用函数表
  |
  +-- 通用路径
        -> 并发标记时 deductAssistCredit(size)
        -> small noscan
             -> tiny(小于 maxTinySize 且上下文允许)
             -> mallocgcSmallNoscan
        -> small scan
             -> heap bits in span:mallocgcSmallScanNoHeader
             -> 否则:mallocgcSmallScanHeader
        -> large:mallocgcLarge
        -> 更新 sanitizer、profile、GC assist 和统计

sizeSpecializedMallocEnabled 还受 GOEXPERIMENT、race/asan/msan、平台等条件影响,所以不能看到 Go 1.26 的源码后就断言所有生产构建都走专用表。无论分派入口如何,常见小对象最终仍依赖当前 P 的 mcache,避免每次分配争用全局堆锁。

mcache与mcentral

每个 P 持有一个 mcache,因此 M 绑定 P 后可以访问对应的本地缓存。某个 spanClass 用满后,mcache 把旧 span 归还给 mcentral,并获取一个仍有空闲槽位的 span。

mcentral 按 spanClass 分组。多个 P 可能同时补货,因此这条路径需要共享同步;但补货频率远低于每次对象分配,同步成本被整批 span 的后续分配摊薄。现代实现使用并发 spanSet 和必要的内部锁,不能再把 mcentral 描述成由一把粗粒度中心锁保护的普通链表。

Go 1.26.2 的 mcache 关键字段包括:

GO
type mcache struct {
	nextSample  int64
	memProfRate int
	scanAlloc   uintptr

	tiny       uintptr
	tinyoffset uintptr
	tinyAllocs uintptr

	alloc          [numSpanClasses]*mspan
	reusableNoscan [numSpanClasses]gclinkptr
	stackcache     [_NumStackOrders]stackfreelist
	flushGen       atomic.Uint32
}

alloc 是按 spanClass 索引的当前 span。reusableNoscan 是 Go 1.26 中值得单独注意的对象复用快路径;它与 span 中通过 allocation bitmap 查找新槽位不是同一机制。flushGen 用来判断跨 GC 周期后缓存是否过期,需要在重新获得 P 时刷新。

mcentral 不再适合画成一个简单链表:

GO
type mcentral struct {
	spanclass spanClass
	partial  [2]spanSet
	full     [2]spanSet
}

两组 partial/fullsweepgen 交换“已清扫”和“未清扫”角色。cacheSpan 会先扣除 proportional sweep credit,优先从未清扫集合取 span 并尝试 sweep,再查已清扫集合,最后向 mheap 申请新 span。

mheap与heapArena

mheap 负责跨规格的全局页资源、span 元数据、arena 映射和向 OS 申请或归还虚拟内存。大对象绕过 mcache/mcentral,直接进入页级分配路径。

堆地址空间按 heapArena 分区。runtime 可以由对象地址定位对应 arena,再通过页到 span 的映射找到 mspan:

TEXT
object address
  -> arena index
  -> heapArena
  -> page index
  -> spans[page]
  -> mspan
  -> object slot

这条反查链路用于 GC、对象边界检查和堆诊断,不需要遍历整个堆。

mheappages pageAlloc 管理页占用,arenas 保存地址空间到 heapArena 元数据的映射,allspans 保存所有曾创建的 span 描述符。mheap.lock 必须在 system stack 上获取,否则当前 G 在持锁期间增长栈,栈分配再次进入 mheap 会自锁。

页分配器不是按页线性扫描整个堆。pageAlloc.alloc 使用分层摘要定位足够长的空闲页区间;每个 P 的 pageCache 又缓存一小段页位图,降低单页 span 分配访问全局 pageAlloc 的频率。

allocBits、gcmarkBits与sweepgen

allocBits 记录 span 中哪些槽位已分配;gcmarkBits 记录本轮 GC 哪些对象被标记存活。清扫阶段根据两组信息判断对象是否可以回收,并为下一轮更新位图角色。

sweepgen 用代际计数表示 span 与当前清扫周期的关系,帮助多个 goroutine 并发或按需清扫时判断一个 span 是否已经处理,避免只用简单布尔值造成周期混淆。

mspan 中,几个字段共同定义槽位状态:

TEXT
freeindex / allocCache   下一次快速查找空闲槽位的位置和缓存
nelems / elemsize       span 内对象数量与槽位大小
allocBits               哪些槽位已分配
gcmarkBits              本轮 GC 标记到哪些对象
allocCount              当前已分配数量
spanclass               size class + scan/noscan
sweepgen                 与 mheap_.sweepgen 的相对关系

freeIndexForScan 与 allocator 使用的 freeindex 分开,保证扫描器只在对象内容和 heap bits 初始化完成后才把对象视为可扫描。这是分配发布顺序的一部分,不是冗余计数。

Tiny allocator

tiny allocator 会合并某些很小且不含指针的对象,以降低元数据和碎片成本。它不适用于含指针对象,因为 GC 必须准确识别和扫描指针;对象身份、对齐和生命周期条件也会限制是否能够进入 tiny 路径。

多个 tiny 子对象共享一个底层块,所以只要其中任意子对象仍可达,整个块都会存活。这节省分配元数据,但可能延长同块其他对象占用内存的时间。优化小对象时不能只比较 allocation count,还要观察 live heap。

分配如何推动GC

分配并非 GC 之外的纯消费动作:

  • 分配量接近触发线时,分配路径可能启动新一轮 GC。
  • 并发标记期间,新分配对象需要满足标记不变量。
  • 分配过快的 goroutine 会承担 mark assist 工作。
  • span 在使用前可能触发按需清扫。

下一篇将沿着这条接口继续解释标记、写屏障、assist 和 pacer。

从page allocator到操作系统

mheap 管理的 page 是 runtime 视角的虚拟地址页,并不等于这些地址已经全部占用物理内存。更底层的 sysAllocsysMapsysUsedsysUnused 把跨平台内存状态翻译到 OS 实现:

TEXT
None      地址范围尚未保留
Reserved  地址空间已保留,但不可直接访问
Prepared  OS已准备,可转为Ready
Ready     可安全读写,可能占用物理页/RSS

这些是 runtime 的抽象状态,不应直接套用某一个内核 API。Linux 的 mem_linux.go 主要使用 mmapmadvise 等机制:保留虚拟地址、让页可用、提示内核回收物理页的动作彼此分开。

因此 HeapSys、进程虚拟地址空间和 RSS 不是同一个量:

  • HeapSys 表示 runtime 从 OS 获得的堆地址空间规模。
  • HeapIdle 表示已获得但当前未用于 live span 的部分。
  • HeapReleased 表示已提示 OS 可回收的物理页。
  • RSS 由内核驻留策略、页是否实际触碰、其他映射和统计口径共同决定。

对象被 GC 判死,只会先让对应 span/object 变得可复用;这不保证进程 RSS 同步下降。

Scavenger归还物理页

scavenger 处理的是页分配器中的空闲页,不负责判断对象存活。bgscavenge 是后台 scavenger G;pageAlloc.scavenge 按目标字节数查找候选区间,scavengeOne 在 chunk 中选择连续空闲页并调用 sysUnused

TEXT
对象不可达
  -> sweep清除allocBits并释放空span
  -> page allocator看到空闲页
  -> scavenger选择足够老/合适的连续范围
  -> sysUnused提示OS回收物理页

scavenger 有独立于 GC mark pacer 的 gcPaceScavenger,但输入仍会考虑 heap goal、内存限制、页年龄和物理页回收成本。后台 scavenger 默认按有限 CPU 利用率目标节流,内存压力较强时也可能进入更积极的 synchronous scavenging。强制频繁归还会降低 RSS,却可能在之后重新 fault 页面并增加延迟;只看 RSS 最小化并不是分配器调优目标。

源码入口

目标入口
对象分配runtime/malloc.go
P缓存runtime/mcache.go
中心缓存runtime/mcentral.go
mspan、mheap、arenaruntime/mheap.go
size classinternal/runtime/gc/sizeclasses.go
页分配runtime/mpagealloc.go
物理页回收runtime/mgcscavenge.go
OS内存状态runtime/mem.goruntime/mem_linux.go

观测与验证

BASH
GODEBUG=gctrace=1 go test -run='^$' -bench=. -benchmem ./...
go test -run='^$' -bench=. -memprofile=mem.pprof ./...
go tool pprof -alloc_space mem.pprof
go tool pprof -inuse_space mem.pprof

alloc_space 回答累计分配压力,inuse_space 更接近采样时仍存活的堆。二者都受内存采样率影响;小基准应配合 -memprofilerate、多次运行和 benchmark 的 allocs/op,不能从单张 heap profile 反推出每个对象精确生命周期。

延伸阅读

系列导航