第20章 内存管理
本章的内部实现快照基于 Go 1.26.4。语言只保证可观察语义,不保证
mspan、size class、page 大小或分配器数据结构长期不变。升级工具链后,应重新跑逃逸分析和 benchmark,不要用历史源码推导当前性能。
Go 的内存管理由编译器和 Runtime 共同完成:
- 编译器通过逃逸分析决定对象能否放在 goroutine 栈上。
- 堆分配进入
runtime.mallocgc,按对象大小和是否含指针选择路径。 mcache -> mcentral -> mheap/pageAlloc把高频小对象分配从全局锁中分离出来。- GC 按 span 清扫不可达对象,后台 scavenger 再尝试把空闲物理页还给操作系统。
栈还是堆
new(T)、make([]T, n) 和取地址不等于“必然堆分配”。编译器关心的是对象的生命周期、大小与对齐:
type Point struct{ X, Y int }
func local() int {
p := new(Point) // 若 p 没有流出,Point 可能在栈上
p.X = 1
return p.X
}
func shared() *Point {
return &Point{X: 1} // 指针返回给调用方,通常需要堆分配
}
用工具查结论,不靠语法猜测:
go build -gcflags='-m=2' ./...
go test -bench=. -benchmem ./...
Go 1.26 可以在更多场景中为动态长度的本地 slice 生成混合路径:当运行时大小很小时使用预留的栈空间,超过内部阈值时转堆分配。这类阈值是编译器实现细节,不是可依赖的 API。
mallocgc 的四条主路径
Go 1.26.4 中,可以把普通 Go 堆对象的分配概括为四类:
| 类别 | 条件 | 主要路径 | 关键点 |
|---|---|---|---|
| 零字节 | size == 0 | 返回特殊零尺寸地址 | 不要假设不同零尺寸对象地址唯一 |
| Tiny | 小于 16 B 且不含指针 | mallocgcTiny | 多个微小对象可共用一个 16 B block |
| Small | 不大于 32 KiB | 选 size class,从当前 P 的 mcache 取 slot | 常见快路径不需全局锁 |
| Large | 大于 32 KiB | 按 page 向 mheap 申请专用 span | 更容易进入全局慢路径,也更易影响峰值内存 |
伪代码只表达决策树,不复制 Runtime 的实际控制流:
// 概念伪代码,不可编译
func allocate(size uintptr, typ *runtimeType) unsafe.Pointer {
switch {
case size == 0:
return zeroBase()
case size < 16 && !typ.hasPointers():
return tinyAlloc(size)
case size <= 32<<10:
return smallAlloc(sizeClass(size), typ.hasPointers())
default:
return largeAlloc(pagesFor(size), typ)
}
}
含指针与不含指针的对象会进入不同 span class。noscan span 不需要扫描对象内的指针字,这对 GC CPU 很重要,但不意味着应为了 noscan 牺牲清晰的类型设计。
Arena、Page 与 Span
Page 和 arena
在 Go 1.26.4 中,Runtime 的堆 page 是 8 KiB。堆虚拟地址空间再按 heap arena 建立索引;常见 64 位端口的 arena 是 64 MiB,但具体布局取决于端口。Go 1.26 还在 64 位平台启用了堆基地址随机化,不应依赖堆地址范围或重复性。
heapArena 保存某个 arena 的元数据,例如 page 到 mspan 的映射和 page 使用位图。mheap.arenas 用平台相关的一级或两级索引把堆地址映射到 heapArena。arena 是索引与保留空间的粒度,不等于每次分配都向 OS 映射完整 64 MiB 物理内存。
pageAlloc:当前页分配器
mheap.pages 是 pageAlloc。它使用:
- 按 chunk 组织的 page 分配位图;
- 多级 radix summary,每个摘要记录前缀、最大连续区间和后缀的空闲 page 数;
- 空闲与已 scavenged page 的状态。
简化结构:
// runtime/mpagealloc.go,概念快照
type pageAlloc struct {
summary [summaryLevels][]pallocSum // 连续空闲页的基数摘要树
chunks sparseChunkMap // page 分配位图,概念表示
inUse addrRanges // 已映射的地址范围
scav pageAlloc64 // scavenger 所需状态,概念表示
}
申请 N 个连续 page 时,顶层 summary 先过滤不可能满足的区域,然后向下定位具体 chunk 与位图。释放后反向更新摘要。旧文章常见的 mTreap、free/busy/scavenge treap 不属于当前页分配器。
mspan:连续 page 的用途描述符
mspan 描述一段连续 page。小对象 span 会被切成多个等大 slot;大对象通常独占一个 span。
// runtime/mheap.go,只保留理解分配所需的字段
type mspan struct {
startAddr uintptr
npages uintptr
freeindex uintptr
nelems uintptr
allocCache uint64
allocBits *gcBits
gcmarkBits *gcBits
sweepgen uint32
allocCount uint16
spanclass spanClass
state mSpanStateBox
needzero uint8
}
freeindex与allocCache用于快速找下一个可用 slot,常见路径只需位运算。allocBits记录 slot 的分配状态,标记位用于 GC 可达性。Go 1.26 的 Green Tea GC 对部分 span 使用内联 mark/scan bits,因此不应把某个私有字段当作唯一实现。sweepgen将 span 与当前清扫世代关联,支持后台和按需清扫。needzero表示这段内存在再利用前是否需要清零。
spanClass 是 size class 和 noscan 位的组合,概念上类似:
spanClass = sizeClass<<1 | noscanBit
Go 1.26.4 的 internal/runtime/gc/sizeclasses.go 定义 68 个 size-class 索引,其中 0 是保留值,1..67 是 8 B 到 32 KiB 的可用小对象级别。与 scan/noscan 位组合后 numSpanClasses 为 136。大对象使用 size class 0 的专用 span,不是“第 67 类”。
mcache:每 P 的快路径
mcache 属于 P,不属于 goroutine,也不与某个 OS 线程永久绑定。M 只有在持有 P 时才能执行普通 Go 代码,因此当前 M 可以独占访问该 P 的 mcache。
// runtime/mcache.go,简化
type mcache struct {
nextSample int64
scanAlloc uintptr
tiny uintptr
tinyoffset uintptr
tinyAllocs uintptr
alloc [numSpanClasses]*mspan
reusableNoscan [numSpanClasses]gclinkptr
stackcache [_NumStackOrders]stackfreelist
flushGen atomic.Uint32
}
小对象快路径大致是:
- 计算 size class 和 scan/noscan。
- 取
mcache.alloc[spanClass]。 - 通过
allocCache找到下一个空 slot。 - 更新分配位图、profile 采样和 GC assist 账务。
- span 满时才到
mcentral更换 span。
“无锁快路径”指不需全局 allocator 锁,不代表没有原子操作、GC assist、写屏障或 profile 成本。
mcentral:按 span class 共享
mheap.central 为每个 span class 保留一个 mcentral。当 P 的本地 span 用完时,mcentral.cacheSpan 按以下顺序寻找:
- 已清扫且尚有空 slot 的 partial span。
- 未清扫的 partial span,取得清扫所有权后清扫并尝试使用。
- 必要时检查未清扫的 full span,看本轮 GC 是否回收出 slot。
- 都不可用时,向
mheap申请新 span。
// runtime/mcentral.go,Go 1.26.4
type mcentral struct {
spanclass spanClass
partial [2]spanSet // 有空 slot
full [2]spanSet // 没有空 slot
}
每组两个 spanSet 在 GC 周期切换“已清扫/未清扫”角色。spanSet 使用原子索引和按需扩展的 spine,让分配器和后台 sweeper 可并发生产、消费 span。它并不等于整个 mcentral 是“完全无锁”,也不应按某个历史版本的链表实现理解。
mheap:全局慢路径
mheap_ 是 Runtime 的全局堆管理器。它的核心职责是:
- 通过
pageAlloc分配和释放连续 page; - 维护 heap arena 元数据索引;
- 保存各 span class 的
mcentral; - 维护 sweep 世代、全部 span 列表和特殊记录(finalizer、cleanup、pin 等);
- 在需要时扩展堆虚拟地址空间。
// runtime/mheap.go,概念快照
type mheap struct {
lock mutex
pages pageAlloc
sweepgen uint32
allspans []*mspan
arenas arenaIndex
central [numSpanClasses]paddedMCentral
// page reclaim、special records、user arena 等字段省略
}
大对象和 mcentral 的新 span 都会到这一层。这是全局协调点,但不能由此推导“每次大对象分配都会成为实际瓶颈”;锁竞争、清零、缺页、GC assist 和内存带宽谁占主导,要用 mutex/CPU/alloc profile 和 benchmark 判断。
Tiny Allocator
Tiny allocator 只处理小于 16 B 的 noscan 对象。当前 P 的 mcache 保留一个 16 B tiny block 和偏移,按对齐把多个对象塞入同一 block:
16-byte tiny block
+--------+----+--+------+
| uint64 | u32|u8| free |
+--------+----+--+------+
这减少了分配位图与内部碎片,但有两个重要边界:
- 只要同一 tiny block 中仍有对象可达,整个 block 就不能回收。
runtime.AddCleanup不保证对这类可能批量共享 slot 的微小无指针对象及时运行;不能用 cleanup 替代确定性Close。
对象是否进 tiny allocator 由实际堆布局和类型指针位图决定,不是看源码字面量就能保证。
Goroutine 栈
每个 goroutine 有自己的连续栈,当前实现通常以 2 KiB 起步,按需增长并可在 GC 扫描时缩小。这些大小是 Runtime 内部常量,不是语言承诺。
函数序言会检查 SP 与 g.stackguard0。空间不足时,morestack -> newstack -> copystack 大致执行:
- 计算新栈大小并申请新的连续区域。
- 复制活跃栈帧。
- 根据编译器生成的 stack map 调整指向旧栈内部的指针。
- 切换
g.stack,释放旧栈。
小栈块通过每 P mcache.stackcache 与全局 stack pool 复用,较大栈进入 page 分配路径。栈上对象不是独立的 Go 堆对象,但 GC 必须扫描活跃栈帧中的指针槽,把它们当作堆对象的根。
与栈相关的工程结论:
- 不要返回指向 Go 栈的
uintptr;uintptr不是可跟踪指针,且栈会移动。 - 使用
unsafe.Pointer和 cgo 时遵循指针规则,必要时用runtime.Pinner,不要自行假设地址稳定。 - 深递归可反复扩栈并最终超过 Runtime 限制;外部输入可控的递归必须有深度限制。
清扫、Scavenger 与 RSS
GC 的 sweep 和 scavenger 解决不同问题:
- sweep 在 span 内找出未标记 slot,让它们可被 Go 分配器再利用。
- scavenge 把足够大且空闲的物理页告知操作系统可回收,但通常保留虚拟地址以便后续复用。
因此“对象已不可达”、“span slot 已可复用”和“RSS 已下降”不是同一时刻。操作系统对 madvise 等提示的计费和 RSS 反映也不完全相同。
runtime.MemStats 中常用的几个关系:
HeapAlloc:当前存活的 Go 堆对象字节。HeapInuse:已分配给小对象 size class 或大对象的 span 字节。HeapIdle:当前没有用于堆对象的 span 字节。HeapReleased:HeapIdle中已告知 OS 可回收物理页的部分。HeapSys:Runtime 从 OS 获得的堆虚拟地址空间。
GOMEMLIMIT/debug.SetMemoryLimit 尝试约束的 Runtime 内存可用以下指标近似观察:
/memory/classes/total:bytes - /memory/classes/heap/released:bytes
它是软限制,不包括 Go 二进制映射、C 分配和用 syscall.Mmap 管理的内存。容器内存上限不会自动成为 GOMEMLIMIT,应按真实 RSS、非 Go 内存和突发流量留出余量。
debug.FreeOSMemory 会强制 GC 并尽可能归还内存,适合诊断、测试或有明确空闲窗口的特殊工作负载,不应成为高频定时任务。
测量与调优
先分清问题
| 现象 | 首选工具 | 需要回答的问题 |
|---|---|---|
| 分配速率高 | go test -benchmem、allocs profile | 谁在持续分配? |
| 存活堆大 | in-use heap profile | 谁仍持有对象? |
| RSS 高于堆 | runtime/metrics、OS/cgroup 指标 | 是未归还 page、stack、mmap 还是 cgo? |
| 分配延迟尖刺 | CPU/trace/mutex profile | 是 GC assist、清零、缺页还是锁竞争? |
| 升级 Go 后回归 | 固定负载的 A/B benchmark | 是逃逸、size class 还是 GC 变化? |
常用 Runtime 指标:
/gc/heap/allocs:bytes
/gc/heap/allocs:objects
/gc/heap/live:bytes
/memory/classes/heap/objects:bytes
/memory/classes/heap/released:bytes
/memory/classes/total:bytes
指标名应在目标工具链上通过 runtime/metrics.All() 发现,不要把本章列表当作跨版本 schema 承诺。
可操作的优化
- 已知最终规模时预分配 slice/map,但要把容量估算的内存成本纳入测量。
- 用
bytes.Buffer、strings.Builder和strconv.Append*减少中间对象,但不为了零分配牺牲正确性。 sync.Pool只用于可丢失、可安全重置且分配成本已被 profile 证明的临时对象。Pool 可在 GC 时丢弃内容,不是有容量保证的缓存。- 避免把超大 buffer 放回池中长期保留;按上限丢弃历史峰值 buffer。
- 对保留小子切片而留住巨大 backing array 的场景,显式
slices.Clone或bytes.Clone。 - 减少堆指针数量有时能降低 GC 扫描量,但 SoA/索引化等布局优化必须在真实访问模式下 benchmark。
源码阅读路线
按以下顺序比直接从 mheap 巨型结构开始更容易:
src/runtime/malloc.go:mallocgc、tiny/small/large 分流。src/internal/runtime/gc/sizeclasses.go:当前 size class 表。src/runtime/mcache.go:每 P 缓存和 span 更换。src/runtime/mcentral.go:partial/fullspanSet 与清扫交互。src/runtime/mheap.go:mspan、mheap、arena 索引和 span 生命周期。src/runtime/mpagealloc.go:pageAlloc的 radix summary 和 page bitmap。src/runtime/mgcscavenge.go:物理页归还与 pacer。src/runtime/stack.go:栈分配、复制与缩小。
阅读时固定到具体 tag,例如 go1.26.4,并区分三类内容:语言保证、导出 API 契约、Runtime 私有实现。
本章小结
- 逃逸分析决定对象能否使用 goroutine 栈;
new、make或接口转换都不能单独证明堆分配。 - 堆分配的主干是 tiny/small/large 分流与
mcache -> mcentral -> mheap/pageAlloc。 - Go 1.26.4 的页分配器是 page bitmap + 多级 radix summary,不是旧 treap 结构。
mspan是连续 page 的用途和 slot 状态描述符;size class 0 用于大对象,1..67 是当前小对象级别。- sweep 让 slot 可复用,scavenger 让 OS 可回收物理页,它们与对象不可达、RSS 下降都不是同一事件。
- 内存优化必须同时看分配速率、存活堆、Runtime 总内存与 RSS,最终以 profile 和 benchmark 为准。