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+)、泄漏排查工具箱

内存分配是 Go runtime 三大件之一,和 GC、调度紧密咬合。这份 deck 的主线:先建立"栈分配免费、堆分配昂贵"的成本观,讲清逃逸分析的判定逻辑和五类典型场景;然后展开 TCMalloc 式三级分配架构和对象分级;最后是 goroutine 栈、GOGC/GOMEMLIMIT 两个治理旋钮和泄漏排查。所有结论对照 Go 1.27 源码与官方 GC 指南。

开场 · Why Memory Allocation

你写下一行 make,runtime 在背后跑了一整套物流系统

先看一个能在脑子里算出来的场景,再决定要不要往下读。

场景:一个 QPS 5000 的 Go 服务

每个请求都要拼一个 JSON、建几个 struct、切几次 slice。保守按每请求 200 次堆分配算——乘起来就是每秒 100 万次分配请求。

如果没有这套分配器结果
每次都找 OS 要内存一次系统调用是微秒级,100 万次就是秒级卡顿——服务直接不可用
全局一把锁保护一个池子多核互相排队,核越多堵得越死,机器永远跑不满
手动 new / delete由写代码的人自己记账——悬垂指针与泄漏随之而来

Go 的三个回答 = 本 deck 的三条主线

① 能不进堆就不进堆

编译器用逃逸分析证明"这个变量活不过当前函数",就把它留在栈上,函数返回时随栈帧一起销毁,零 GC 成本。——见第 4、5 页

② 必须进堆的,走三级缓存

每个逻辑 CPU(P)有自己的私有小仓库 mcache,绝大多数分配完全无锁;缺货才逐级上溯到 mcentralmheap,由它们偶尔向 OS 批发。——见第 6–9 页

③ 用多了要能调、能查

进程总内存由 GOGC(相对量)与 GOMEMLIMIT(绝对软上限)两个旋钮约束,配合 pprof 定位"到底谁在吃内存"。——见第 11–13 页

先建立数量级直觉:栈分配 ≈ 一次栈指针移动,亚纳秒;堆分配 ≈ 几十纳秒,还要替未来 GC 的标记清扫买单;而向操作系统要内存是微秒级,是堆分配的上百倍。整个内存分配器做的事,就是用多级缓存把昂贵的系统调用摊销掉。
开场先给一个能算出来的场景:QPS 5000、每请求 200 次分配,就是每秒一百万次。然后问"没有分配器会怎样"——每次找 OS 要内存是微秒级、全局锁会堵死多核、手动管理带来悬垂指针。Go 的三个回答正好是本 deck 的三条主线:逃逸分析把能留栈的留栈、三级缓存让绝大多数分配无锁、两个旋钮加 pprof 负责治理。最后给数量级直觉:栈亚纳秒、堆几十纳秒、系统调用微秒级——整套设计就是把系统调用摊销掉。

读之前 · Before You Read

先花一分钟对齐九个词,后面每一页都会用到

下面这些术语全部是本 deck 真正会用到的,没有凑数;前置知识不在本 deck 内的,用链接指过去。

