Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

第20章 内存管理

本章的内部实现快照基于 Go 1.26.4。语言只保证可观察语义,不保证 mspan、size class、page 大小或分配器数据结构长期不变。升级工具链后,应重新跑逃逸分析和 benchmark,不要用历史源码推导当前性能。

Go 的内存管理由编译器和 Runtime 共同完成:

  1. 编译器通过逃逸分析决定对象能否放在 goroutine 栈上。
  2. 堆分配进入 runtime.mallocgc,按对象大小和是否含指针选择路径。
  3. mcache -> mcentral -> mheap/pageAlloc 把高频小对象分配从全局锁中分离出来。
  4. 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.pagespageAlloc。它使用:

  • 按 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 与位图。释放后反向更新摘要。旧文章常见的 mTreapfree/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
}
  • freeindexallocCache 用于快速找下一个可用 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
}

小对象快路径大致是:

  1. 计算 size class 和 scan/noscan。
  2. mcache.alloc[spanClass]
  3. 通过 allocCache 找到下一个空 slot。
  4. 更新分配位图、profile 采样和 GC assist 账务。
  5. span 满时才到 mcentral 更换 span。

“无锁快路径”指不需全局 allocator 锁,不代表没有原子操作、GC assist、写屏障或 profile 成本。

mcentral:按 span class 共享

mheap.central 为每个 span class 保留一个 mcentral。当 P 的本地 span 用完时,mcentral.cacheSpan 按以下顺序寻找:

  1. 已清扫且尚有空 slot 的 partial span。
  2. 未清扫的 partial span,取得清扫所有权后清扫并尝试使用。
  3. 必要时检查未清扫的 full span,看本轮 GC 是否回收出 slot。
  4. 都不可用时,向 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 内部常量,不是语言承诺。

函数序言会检查 SPg.stackguard0。空间不足时,morestack -> newstack -> copystack 大致执行:

  1. 计算新栈大小并申请新的连续区域。
  2. 复制活跃栈帧。
  3. 根据编译器生成的 stack map 调整指向旧栈内部的指针。
  4. 切换 g.stack,释放旧栈。

小栈块通过每 P mcache.stackcache 与全局 stack pool 复用,较大栈进入 page 分配路径。栈上对象不是独立的 Go 堆对象,但 GC 必须扫描活跃栈帧中的指针槽,把它们当作堆对象的根。

与栈相关的工程结论:

  • 不要返回指向 Go 栈的 uintptruintptr 不是可跟踪指针,且栈会移动。
  • 使用 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 字节。
  • HeapReleasedHeapIdle 中已告知 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.Bufferstrings.Builderstrconv.Append* 减少中间对象,但不为了零分配牺牲正确性。
  • sync.Pool 只用于可丢失、可安全重置且分配成本已被 profile 证明的临时对象。Pool 可在 GC 时丢弃内容,不是有容量保证的缓存。
  • 避免把超大 buffer 放回池中长期保留;按上限丢弃历史峰值 buffer。
  • 对保留小子切片而留住巨大 backing array 的场景,显式 slices.Clonebytes.Clone
  • 减少堆指针数量有时能降低 GC 扫描量,但 SoA/索引化等布局优化必须在真实访问模式下 benchmark。

源码阅读路线

按以下顺序比直接从 mheap 巨型结构开始更容易:

  1. src/runtime/malloc.gomallocgc、tiny/small/large 分流。
  2. src/internal/runtime/gc/sizeclasses.go:当前 size class 表。
  3. src/runtime/mcache.go:每 P 缓存和 span 更换。
  4. src/runtime/mcentral.gopartial/full spanSet 与清扫交互。
  5. src/runtime/mheap.gomspanmheap、arena 索引和 span 生命周期。
  6. src/runtime/mpagealloc.gopageAlloc 的 radix summary 和 page bitmap。
  7. src/runtime/mgcscavenge.go:物理页归还与 pacer。
  8. src/runtime/stack.go:栈分配、复制与缩小。

阅读时固定到具体 tag,例如 go1.26.4,并区分三类内容:语言保证、导出 API 契约、Runtime 私有实现。

本章小结

  • 逃逸分析决定对象能否使用 goroutine 栈;newmake 或接口转换都不能单独证明堆分配。
  • 堆分配的主干是 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 为准。