第17章 内存管理
引言:第16章给出了 Go 内存管理的"全景图"。本章把镜头拉近到数据结构层面,逐字段拆解 mspan、mcache、mcentral、mheap、arena、treap,并解释对象从
mallocgc到 mmap 的完整路径。读完本章,你应该能解释"为什么 Go 分配 8 字节比 1KB 还快"、“为什么make([]byte, 1)不一定进堆"以及"arena、span、page 三层抽象的边界在哪里”。
Heap
1. 是什么
Go 的"堆"有两层含义:
- 逻辑堆:用户通过
new、make、取地址逃逸得到的对象,由 GC 管理。 - 物理堆:runtime 通过
mmap向 OS 申请的一大片连续虚拟地址空间,按 arena 切大块、按 page 切小块,由mheap全局唯一实例统筹分配。
物理堆是逻辑堆的"地基"。所有逃逸对象最终落在物理堆的某个 page 上,由 mspan 标记"这段 page 当前按 size class X 分配"。
2. 底层数据结构
64 位 Linux/amd64 下,堆地址空间按 arena(也叫 heap arena)划分,每块 64 MiB。heapArena 是每块 arena 的"元数据页":
| |
mheap 是全局唯一实例(var mheap_ mheap),它持有所有 arena、所有 mcentral、所有空闲 page:
| |
字段解释:
central[136]:每个 size class(67 个 × 2 scan/noscan)一个 mcentral,加 cache line pad 防伪共享。freetreap:空闲 span 集合,按地址排序便于合并相邻 span,按优先级近似平衡二叉树,期望 O(log n)。arenas:两级 radix 树索引所有 heapArena 元数据。给定任意堆指针p,能 O(1) 定位其所在 arena 与 page。curArena:当前正在用的 arena 边界,分配新 arena 时从这里"切"。sweepgen:每轮 sweep 加 1,mspan 用sweepgen比对判断"是否要回收"。
分配路径(伪代码):
| |
3. 工程实践与常见坑
- 大对象直接走 mheap:>32KB(67 个 size class 之外)的对象会绕开 mcache/mcentral,直接向 mheap 要
ceil(size/8KB)页。这意味着每次分配都拿全局mheap.lock——并发大对象分配是显著瓶颈。 arena不是mmap一次 64MB:mmap单位更大(heapArenaBytes量级),arena 元数据是按需分配的。/proc/<pid>/maps看到的大块匿名段即此。MADV_DONTNEED与MADV_FREE:runtime 在scavenge时把空闲页归还给 OS,Linux 默认用MADV_FREE(懒回收,再次访问不需缺页),达到scavengeGoal时才退化MADV_DONTNEED。GODEBUG=madvdontneed=1强制后者,便于 RSS 监控对齐。- 观察物理堆:
runtime.ReadMemStats的HeapSys(OS 给的总堆虚拟内存)、HeapInuse(已分配)、HeapIdle(空闲未还)、HeapReleased(已还给 OS)。HeapIdle - HeapReleased是 runtime 持有但未分配的"缓存",可被复用但占 RSS。 - 容器 OOM 与
HeapReleased:HeapIdle高但HeapReleased低意味着 RSS 高但实际活对象少;调高GOMEMLIMIT触发更激进 scavenge 可缓解。 - 不要
runtime.MemProfileRate = 1:默认 512KB 采样,开 1 是全量记录,每 alloc 都进 map,分配本身被拖慢数倍。
Stack
1. 是什么
每个 goroutine 有独立栈。与 OS 线程固定栈不同,Go 栈起步 2KB(_StackMin),按需扩展,最多 1GB(_StackMax)。栈空间来自堆(准确地说是来自 mheap 切出的 mspan),但栈对象不参与 GC mark——扫描时只扫栈根,不扫栈内对象图。
逃逸分析决定对象在栈还是堆。栈分配几乎免费:移动 SP 指针即可,无 GC 压力,函数返回自动回收。
2. 底层数据结构
| |
栈扩容流程(runtime/stack.go):
- 函数序言:
cmp SP, g.stackguard0,若 SP < stackguard0 调runtime.morestack。 morestack→newstack:- 计算新大小(通常 2 倍,最多翻到
_StackMax); - 分配新栈(来自 mheap 的 stack pool 或直接 mallocgc);
copystack:把旧栈整段 memcpy 到新栈,并调整所有指向旧栈内部的指针——靠栈帧里的stackmap元数据知道哪些槽是指针;- 释放旧栈回 stack pool;
gogo切回用户代码,从函数序言重入。
- 计算新大小(通常 2 倍,最多翻到
栈缩容:GC 时检测 goroutine 实际使用 < 1/4,且未在系统调用中,会把它缩到 2 倍使用量。
栈分配的 mspan 走专门 size class(栈大小离散:2K、4K、8K、16K、32K…),有一个 stack pool 缓存:
| |
关键细节:
copystack是 STW 的(针对单个 G 的 STW,但会 stop 该 M)。栈拷贝本身只发生一次(扩容后通常够用整个 goroutine 生命周期),所以总开销摊薄后很低。
3. 工程实践与常见坑
runtime.Stack与runtime.Callers:前者返回格式化字符串(含分配),后者直接拿 PC 数组,热点路径用后者。- 深递归 → 栈溢出 fatal:
runtime: goroutine stack exceeds 1000000000-byte limit直接 crash,无 recover。深递归算法在 Go 里不友好,应改迭代或换语言。 //go:nosplit函数:禁止扩容,函数序言跳过 SP 检查。用于 runtime 内部极小函数(如gcWriteBarrier),用户代码用错会栈溢出。//go:nosplit必须保证栈足够(≤_StackGuard)。- CGO 调用切到
g0栈:每次 CGO 调用都在 M 的g0栈上执行(更大、固定),切回 G 时恢复 G 栈。频繁 CGO 调用有上下文切换开销。 - 栈上 string 拼接:
s := "a" + "b"这种常量拼接编译期完成;运行期fmt.Sprintf大多逃逸。+链式拼接 N 个 string 是 O(N²)。 - 逃逸分析边界:
func f() *int { x := 1; return &x }会逃逸(返回局部指针),但func f() int { x := 1; return x }不会。go build -gcflags='-m -l'看分析结果(-l关内联避免干扰)。
Tiny Allocator
1. 是什么
mallocgc 对 无指针(noscan) 的微小对象(<16B)走 tiny allocator:把多个微对象合并放进一个 16B 的 tiny block 里,由 mcache 持有。这是 Go 1.2 引入的优化,针对"大量 []byte(1)、int32、bool 之类"的碎片化场景。
2. 底层数据结构
mcache 里维护一个 tiny slot:
| |
分配逻辑(runtime/msize.go 与 malloc.go,简化):
| |
要点:
- 只有 noscan 才能合并:含指针对象不能合并——合并后扫描时无法区分边界,可能扫到下一个对象的"假指针"。每个小对象自身就是有效指针图。
- 大小 ≤16B:上限与
maxTinySize对齐,过大会浪费。 - 对齐:tiny block 内 8B 对齐优先,4B/2B/1B 依次退让,保证字段访问对齐。
- 释放:单个小对象不能主动 free,等整个 16B block 所有小对象都不可达时由 GC 整体回收。
3. 工程实践与常见坑
make([]byte, 1)的真实代价:不逃逸时栈分配;逃逸时进 tiny allocator,与下一个<16Bbyte 数组挤同一个 block。所以一次性"临时 1B 缓冲"不会真触发 16B 分配。sync.Pool与 tiny allocator 协同:Pool 缓存的对象仍可能进 tiny allocator(如果它没指针且 ≤16B),但池的意义更多在于"避免重新初始化"。string(1byte)的逃逸:string是 16B 头(指针 + 长度),合并 tiny 没意义,但其 backing array 是单独的 1B/2B…/16B 分配,可走 tiny。- 不要靠 tiny allocator 做"零拷贝":tiny block 是 mcache 私有,跨 goroutine 传递指针等于跨 P 传递——对象会因不可达被 GC,但内存复用是 mcache 级,不保证。
bool/int8字段逃逸时几乎免费:tiny allocator 让这类小对象分配成本极低,但对象计数仍计入 GC——海量小对象即便每次便宜,累计扫描仍是压力。
Span
1. 是什么
mspan 是 mheap 分配给 mcache/mcentral 的"基本单元",由若干连续 8KB page 组成,绑定一个 size class。可以把它理解为"一摞定长对象槽":分配时不切割 page,而是按 size class 把 page 内每个对象槽标记 已分配/空闲。
每个 mspan 要么属于一个 size class(“分配中的 span”),要么是"空闲 span"(按 page 给 mheap 管理)。
2. 底层数据结构
| |
关键操作:
| |
sizeclasses.go 里 67 个 class 的定义(前几个):
| class | elemsize | span 页数 | 一次 span 元素数 |
|---|---|---|---|
| 1 | 8 B | 1 | 8192 |
| 2 | 16 B | 1 | 4096 |
| 3 | 24 B | 1 | 2730 |
| 4 | 32 B | 1 | 2048 |
| 5 | 48 B | 1 | 1365 |
| 6 | 64 B | 1 | 1024 |
| 7 | 80 B | 1 | 819 |
| … | … | … | … |
| 67 | 32768 B | 8 | 8 |
class 之间留"间隔"是为了内存利用率:8/16/24/32 是 8 的倍数;48 跨 16 的倍数;后续按 ~1.15× 递增。整体在"碎片少"与"类数少"间权衡,67 类是经验最优。
3. 工程实践与常见坑
- 同一 size class 的对象不能跨 span 共享:mcache 持有的 span 是 P 私有,跨 P 传指针正常,但 mcache 统计是 per-P。
allocBits不是 mark bits:allocBits表示"是否已分配",GC 时复制为gcmarkBits后清零,标记阶段往gcmarkBits写。Sweep 时allocBits = gcmarkBits,未标记的槽自然变成"空闲"。sweepgen让 sweep 大部分无锁:mcache refill 时用 CAS 把 span 的 sweepgen 与全局对齐,对齐后该 span 的 free 操作不需 mheap.lock。- 大对象不进 size class:>32KB 直接 mheap,分配出来是个
npages>4的 mspan 但spanclass=0(“大对象"标志),整块作为一个对象。GC 时直接判断可达性,无 allocBits 维护。 freeindex是优化:从上次分配位置开始扫描,避免每次从头扫 allocBits。若freeindex == nelems说明该 span 满,需 refill。
MCache
1. 是什么
mcache 是 每个 P 一个 的本地分配缓存,是无锁快路径。mallocgc 大多数命中这里,无需任何原子或锁操作。每个 mcache 持有 136 个 mspan(每个 size class + noscan 变体一个),加 tiny allocator 状态。
2. 底层数据结构
| |
mcache 与 P 的关系:
| |
分配快路径(简化):
| |
refill 流程:把当前 span(用光或不再适合)归还 mcentral,从 mcentral 拿一个 partial span。如果 mcentral 也没有,mcentral 向 mheap 申请。
为什么无锁:每个 P 同一时刻只被一个 M 绑定执行,mcache 是 P 私有——并发只发生在 P 切换 M 时,runtime 保证切换时 mcache 状态一致(releasep/acquirep 会 flush)。
3. 工程实践与常见坑
GOMAXPROCS=1时只有 1 个 mcache:所有分配串行化同一缓存。无锁优化失效,但缓存命中率反而高。runtime.GOMAXPROCS减小会"丢弃"mcache:P 进入_Pdead时其 mcache 被释放,span 归还 mcentral。再次增大GOMAXPROCS会重新分配。频繁调整GOMAXPROCS有抖动。debug.SetGCPercent不影响 mcache:mcache 是分配路径,GC 是回收路径,二者解耦。runtime.NumCPUvsGOMAXPROCS:mcache 数量 =GOMAXPROCS,与 CPU 数无关。容器里设小GOMAXPROCS会减少 mcache 数,可能让"分配热点集中"反而变快——但通常会因为并发度降低而变慢。prepareForSyscall会 flush mcache:M 进入长时间 syscall 前 P 会被拿走,下次回到 P 时 mcache 已被其他 M 使用过——但这种切换由 runtime 保证 mcache 状态正确,用户无感知。
MCentral
1. 是什么
mcentral 是 每个 size class 一个 的中央缓存,所有 P 共享。它持有若干 mspan,分为 partial(有空闲槽)和 full(无空闲槽)两组,每组再分 swept(已扫)和 unswept(待扫)共 4 个集合。Mcache 在 miss 时向 mcentral 要 span,mcentral 是带锁的中路径。
2. 底层数据结构
| |
spanSet 的设计目标是 并发友好:生产(mcache 归还)和消费(mcache 申请)可以并发,仅 head/tail 越界时才需要扩容锁。它取代了 Go 1.20 之前的 mSpanList + mutex,在多 P 高并发分配时显著降低锁竞争。
核心操作:
| |
mcentral.grow() 路径:向 mheap 申请一个 npages 的 span,初始化为该 size class 的 mspan,加入 partial。
3. 工程实践与常见坑
- mcentral 锁是分配热点的"次瓶颈”:Go 1.21 spanSet 改造后竞争大幅下降,但极端场景(很多 P 频繁 miss mcache)仍可见。可通过减少分配、用
sync.Pool跨 goroutine 复用来规避。 partial/full×swept/unswept四象限的意义:保证 sweep 与分配并发——分配优先用已扫 span,未扫 span 留给后台 sweep worker 处理,避免分配路径触发同步 sweep。GOGC太小 → mcentral 频繁 grow/shrink:GC 太频繁让 span 一直在 mheap/mcentral/mcache 间搬运,CPU 烧在搬运上而非业务。gctrace=1看scvg行(scavenge)能观察。sync.Pool实际复用的是 mcache 路径:Pool 的Get/Put在 per-PpoolLocal内进行,底层仍是 mcache 分配——所以 Pool 减少 GC 而非减少 mallocgc 调用次数(虽然两者经常一起降)。- 观察 mcentral 命中率:
runtime/metrics没有直接暴露 mcentral 命中率,但gctrace=1的scvg行与runtime.ReadMemStats的BySize数组能间接反映各 size class 的活跃度。
MHeap
1. 是什么
mheap 是 全局唯一 的堆管理器,持有所有 arena、所有 mcentral、所有空闲 page。它向 OS 申请内存(mmap),按 page (8KB) 切分,按需 scavenge 还给 OS(madvise)。任何 mcentral miss 最终都会到 mheap;任何大对象(>32KB)直接走 mheap。mheap_ 是全局变量,访问需 mheap.lock(除只读元数据路径)。
2. 底层数据结构
| |
treap(笛卡尔树):每个节点是一个空闲 mspan,键是 startAddr(满足 BST 性质便于找相邻 span 合并),优先级是随机数(满足堆性质近似平衡):
| |
allocSpan 主要路径(简化):
| |
grow 路径(简化):
| |
scavenge 路径(简化):
| |
3. 工程实践与常见坑
mheap.lock是大对象分配的瓶颈:高并发大对象(如make([]byte, 64<<10))会让mheap.lock成为热点。优化方向:复用 buffer(sync.Pool/bytes.Buffer)、流式处理而非大 buffer。- scavenge 节奏与 RSS:
GOMEMLIMIT设置后会触发更频繁 scavenge,RSS 峰值降低但MADV_FREE的"软释放"特性意味着 RSS 不立即下降——只有内存压力来时 OS 才真正回收。 GODEBUG=madvdontneed=1:强制MADV_DONTNEED,RSS 立即下降,但再次访问触发缺页中断,性能略降。监控对齐 RSS 时常用。- cgroup 与
GOMEMLIMIT:Go 1.21 仍未自动读 cgroup 限制(automaxprocs解决 GOMAXPROCS 但不解决 GOMEMLIMIT),手动设GOMEMLIMIT= cgroup 内存上限的 ~90% 是稳妥做法。 - 大对象释放"懒惰":
mheap.freeSpan把 span 放回 free treap,但不立即madvise——等 scavenge 触发。这意味着"释放大对象后 RSS 不掉"是正常现象。 - arena 元数据占用:每个 64MB arena 配一个
heapArena(约 8KB 的 spans 数组 + bitmap),1GB 堆约 16 个 arena,元数据 ~128KB——可忽略。但嵌入式小内存设备要注意。 - 观察 mheap 行为:
runtime.ReadMemStats的HeapSys/HeapInuse/HeapIdle/HeapReleased直接反映 mheap 视角;debug.GCStats的PauseTotalNs间接反映 mheap sweep 的代价。
本章小结
- Go 堆采用 arena → page → span 三层抽象:64MB arena 由 mmap 切出,8KB page 是最小管理单元,mspan 把连续 page 绑定到 size class 提供定长对象槽。
- mcache → mcentral → mheap 三级缓存:mcache per-P 无锁快路径,mcentral per-size-class 中路径(带锁但 Go 1.21 用 spanSet 降低竞争),mheap 全局唯一慢路径(含 treap 与 arena radix)。
- tiny allocator 把 ≤16B 无指针对象合并进 16B block,缓解碎片。
- 栈分配独立于堆,逃逸分析决定栈/堆,栈扩缩容靠
copystack(依赖 stackmap 调整指针)。 - GC 与分配解耦:
allocBits跟踪分配,gcmarkBits跟踪可达,sweepgen让 sweep 大部分无锁。 - 工程上:减少逃逸、复用 buffer、合理设
GOMEMLIMIT与GOGC、容器内调GOMAXPROCS、必要时madvdontneed=1对齐 RSS 监控。
掌握这些数据结构后,回到 第16章 Runtime 总览 的全景图就不再有"黑盒";后续 第18章 GC 会基于 allocBits/gcmarkBits/sweepgen 展开标记-清扫细节,第19章 Scheduler 会基于 P/mcache 绑定关系解释抢占与 syscall 让出 P 的内存侧影响。