术语(中英对照)一句话白话定义
stack每个函数的一小块私有内存。函数一返回就整块作废,不需要谁来回收
heap全进程共享的大池子。谁都能申请,但没人知道该什么时候还,所以要靠 GC 定期扫
逃逸分析 escape analysis编译器在编译期静态推断"这个变量会不会活过当前函数"——会就放堆,不会就放栈
GC 垃圾回收runtime 定期找出"已经没人引用的对象",把它们的内存收回池子的后台流程
pageGo 向操作系统批发内存的最小单位,8KB。零售给业务代码的小块都从页里切出来
术语(中英对照)一句话白话定义
span mspan把若干连续页切成"定长小块"的管理单元,是分配器真正发货的货架
大小类 size class预定义好的几十档固定字节数(8B…32KB)。申请时向上取最近一档,牺牲一点空间换低碎片
arena 内存区Go 一次向操作系统申请的 64MB 大块地址空间。进程里所有 Go 堆都在某块 arena 里
P 处理器 processorGMP 模型里的"逻辑 CPU"。每个 P 有自己的私有仓库 mcache——这就是无锁分配的来源
RSS 常驻内存进程真正占用的物理内存。监控图上看到的"内存"通常是它,所以它比 Go 自己的堆指标滞后
最小心智模型(后面所有机制都是这句话的实现细节):Go 的内存只有一句话——能在编译期证明活不过当前函数的,放栈上随函数销毁;证明不了的,进堆,由三级缓存按大小类切一块给它,最后由 GC 回收。
三篇前置(本 deck 不展开):gmp-scheduler.html —— P / M / G 分别是什么,为什么"每个 P 一个仓库"就能无锁;go-gc.html —— 三色标记与清扫的完整流程;../os/memory-layout.html —— 用户态 malloc 的 brk/mmap 分流,"批发零售"思想与 Go 三级架构同构。
术语页给九个词:栈、堆、逃逸分析、GC、页、span、大小类、arena、P、RSS——后面每页都会用到。最小心智模型一句话:编译期能证明活不过当前函数就放栈,证明不了就进堆,按大小类切一块,最后由 GC 回收,后面所有机制都是这句话的实现细节。前置指三个 deck:GMP 讲 P 是什么、Go GC 讲回收流程、OS 内存布局讲 malloc 的批发零售思想(与 Go 三级架构同构)。

Stack vs Heap

栈分配几乎免费:一次 SP 移动 vs 一次 mallocgc

回到开场那 100 万次分配——它们的代价到底差多少?先把两个"仓库"的成本摊开看,这是理解后面全部机制的起点。

栈分配:几条指令的 bump allocation

栈指针下移 + 函数返回时整体上移回收——不需要 GC、不需要锁、不需要记录元数据。局部变量天然随栈帧一起消失,内存局部性好(热缓存)。这就是"逃逸分析优化"省下的全部成本。

堆分配:mallocgc 的完整路径

size class 查表 → mcache 取槽 → (可能)mcentral/mheap → GC 要标记、扫描、回收——含指针对象还给 GC 增加图遍历负担。量级感受:栈分配亚纳秒级,堆分配几十纳秒起步,再摊上 GC 的 CPU。

维度
分配速度SP 移动,亚纳秒mallocgc:几十 ns + cache miss
回收函数返回自动回收(零成本)GC 标记-清扫,写屏障记录指针
对 GC 的负担无(对象不可达自栈帧外)scan 类对象进入 GC 图遍历
谁来决定编译器逃逸分析静态决定——Go 没有手动堆释放,一切看"生命周期能否被静态证明"
面试口径:"Go 的变量在栈还是堆不由 var/指针决定,由逃逸分析决定;指针不一定逃逸(可被内联/证明不出函数),值类型也可能逃逸(被接口装箱)。"
先立成本观:栈分配就是栈指针挪一挪,返回时自动回收,没有 GC 什么事;堆分配走完整 mallocgc,后面还拖着 GC 的标记扫描。所以逃逸分析的本质是"把分配从右列搬到左列"。要纠正的误区是"指针必逃逸":不一定,编译器能证明指针不出函数就不会逃逸;反过来值类型被接口装箱照样进堆。

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)→ 堆
体积放得进栈帧预算吗?超大对象 → 直接堆(栈帧有预算上限)
被调用函数会内联吗?内联后"逃逸"可能消失——小函数封装指针返回值常被内联救回来
原理一句话:逃逸分析 = 编译期的"对象图可达性"分析(构建变量引用流,判断生命周期是否能在编译期证明不超过所在栈帧)。它是保守的:证明不了就按逃逸处理。
观测工具一行命令:go build -gcflags 横杠 m,输出里 escapes to heap 和 moved to heap 就是结论,两个 m 更详细。要点有二:一是关内联(横杠 l)才能看到未优化的真实判定,内联经常把"逃逸"救回来,这是小函数封装的建议依据;二是逃逸分析本质是编译期的引用流可达性分析,策略保守——证明不了不逃逸就按逃逸处理,因为悬垂指针的代价远高于多分配。

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' 逐一验证——面试现场跑一遍是杀手锏。

