Theory · Golang · Runtime
栈分配几乎免费 → 逃逸分析决定去留 → mcache/mcentral/mheap 三级架构 —— Go 把 TCMalloc 思想搬进了 runtime
编译器逃逸分析静态决定:栈还是堆——go build -gcflags='-m' 可直接观测
mcache(per-P 无锁)→ mcentral(按 size class)→ mheap(页堆);tiny/small/large 分级
2KB 连续栈 grow/copy、GOGC 与 GOMEMLIMIT(1.19+)、泄漏排查工具箱
开场 · Why Memory Allocation
先看一个能在脑子里算出来的场景,再决定要不要往下读。
每个请求都要拼一个 JSON、建几个 struct、切几次 slice。保守按每请求 200 次堆分配算——乘起来就是每秒 100 万次分配请求。
| 如果没有这套分配器 | 结果 |
|---|---|
| 每次都找 OS 要内存 | 一次系统调用是微秒级,100 万次就是秒级卡顿——服务直接不可用 |
| 全局一把锁保护一个池子 | 多核互相排队,核越多堵得越死,机器永远跑不满 |
| 手动 new / delete | 由写代码的人自己记账——悬垂指针与泄漏随之而来 |
编译器用逃逸分析证明"这个变量活不过当前函数",就把它留在栈上,函数返回时随栈帧一起销毁,零 GC 成本。——见第 4、5 页
每个逻辑 CPU(P)有自己的私有小仓库 mcache,绝大多数分配完全无锁;缺货才逐级上溯到 mcentral、mheap,由它们偶尔向 OS 批发。——见第 6–9 页
进程总内存由 GOGC(相对量)与 GOMEMLIMIT(绝对软上限)两个旋钮约束,配合 pprof 定位"到底谁在吃内存"。——见第 11–13 页
读之前 · Before You Read
下面这些术语全部是本 deck 真正会用到的,没有凑数;前置知识不在本 deck 内的,用链接指过去。
| 术语(中英对照) | 一句话白话定义 |
|---|---|
| 栈 stack | 每个函数的一小块私有内存。函数一返回就整块作废,不需要谁来回收 |
| 堆 heap | 全进程共享的大池子。谁都能申请,但没人知道该什么时候还,所以要靠 GC 定期扫 |
| 逃逸分析 escape analysis | 编译器在编译期静态推断"这个变量会不会活过当前函数"——会就放堆,不会就放栈 |
| GC 垃圾回收 | runtime 定期找出"已经没人引用的对象",把它们的内存收回池子的后台流程 |
| 页 page | Go 向操作系统批发内存的最小单位,8KB。零售给业务代码的小块都从页里切出来 |
| 术语(中英对照) | 一句话白话定义 |
|---|---|
| span mspan | 把若干连续页切成"定长小块"的管理单元,是分配器真正发货的货架 |
| 大小类 size class | 预定义好的几十档固定字节数(8B…32KB)。申请时向上取最近一档,牺牲一点空间换低碎片 |
| arena 内存区 | Go 一次向操作系统申请的 64MB 大块地址空间。进程里所有 Go 堆都在某块 arena 里 |
| P 处理器 processor | GMP 模型里的"逻辑 CPU"。每个 P 有自己的私有仓库 mcache——这就是无锁分配的来源 |
| RSS 常驻内存 | 进程真正占用的物理内存。监控图上看到的"内存"通常是它,所以它比 Go 自己的堆指标滞后 |
Stack vs Heap
回到开场那 100 万次分配——它们的代价到底差多少?先把两个"仓库"的成本摊开看,这是理解后面全部机制的起点。
栈指针下移 + 函数返回时整体上移回收——不需要 GC、不需要锁、不需要记录元数据。局部变量天然随栈帧一起消失,内存局部性好(热缓存)。这就是"逃逸分析优化"省下的全部成本。
size class 查表 → mcache 取槽 → (可能)mcentral/mheap → GC 要标记、扫描、回收——含指针对象还给 GC 增加图遍历负担。量级感受:栈分配亚纳秒级,堆分配几十纳秒起步,再摊上 GC 的 CPU。
| 维度 | 栈 | 堆 |
|---|---|---|
| 分配速度 | SP 移动,亚纳秒 | mallocgc:几十 ns + cache miss |
| 回收 | 函数返回自动回收(零成本) | GC 标记-清扫,写屏障记录指针 |
| 对 GC 的负担 | 无(对象不可达自栈帧外) | scan 类对象进入 GC 图遍历 |
| 谁来决定 | 编译器逃逸分析静态决定——Go 没有手动堆释放,一切看"生命周期能否被静态证明" | |
Escape Analysis · -gcflags='-m'
$ go build -gcflags='-m' ./… ./main.go:10:9: &x escapes to heap ./main.go:15:2: moved to heap: x ./main.go:20:9: inlining call to f # 更详细(两轮 -m) $ go build -gcflags='-m -m' ./… # 关闭内联看"未优化"真相: $ go build -gcflags='-m -l' ./…
escapes to heap = 该值的分配落到堆;moved to heap = 变量本身被提升到堆(闭包捕获等)
| 编译器问的问题 | 答案决定 |
|---|---|
| 值的引用会不会"流出"当前函数? | 流出(返回指针/存堆容器/进 channel/装箱接口)→ 堆 |
| 大小能静态确定吗? | 运行期才知道大小(变长 make)→ 堆 |
| 体积放得进栈帧预算吗? | 超大对象 → 直接堆(栈帧有预算上限) |
| 被调用函数会内联吗? | 内联后"逃逸"可能消失——小函数封装指针返回值常被内联救回来 |
Five Classic Escapes
// ① 返回局部指针(最经典) func f() *int { x := 42 return &x // &x escapes to heap } // ② interface 参数(fmt 全家桶) func g() { n := 10 fmt.Println(n) // n 装箱 → 逃逸 } // ③ 闭包捕获引用 func h() func() int { x := 0 return func() int { x++; return x } // moved to heap: x }
// ④ 动态大小(运行期才知道 n) func mk(n int) []byte { return make([]byte, n) // make escapes: 大小不可静态定 } // ⑤ 超大对象 / 存入堆容器 func big() { s := make([]int64, 8192) _ = s // 大到栈帧放不下 → 堆 } func store(m map[int]*T) { m[1] = &T{} // 存进堆上的 map → 逃逸 }
① 改成值返回:栈上完成;② Println 换具体类型函数:不装箱;③ 闭包改捕获值拷贝:不 moved;④ 常量长度 make([]byte, 32) 且不逃出:栈。用 -gcflags='-m' 逐一验证——面试现场跑一遍是杀手锏。
TCmalloc-style · 3-level
Tiny / Small / Large
| 级别 | 判定 | 路径 | 优化点 |
|---|---|---|---|
| tiny | <16B 且不含指针(noscan) | mcache 的 tiny 分配器:多个微对象合并进同一个 16B 块,偏移对齐 | 微对象(bool/int8/小 string header 外的数据)不再各占一个 size class——合并省内存、省分配次数 |
| small | ≤32KB | 按大小映射到 67 个 size class,走 mcache → mcentral | class 表离线生成(mksizeclasses.go),把"碎片浪费 + 尾部浪费"最优化 |
| large | >32KB | 跳过 mcache/mcentral,直接 mheap 按页分配专属 span | 避免大对象占用/击穿某一 class 的缓存;整 span 归还简单 |
span class = size class × 2(scan/noscan,共 136):含指针对象走 scan 类,GC 需要扫描其指针位图(heap bitmap);纯数据对象走 noscan,GC 整块跳过。struct{ b byte; x []int }(含 slice 头指针)是 scan;[16]byte 是 noscan。
连续分配 byte/int8 等微对象时共享 16B 块(类似分区分配):100 个 1B 对象从"100×8B 类"压缩到约 7 个 16B 块。限制:块内一个对象存活则整块存活——微对象的"伪泄漏"可能存在,但影响被 16B 封顶。
Size Classes · Roundupsize
// runtime/sizeclasses.go(由 makesizeclasses.go 生成) // class bytes/obj bytes/span objects max waste // 1 8 8192 1024 87.50% // 2 16 8192 512 43.95% // 3 24 8192 341 29.27% // 4 32 8192 256 21.88% // 5 48 8192 170 31.52% // … // 32 1024 8192 8 12.40% // 67 32768 8192 0? — 32768 是上限 roundupsize(n) // 返回 n 字节向上对齐到的 class 大小
| 现象 | 解释 |
|---|---|
make([]byte, 5) 的 cap 是 8 | 5B 向上对齐到 class 1(8B)→ cap 反推为 8(slice deck 第 9 页同款) |
make([]bool, 17) 的 cap 是 24 | 17B → class 3(24B) |
| class 1 的 max waste 高达 87.5% | 申请 1B 占 8B 槽——小类允许高浪费换低碎片,大类的浪费率被表压到 ~12% |
| 相邻类间隔设计(8,16,24,32,48,64…) | 离线优化:在"内部碎片"与"span 尾部浪费"间找全局最优,不是等比数列 |
每个对象槽位在 heap bitmap 中有标记位(指针/标量 + 标记位),scan 类对象按位图扫描——size class 的定长切分是位图布局的前提。
Arena · Span · Scavenger
| 单位 | 大小与职责 |
|---|---|
| arena(heapArena) | 64 位平台 64MB(heapArenaBytes):向 OS 申请/归还的 granularity;每个 arena 带一份元数据(spans 映射 + heap bitmap) |
| span(mspan) | 页(8KB)的倍数;small 对象的 span 固定 8KB 定长切分;large 对象独占整段 span |
| page | 8KB(pageSize)——mheap 分配/回收的最小单位 |
| heap bitmap | 每个机器字 4 bit:标记位(GC 三色)+ 指针/标量位——GC 扫描与写屏障的底账 |
// GC 后清理的 span 内的空闲页: // madvise(MADV_DONTNEED / MADV_FREE) // → HeapReleased(RSS 实际下降) // 两个触发源: // 1. 后台 scavenger(按比例持续归还) // 2. GOMEMLIMIT 迫近时加速归还
| 观察口径 | 含义 |
|---|---|
| Sys(ReadMemStats) | 向 OS 要过的总量(含未归还) |
| HeapReleased | 已 madvise 归还 OS 的部分 |
| Sys − HeapReleased | GOMEMLIMIT 的统计口径(gc-guide) |
MADV_FREE vs DONTNEED 因 OS 而异:Linux 上 RSS 下降延迟不同,监控口径注意(go.dev/doc/gc-guide 提及)
Contiguous Stacks
| 机制 | 内容 |
|---|---|
| 初始大小 | 经典 2KB(stackMin = 2048,src/runtime/stack.go);Go 1.19 起按历史平均使用量自适应初始化(release notes:"allocate initial goroutine stacks based on the historic average stack usage"),减少刚启动就扩栈的拷贝 |
| 增长:连续栈(1.3+) | 函数序言比较 SP 与 stackguard0 → 不够则 morestack → newstack:分配 2× 大小新栈,memmove 整栈拷贝并修正指针(栈上指针可定位修正)——旧栈释放 |
| 收缩 | GC 时检查:栈实际使用 < 1/4 容量 → 减半收缩(shrinkstack),防止"峰值栈"长期占内存 |
| 上限 | 64 位默认 1GB、32 位 250MB(debug.SetMaxStack 可调)——超限 fatal "goroutine stack exceeds …"(递归失控的兜底) |
// 分段栈(Go 1.2 前):栈不够 → 新挂一段 // 问题:hot loop 跨段边界反复 morestack/lessstack // → "分段栈税"(hot split problem) // 连续栈:直接搬新家 // 代价:一次 memmove + 指针修正 // 收益:段边界消失,hot loop 无惩罚 // 栈天然连续 → 缓存友好
对比线程:OS 线程默认栈 1–8MB 且创建昂贵——"10 万 goroutine"的前提就是 2KB 级初始栈(见 gmp-scheduler deck)
Tuning · GOGC / GOMEMLIMIT
目标堆 = live heap + (live heap + GC roots) × GOGC/100(roots 纳入自 Go 1.18)。官方例子:live 8MiB + roots 2MiB → GOGC=100 时新堆 +10MiB、总量 ~18MiB。翻倍 GOGC ≈ 翻倍内存、约省一半 GC CPU——纯 CPU/内存折衷。
口径 = Sys − HeapReleased(runtime 看得见的全部内存)。是软限制:"只承诺合理努力";配套 GC CPU limiter(约 50% GC CPU、2×GOMAXPROCS 窗口)——保"最多慢 2 倍"而不是无限 GC 打嗝。
| 场景 | 官方推荐(gc-guide) |
|---|---|
| 容器内存固定且独占 | GOGC=off + GOMEMLIMIT=容器额度的 90~95%——"资源经济性最大化"(guide 原文),留 5–10% 给 runtime 看不见的开销 |
| 内存与他人共享 / 额度模糊 | 保留 GOGC(较小值)+ GOMEMLIMIT 组合 |
| CLI/桌面小工具 | 不设 limit——GC 已足够好,设置反而复杂化 |
| live heap 本身逼近 limit | thrashing 警告:GC 连转程序停摆——soft limit + limiter 是"逃生门",最终仍需 OOM 式失败 |
Leak Patterns
| 模式 | 机理 | 修法 |
|---|---|---|
| ① 截取引用大数组 | big[:1] 的 header 钉住整个底层数组(slice deck 第 15 页) | slices.Clone / copy 出小数组 |
| ② goroutine 泄漏 | goroutine 阻塞在无人读的 channel / 忘 unlock / 死循环——永不退出,栈+引用全留存(最常见!) | context 取消 + select 默认分支;errgroup 收口;goleak 测试 |
| ③ 全局 map 只增不减 | cache 无淘汰:key 永久累积 | 限容 LRU / 定期清理 / weak 指针思路(1.20 runtime.AddCleanup 等) |
| ④ time.Ticker/Timer 不 Stop | Ticker 每 tick 都有;1.23 前 Timer 未 Stop/未读 channel 前不释放 | defer t.Stop();1.23 起 Timer/Ticker GC 可回收(未被引用时) |
| ⑤ 闭包长持有大对象 | 闭包注册进全局回调/长生命周期结构,捕获的变量全被钉住 | 捕获前瘦身(只留所需字段) |
| ⑥ sync.Pool 放大 | 大对象进 Pool + GC 不及时 → 两代间驻留;或 Pool 本身被长生命周期对象引用 | 只放可重建的纯缓冲;控制对象大小 |
Diagnostics
// ① pprof heap(net/http/pprof 或手动) go tool pprof -sample_index=inuse_space \ http://svc/debug/pprof/heap // inuse_space: 当前存活字节 —— 查泄漏 // alloc_space: 累计分配字节 —— 查分配热点 // ② GC 运行轨迹 GODEBUG=gctrace=1 ./app // gc 12 @8.4s 3%: 4.1->8.2->4.4 MB, 2 MB stacks, // … 4+6+4 ms clock, … 12-4-4 MB goal … // 堆:开扫前->峰值->扫后 // ③ runtime/metrics(结构化指标) // /gc/heap/live:bytes · /memory/classes/…
| 工具 | 用途 / 关键读数 |
|---|---|
| pprof goroutine profile | goroutine 泄漏定位:数万 goroutine 堆在同一个 chan receive 栈=铁证 |
| runtime.ReadMemStats | HeapAlloc / HeapSys / HeapReleased / Sys:对账 GOMEMLIMIT 口径 |
| gc-trace 的 goal 行 | …-MB goal 显示 GOGC/GOMEMLIMIT 算出的目标堆,验证配置是否生效 |
| go test -bench . -benchmem | B/op、allocs/op:微基准回归分配次数——优化的第一指标 |
| GODEBUG=allocfreetrace / delve | 细粒度追踪单个对象分配(重,仅小复现用) |
速查 · Cheat Sheet
面试前扫一眼、线上排查时照着做:左列是"内存怎么一路分下来",右列是"用多了怎么调"。
① 分配路径判定:按大小对号入座
| 对象 | 走哪条路 | 加锁 |
|---|---|---|
| <32KB 小对象 | 本 P 的 mcache 取现成 span,按 size class 切一块 | 无锁 |
| 小对象缺货 | 向 mcentral 补一个 span(只锁这一个大小类);mcentral 也空则向 mheap 切页,页不够才向 OS 申请 arena | 类锁 → 全局锁 |
| ≥32KB 大对象 | 跳过前两级,直接走 mheap 按页数分配 | 全局锁 |
| <16B 且无指针 | tiny 分配器:多个微对象合并进同一个 16B 块 | 无锁 |
② 一口气报出的数字
16B tiny · 32KB 大对象分界 · 67 类 · 8KB 页 · 64MB arena · 136 span 类 · 栈 2KB/上限 1GB · GOGC 默认 100 · GOMEMLIMIT = Sys − HeapReleased
③ 两个旋钮怎么选
| 旋钮 / 组合 | 含义与适用 |
|---|---|
GOGC | 相对量:目标堆 = live +(live + roots)× GOGC/100。不知道设什么就别动;调小换内存,调大换 CPU |
GOMEMLIMIT | 绝对软上限(Go 1.19+)。容器里优先用它:只承诺尽力维持,触顶只多做 GC,不 panic |
| 两者同设 | 取更小的那个目标生效——GOMEMLIMIT 兜上限,GOGC 管平常节奏 |
④ 线上排查三步
inuse_space 涨但 QPS 平稳 → 真泄漏;跟着 QPS 涨 → 只是压力大
优先看 alloc_space:高频小对象决定 GC 频率;allocs/op 是回归指标
Sys 高但 HeapInuse 低 → 已归还;HeapIdle 不降 → 碎片化
Interview QA · 1/2
先盖住答案自答一遍,再逐题对照——答不上来的那几题,就是今晚要补的地方。
编译器逃逸分析决定,用 go build -gcflags='-m' 观测。指针不一定逃逸:能证明引用不出函数(常被内联救回)就在栈上;值类型也可能逃逸(接口装箱、进堆容器、闭包捕获)。
返回局部指针、接口参数装箱(fmt)、闭包捕获引用、动态大小 make、超大对象;变体:指针存入 map/slice、指针发 channel。每类都能给出"消除逃逸"的改法并现场验证。
栈分配 = 栈指针移动几条指令,回收随函数返回自动完成,无锁无元数据无 GC;堆走 mallocgc 全路径(查表→mcache→GC 标记)。附带收益:栈数据热缓存、对 GC 零负担。
借鉴 TCMalloc 的线程缓存思想:mcache per-P 无锁(绝大多数分配终结于此)→ mcentral 每个大小类一把锁 → mheap 全局页堆。Go 的改造:缓存挂在 P 而非线程、与 GC 清扫深度集成。
tiny:<16B 且无指针,mcache 内多个微对象合并进 16B 块省内存;small:≤32KB 走 67 个 size class;large:>32KB 跳过前两级直达 mheap。另按是否含指针分 scan/noscan 双类,GC 只扫 scan。
分配粒度表:8B 起步到 32KB 共 67 类,每类 span 固定 8KB 定长切分。分配按类向上取整(roundupsize),slice 的 cap 从分配字节反推——make([]byte,5) 实际 8B 类 cap=8。类间隔由工具离线优化碎片。
初始 2KB(stackMin=2048),1.19 起按历史平均栈用量自适应初始化。不够时 morestack:分配 2× 新栈、整栈 memmove 并修正指针(连续栈,1.3 起取代分段栈解决 hot split)。GC 时使用率 <1/4 减半收缩。上限 64 位 1GB。
分段栈在段边界会反复 more/lessstack——热循环跨边界时"分段税"极高且不可预期。拷贝栈把代价变成偶发的一次 memmove,摊销后趋近于零,且栈内存连续缓存友好。这就是 Go 1.3 的关键决策。
Interview QA · 2/2
同样先自答再对照。这组题的高频失分点是"只背结论不给数字"——每个答案都要带上具体数值。
GC 触发的堆目标:live heap + (live+roots)×GOGC/100(roots 计入自 1.18)。翻倍 GOGC ≈ 翻倍内存换约一半 GC CPU。它是相对量——容器内存固定时算不准绝对值,这是 GOMEMLIMIT 出现的原因。
口径是 runtime 全部内存(Sys−HeapReleased)。软=尽力维持不保证;若存活堆逼近上限,GC 会连转(thrashing),所以配 GC CPU limiter:GC CPU 占比上限约 50%(2×GOMAXPROCS 窗口)——宁可慢 2 倍也不无限打嗝,之后仍 OOM 式失败。
官方 gc-guide 推荐:内存固定且归 Go 独占的容器,关 GOGC、设 GOMEMLIMIT 为额度 90–95%——"资源经济性最大化"(最小 GC 频率维持上限)。共享内存场景保留 GOGC;CLI 工具不设。
GC 只管可达性,泄漏=不需要的对象仍被引用。六大模式:goroutine 泄漏(最常见)、切片截取钉住大数组、全局 map 无淘汰、Ticker 不 Stop(1.23 前 Timer)、闭包长持大对象、sync.Pool 大对象驻留。
gc N @Ts P%: 堆(扫描前→峰值→扫后)+ 栈等;goal 是本轮目标堆——验证 GOGC/GOMEMLIMIT 是否生效;P% 是 GC 累计 CPU 占比。配合 pprof:alloc_space 找分配热点,inuse_space 找泄漏。
mcache 挂在 P 上,同一时刻只有一个 G 在该 P 运行,天然无竞争。本类 span 用尽 → 向 mcentral 取新 span(同类锁);mcentral 无货 → mheap 申请页(全局锁);>32KB 大对象跳过前两级直达 mheap。
不是。逃逸是语义必需:返回的对象本来就要比函数活得久(构造器、缓存填充、异步任务的上下文)。真正的坏事是"高频热路径上被装箱/不必要地返回指针"——优化方向是值传递、预分配、减少接口装箱,而不是消灭一切逃逸。
Related & References
Go GC →(三色标记与写屏障:分配的"下游账单")
GMP 调度模型 →(mcache 挂在 P 上、栈扩容的调度交互)
切片:结构与扩容 →(roundupsize 与 cap 反推)
sync 并发原语底层 →(per-P 无锁设计同源)
OS · 内存布局与堆分配 →(malloc 的 brk/mmap 分流)
成本观 → 栈免费 / 堆昂贵 → 逃逸分析搬运
五类逃逸 → 返箱闭变巨 → -m 现场验证
分配 → tiny/small/large → mcache→mcentral→mheap
治理 → 2KB 连续栈 / GOGC+GOMEMLIMIT / 泄漏排查
参考来源(本 deck 全部结论可溯源至下列一手材料)
| src/runtime/malloc.go(对照 Go 1.27) | 文件头 "Memory allocator" 总注释:三级架构、tiny/small/large 分级、arena/span/bitmap |
| src/runtime/sizeclasses.go | 67+1 size class 表(8B–32KB、8KB span、max waste)与生成器 makesizeclasses.go |
| src/runtime/stack.go | stackMin=2048、连续栈 grow/copy(newstack)、shrinkstack 收缩 |
| go.dev/doc/gc-guide | GOGC 公式、GOMEMLIMIT 软语义与 50% limiter、推荐组合 |
| go.dev/doc/go1.19 | GOMEMLIMIT 引入;goroutine 初始栈按历史平均自适应 |
| go.dev/doc/go1.23 | Timer/Ticker 无引用后可被 GC 回收(泄漏模式④的版本边界) |
| go.dev/wiki/LeakingGoRoutines · go.dev/blog/ismmkeynote | goroutine 泄漏模式;GC 演进官方叙述(SLO 视角) |