记忆框架:五个字诀——返(返回指针)箱(接口装箱)闭(闭包捕获)变(动态大小)巨(超大对象);再加两个高频变体:指针存 map/slice、指针发 channel。
五类逃逸每个配了最小例子:返回指针、interface 装箱、闭包捕获、动态大小、超大对象。记忆口诀五个字:返箱闭变巨,外加指针进容器和进 channel 两个变体。右下角的对照实验是面试杀手锏:每类都给一个"逃逸消失"的改法,现场用横杠 m 验证,比背结论有说服力得多。第四类注意常量长度且不逃出的 make 可以留在栈上。

TCmalloc-style · 3-level

Go 分配器三级架构:mcache → mcentral → mheap

Go 内存分配三级架构 分配自上而下:goroutine 的 mallocgc 先查所属 P 的 mcache,无锁命中即返回;mcache 按大小类缓存 span,用尽向对应 size class 的 mcentral 申请;mcentral 维护该类的 partial/full span 链,无货再向全局 mheap 申请页;mheap 管理页堆并在不足时向操作系统 mmap 新的 64MB arena。锁竞争被逐级化解:mcache 无锁、mcentral 每类一锁、mheap 全局一锁。 size class 命中 span 用尽 → 取新 span 无货 → 申请整页 span 页不够 → mmap 新 arena mallocgc(size, typ, needzero) 所有堆分配的唯一入口(tiny/small/large 分级分流) mcache · 每个处理器 P 一个 缓存各 size class 的 mspan;分配/回收零锁(P 私有) GC 后本地 span 扫描回收(flush) mcentral · 每个 size class 一个(×136) 维护该类的 span 链:partial(有空槽)/ full(满) 各分 swept / unswept 两队,随 GC 轮换(sweepgen) 互斥锁粒度 = 单一 size class(不同类互不竞争) mheap · 全局页堆(8KB 页) 按页管理/free 索引;一把全局锁;scavenger 归还 OS spans ↔ arena 映射由 heapArena 元数据记录 OS · mmap / madvise(heapArenaBytes = 64MB / arena) 锁竞争的逐级化解(TCMalloc 核心思想) ① mcache:P 私有 → 无锁。绝大多数分配在这一层 终结,与其它 P 完全并行(源自 TCMalloc 的 thread-cache 思想,Go 把"线程"换成 P) ② mcentral:同类共享一把锁——不同 size class 之间不互相阻塞 ③ mheap:全局一把锁,但只有"向 OS 要/还页" 才走到,频率极低 演变注意点 · mcache 早期挂在 M 上,后移到 P(跟 GMP 对齐) · mcentral 的 partial/full 链在 1.16 换成两组 spanSet(partial swept/unswept、full swept/unswept) · 大对象(>32KB)跳过 mcache/mcentral 直达 mheap
三级架构一张图讲完:mallocgc 是唯一入口,先查本 P 的 mcache——P 私有无锁,绝大多数分配在这层终结;用尽向对应 size class 的 mcentral 要新 span,锁粒度只有单个类;mcentral 无货再向全局 mheap 申请页,页不够才 mmap 新的 arena。这就是 TCMalloc 的 thread-cache 思想,Go 把线程换成了 P。注意 mcache 早期挂在 M 上后移到 P,以及大对象跳过前两级直达 mheap。

Tiny / Small / Large

对象三级:tiny(<16B 无指针)/ small(≤32KB)/ large

级别判定路径优化点
tiny<16B 且不含指针(noscan)mcache 的 tiny 分配器:多个微对象合并进同一个 16B 块,偏移对齐微对象(bool/int8/小 string header 外的数据)不再各占一个 size class——合并省内存、省分配次数
small≤32KB按大小映射到 67 个 size class,走 mcache → mcentralclass 表离线生成(mksizeclasses.go),把"碎片浪费 + 尾部浪费"最优化
large>32KB跳过 mcache/mcentral,直接 mheap 按页分配专属 span避免大对象占用/击穿某一 class 的缓存;整 span 归还简单

scan vs noscan:×2 的 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。

tiny 合并的实际效果

连续分配 byte/int8 等微对象时共享 16B 块(类似分区分配):100 个 1B 对象从"100×8B 类"压缩到约 7 个 16B 块。限制:块内一个对象存活则整块存活——微对象的"伪泄漏"可能存在,但影响被 16B 封顶。

面试口径:"Go 分配器按 (大小, 是否含指针) 二维分流:tiny 合并、small 走 67 类缓存、large 直达页堆;scan/noscan 让 GC 只扫需要扫的对象。"
对象按二维分流:大小和是否含指针。小于 16 字节且无指针的走 tiny 分配器,多个微对象合并进一个 16 字节块,这是很多人不知道的细节。32KB 以下走 67 个 size class;以上直接跳过前两级向页堆要整段。scan 和 noscan 双 span 类让 GC 只扫含指针的对象。tiny 合并的边界也要说:块内一个存活整块存活,微对象可能有 16 字节封顶的伪泄漏。

Size Classes · Roundupsize

size class 表:分配粒度的全部秘密

// 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 大小
span 8KB:每类 span 固定 8192 字节(pageSize),objects 列是该 span 能切多少个该类对象——同页同类的"定长切分"让 free list 管理退化为位图/链表。
现象解释
make([]byte, 5) 的 cap 是 85B 向上对齐到 class 1(8B)→ cap 反推为 8(slice deck 第 9 页同款)
make([]bool, 17) 的 cap 是 2417B → class 3(24B)
class 1 的 max waste 高达 87.5%申请 1B 占 8B 槽——小类允许高浪费换低碎片,大类的浪费率被表压到 ~12%
相邻类间隔设计(8,16,24,32,48,64…)离线优化:在"内部碎片"与"span 尾部浪费"间找全局最优,不是等比数列

与 GC 的咬合

每个对象槽位在 heap bitmap 中有标记位(指针/标量 + 标记位),scan 类对象按位图扫描——size class 的定长切分是位图布局的前提。

size class 表是分配器的核心数据结构:67 个类、每个 span 固定 8KB、由工具离线生成并优化碎片率。三个要报出的数:8KB 页、67 类、16B tiny 上限。表里最扎眼的是 class 1 的 max waste 87.5%——申请一个字节占八字节槽,小类用高浪费换低碎片。roundupsize 反推 cap 的机制在 slice 那份 deck 已详细推过,这里串起来:slice 扩容的"意外 cap"就是这张表决定的。

Arena · Span · Scavenger

内存的组织形态:arena → span → 对象槽

单位大小与职责
arena(heapArena)64 位平台 64MB(heapArenaBytes):向 OS 申请/归还的 granularity;每个 arena 带一份元数据(spans 映射 + heap bitmap)
span(mspan)页(8KB)的倍数;small 对象的 span 固定 8KB 定长切分;large 对象独占整段 span
page8KB(pageSize)——mheap 分配/回收的最小单位
heap bitmap每个机器字 4 bit:标记位(GC 三色)+ 指针/标量位——GC 扫描与写屏障的底账
为什么 arena 这么大:向 OS 的 mmap 次数要少(每次 syscall 有成本),64MB 粒度让地址空间映射摊销;代价是 Sys 数值看起来比实际用量大——差额就是 HeapReleased 的来历。

scavenger:把内存还给 OS 的后台机制

// GC 后清理的 span 内的空闲页:
// madvise(MADV_DONTNEED / MADV_FREE)
// → HeapReleased(RSS 实际下降)

// 两个触发源:
// 1. 后台 scavenger(按比例持续归还)
// 2. GOMEMLIMIT 迫近时加速归还
观察口径含义
Sys(ReadMemStats)向 OS 要过的总量(含未归还)
HeapReleased已 madvise 归还 OS 的部分
Sys − HeapReleasedGOMEMLIMIT 的统计口径(gc-guide)

MADV_FREE vs DONTNEED 因 OS 而异:Linux 上 RSS 下降延迟不同,监控口径注意(go.dev/doc/gc-guide 提及)

这页补齐内存的组织形态:64MB 的 arena 是向 OS 要地的粒度,8KB 的页是 mheap 管理的粒度,span 把页切成定长对象槽,heap bitmap 是 GC 扫描的底账。scavenger 负责把空闲页 madvise 还给 OS,归还量就是 HeapReleased,GOMEMLIMIT 的统计口径正是 Sys 减 HeapReleased。监控时注意 MADV_FREE 在 Linux 上 RSS 下降有延迟,别把曲线误读成泄漏。

Contiguous Stacks

goroutine 栈:2KB 起步,连续栈增长与收缩

机制内容
初始大小经典 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 …"(递归失控的兜底)

为什么 1.2 的分段栈被换成拷贝栈?

// 分段栈(Go 1.2 前):栈不够 → 新挂一段
// 问题:hot loop 跨段边界反复 morestack/lessstack
//   → "分段栈税"(hot split problem)

// 连续栈:直接搬新家
// 代价:一次 memmove + 指针修正
// 收益:段边界消失,hot loop 无惩罚
//      栈天然连续 → 缓存友好
栈也从堆来:goroutine 栈内存本身由 stackalloc 从栈 span 缓存分配——所以"栈分配免费"指分配动作,栈内存的管理仍走页系统;2KB 只是"足够小到可以无脑开"。

对比线程:OS 线程默认栈 1–8MB 且创建昂贵——"10 万 goroutine"的前提就是 2KB 级初始栈(见 gmp-scheduler deck)

goroutine 栈四件事:初始 2KB,Go 1.19 起改为按历史平均用量自适应初始化,减少过早扩栈;增长走连续栈——新栈两倍大、整栈 memmove 加指针修正,替代早期分段栈就是为了消除热循环跨段的 hot split 税;GC 时使用率低于四分之一会减半收缩;上限 64 位 1GB,超限 fatal。一个反直觉点:栈内存本身也是从页系统分配的,免费的是分配动作不是内存。

Tuning · GOGC / GOMEMLIMIT

两个旋钮:GOGC 定频率,GOMEMLIMIT 定上限

GOGC:GC 触发目标(默认 100)

目标堆 = live heap + (live heap + GC roots) × GOGC/100(roots 纳入自 Go 1.18)。官方例子:live 8MiB + roots 2MiB → GOGC=100 时新堆 +10MiB、总量 ~18MiB。翻倍 GOGC ≈ 翻倍内存、约省一半 GC CPU——纯 CPU/内存折衷。

GOMEMLIMIT:软内存上限(Go 1.19+)

口径 = 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 本身逼近 limitthrashing 警告:GC 连转程序停摆——soft limit + limiter 是"逃生门",最终仍需 OOM 式失败
答题口径:"GOGC 是相对量(对 live heap 的倍数),容器时代它算不准绝对内存——GOMEMLIMIT 补的就是绝对上限这一维;两者正交可组合。"
两个旋钮的分工:GOGC 是相对量,目标堆等于存活堆乘以系数,翻倍 GOGC 就是拿内存换 GC CPU;GOMEMLIMIT 是 Go 1.19 的绝对软上限,口径是 Sys 减 HeapReleased,靠 GC CPU limiter 保证最坏慢两倍而不是无限打嗝。容器场景背官方推荐组合:GOGC 关掉加 GOMEMLIMIT 留百分之五到十的余量。thrashing 风险要提:存活堆本身逼近上限时 GC 连转,这是软限制的边界。

Leak Patterns

六大泄漏模式:GC 时代也会"漏"

模式机理修法
① 截取引用大数组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 不 StopTicker 每 tick 都有;1.23 前 Timer 未 Stop/未读 channel 前不释放defer t.Stop();1.23 起 Timer/Ticker GC 可回收(未被引用时)
⑤ 闭包长持有大对象闭包注册进全局回调/长生命周期结构,捕获的变量全被钉住捕获前瘦身(只留所需字段)
⑥ sync.Pool 放大大对象进 Pool + GC 不及时 → 两代间驻留;或 Pool 本身被长生命周期对象引用只放可重建的纯缓冲;控制对象大小
判定口诀:Go 的泄漏 = "对象不再需要,但仍被可达路径引用"。GC 只管可达性——所以所有泄漏本质都是引用链问题,从"谁还指着它"入手,而不是"为什么没回收"。
六大泄漏模式按频率排:goroutine 泄漏永远第一,阻塞在没人读的 channel 上永不退出;其次是切片截取钉住大数组和全局 map 无淘汰。Timer 要分版本说:1.23 起 Timer 和 Ticker 不再被引用就能被 GC 回收,之前的版本必须 Stop。判定口诀要背:GC 只管可达性,泄漏本质是引用链问题,排查思路是找谁还指着它。

Diagnostics

排查工具箱:pprof / gctrace / runtime/metrics

// ① 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 profilegoroutine 泄漏定位:数万 goroutine 堆在同一个 chan receive 栈=铁证
runtime.ReadMemStatsHeapAlloc / HeapSys / HeapReleased / Sys:对账 GOMEMLIMIT 口径
gc-trace 的 goal 行…-MB goal 显示 GOGC/GOMEMLIMIT 算出的目标堆,验证配置是否生效
go test -bench . -benchmemB/op、allocs/op:微基准回归分配次数——优化的第一指标
GODEBUG=allocfreetrace / delve细粒度追踪单个对象分配(重,仅小复现用)
排查顺序(工程习惯):先 gctrace 看是"分配太快"还是"驻留太多"→ alloc 高走 alloc_space 找热点,inuse 高走 inuse_space 找引用链 → goroutine profile 补 goroutine 泄漏。
工具箱按排查顺序讲:先用 gctrace 分流,目标是看分配快还是驻留多,goal 行能验证 GOGC 配置是否生效;alloc 累计高走 pprof 的 alloc_space 找分配热点,存活高走 inuse_space 找引用链;goroutine 泄漏用 goroutine profile,几万协程堆在同一个接收点就是铁证。benchmem 的 allocs 每操作数是优化第一指标。记住 pprof 端口生产要隔离加鉴权。

速查 · 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 是回归指标

最后看是否归还 OS

Sys 高但 HeapInuse 低 → 已归还;HeapIdle 不降 → 碎片化

速查页压两列:左边是分配路径判定表加必背数字,右边是两个旋钮的选型和线上排查三步。路径判定按大小对号入座——小对象走本 P 的 mcache 无锁,缺货才逐级下沉,大对象跳过前两级;数字要一口气报出 16B、32KB、67 类、8KB 页、64MB arena、136 span 类、2KB 栈、GOGC 与 GOMEMLIMIT 口径。治理三步:先看存活量判断是不是真泄漏,再看累计分配找热点,最后看内存有没有还给 OS。

Interview QA · 1/2

逃逸与分配 8 连问

先盖住答案自答一遍,再逐题对照——答不上来的那几题,就是今晚要补的地方。

1 · 怎么判断变量在栈还是堆?指针一定逃逸吗?

逃逸分析静态决定-gcflags='-m'

编译器逃逸分析决定,用 go build -gcflags='-m' 观测。指针不一定逃逸:能证明引用不出函数(常被内联救回)就在栈上;值类型也可能逃逸(接口装箱、进堆容器、闭包捕获)。

2 · 常见逃逸场景?

返箱闭变巨

返回局部指针、接口参数装箱(fmt)、闭包捕获引用、动态大小 make、超大对象;变体:指针存入 map/slice、指针发 channel。每类都能给出"消除逃逸"的改法并现场验证。

3 · 为什么栈分配快?快在哪?

SP 移动零 GC缓存友好

栈分配 = 栈指针移动几条指令,回收随函数返回自动完成,无锁无元数据无 GC;堆走 mallocgc 全路径(查表→mcache→GC 标记)。附带收益:栈数据热缓存、对 GC 零负担。

4 · Go 分配器的架构?和 TCMalloc 什么关系?

mcache/mcentral/mheap锁逐级化解

借鉴 TCMalloc 的线程缓存思想:mcache per-P 无锁(绝大多数分配终结于此)→ mcentral 每个大小类一把锁 → mheap 全局页堆。Go 的改造:缓存挂在 P 而非线程、与 GC 清扫深度集成。

5 · tiny/small/large 怎么分?tiny 有什么用?

<16B 无指针≤32KB合并块

tiny:<16B 且无指针,mcache 内多个微对象合并进 16B 块省内存;small:≤32KB 走 67 个 size class;large:>32KB 跳过前两级直达 mheap。另按是否含指针分 scan/noscan 双类,GC 只扫 scan。

6 · size class 是什么?为什么 make 的 cap 会变大?

67 类 8B–32KB8KB spanroundupsize

分配粒度表:8B 起步到 32KB 共 67 类,每类 span 固定 8KB 定长切分。分配按类向上取整(roundupsize),slice 的 cap 从分配字节反推——make([]byte,5) 实际 8B 类 cap=8。类间隔由工具离线优化碎片。

7 · goroutine 栈多大?怎么增长?怎么收缩?

2KB(1.19 自适应)2× 拷贝GC 减半

初始 2KB(stackMin=2048),1.19 起按历史平均栈用量自适应初始化。不够时 morestack:分配 2× 新栈、整栈 memmove 并修正指针(连续栈,1.3 起取代分段栈解决 hot split)。GC 时使用率 <1/4 减半收缩。上限 64 位 1GB。

8 · 为什么放弃分段栈改用拷贝栈?

hot splitmemmove 摊销

分段栈在段边界会反复 more/lessstack——热循环跨边界时"分段税"极高且不可预期。拷贝栈把代价变成偶发的一次 memmove,摊销后趋近于零,且栈内存连续缓存友好。这就是 Go 1.3 的关键决策。

QA 第一组覆盖逃逸与分配。第一题必须纠正"指针必逃逸"的误区。第五题报出三个数字:16B tiny 上限、67 个类、32KB 大对象分界。第七题带出 1.19 自适应初始化这个版本点。第八题讲 hot split 故事——为什么拷贝栈赢了,这是体现理解深度的题。

Interview QA · 2/2

治理与排查 7 连问

同样先自答再对照。这组题的高频失分点是"只背结论不给数字"——每个答案都要带上具体数值。

9 · GOGC 是什么?目标堆怎么算?

默认 100live×GOGC/100

GC 触发的堆目标:live heap + (live+roots)×GOGC/100(roots 计入自 1.18)。翻倍 GOGC ≈ 翻倍内存换约一半 GC CPU。它是相对量——容器内存固定时算不准绝对值,这是 GOMEMLIMIT 出现的原因。

10 · GOMEMLIMIT 为什么是"软"的?50% limiter?

Go 1.19Sys−HeapReleased防 GC 打嗝

口径是 runtime 全部内存(Sys−HeapReleased)。软=尽力维持不保证;若存活堆逼近上限,GC 会连转(thrashing),所以配 GC CPU limiter:GC CPU 占比上限约 50%(2×GOMAXPROCS 窗口)——宁可慢 2 倍也不无限打嗝,之后仍 OOM 式失败。

11 · GOGC=off + GOMEMLIMIT 什么时候用?

容器独占内存留 5–10% 余量

官方 gc-guide 推荐:内存固定且归 Go 独占的容器,关 GOGC、设 GOMEMLIMIT 为额度 90–95%——"资源经济性最大化"(最小 GC 频率维持上限)。共享内存场景保留 GOGC;CLI 工具不设。

12 · 常见内存泄漏场景?本质是什么?

引用链问题goroutine 泄漏

GC 只管可达性,泄漏=不需要的对象仍被引用。六大模式:goroutine 泄漏(最常见)、切片截取钉住大数组、全局 map 无淘汰、Ticker 不 Stop(1.23 前 Timer)、闭包长持大对象、sync.Pool 大对象驻留。

13 · gctrace 输出怎么看?

堆三段值goal 行GC CPU 占比

gc N @Ts P%: 堆(扫描前→峰值→扫后)+ 栈等;goal 是本轮目标堆——验证 GOGC/GOMEMLIMIT 是否生效;P% 是 GC 累计 CPU 占比。配合 pprof:alloc_space 找分配热点,inuse_space 找泄漏。

14 · mcache 为什么无锁?什么时候下沉到 mcentral/mheap?

P 私有span 用尽大对象直达

mcache 挂在 P 上,同一时刻只有一个 G 在该 P 运行,天然无竞争。本类 span 用尽 → 向 mcentral 取新 span(同类锁);mcentral 无货 → mheap 申请页(全局锁);>32KB 大对象跳过前两级直达 mheap。

15 · 逃逸一定是坏事吗?给"该逃逸"的例子。

不是生命周期比函数长就必须堆

不是。逃逸是语义必需:返回的对象本来就要比函数活得久(构造器、缓存填充、异步任务的上下文)。真正的坏事是"高频热路径上被装箱/不必要地返回指针"——优化方向是值传递、预分配、减少接口装箱,而不是消灭一切逃逸。

QA 第二组覆盖治理与排查。第十题讲透软限制的设计动机:limiter 用最坏慢两倍换不死锁在 GC 里。第十一题背官方推荐组合的适用条件。第十三题 gctrace 要能现场读出三段堆值和 goal 行。第十五题是价值观题:逃逸不是坏事,生命周期决定去留,要防的是热路径上的无谓装箱——避免把优化讲成洁癖。

Related & References

相关知识点与参考

同领域 deck

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.go67+1 size class 表(8B–32KB、8KB span、max waste)与生成器 makesizeclasses.go
src/runtime/stack.gostackMin=2048、连续栈 grow/copy(newstack)、shrinkstack 收缩
go.dev/doc/gc-guideGOGC 公式、GOMEMLIMIT 软语义与 50% limiter、推荐组合
go.dev/doc/go1.19GOMEMLIMIT 引入;goroutine 初始栈按历史平均自适应
go.dev/doc/go1.23Timer/Ticker 无引用后可被 GC 回收(泄漏模式④的版本边界)
go.dev/wiki/LeakingGoRoutines · go.dev/blog/ismmkeynotegoroutine 泄漏模式;GC 演进官方叙述(SLO 视角)
收尾页给同领域链接和参考来源。这份 deck 的数字都有出处:三级架构与分级出自 malloc.go 顶部注释,size class 表来自 sizeclasses.go,2KB 和自适应初始栈出自 stack.go 与 1.19 release notes,GOGC 公式与 GOMEMLIMIT 语义出自官方 GC 指南,Timer 的版本边界出自 1.23 release notes。复习时回到这些一手材料核对,别背二手转述。