阅读契约:不需要把“堆”当成坏词。先学会区分三个问题:对象为什么必须活得比当前函数久;程序每秒制造多少新对象;其中多少对象在 GC 时仍然活着。后半篇再进入逃逸分析、分配器与并发 GC 源码。
语言并不承诺对象一定放栈或堆;具体判断固定到 Go 1.26.0 tag 的 escape.go、 malloc.go、 mgc.go 与 mgcpacer.go。 测量接口来自官方 runtime/metrics、 runtime/pprof 与 Diagnostics 文档。
一、对象在堆上,不等于内存有问题
在 fetchd 里,局部变量如果只在当前函数计算一次,函数返回后就不再需要,编译器通常可以把它放在当前 goroutine 的栈上。
但一个结果被返回、被 channel 交给别的 goroutine,或被 slice 保存到函数外面,它就可能比当前调用活得更久。此时放到堆上是在保证正确生命周期,不是编译器“失败”。
真正值得追的是三件不同的事:分配速率——请求不断制造多少新对象;存活集合——GC 时还有多少对象不能回收; 延迟代价——分配和 GC 工作是否落到了用户请求上。只有这三者与性能目标发生冲突时,减少分配才有明确价值。
| 问题 | 第一份证据 | 不能单独推出 |
|---|---|---|
| 为什么在堆上 | -gcflags='-m=2' 与源码生命周期 | 它是否值得优化 |
| 一条路径分配多少 | Benchmark + -benchmem | 生产中是谁长期存活 |
| 谁占住了堆 | heap profile(in-use) | 历史累计分配速率 |
| 谁制造分配流量 | allocs profile(累计) | 当前 live heap |
| GC 是否影响延迟 | metrics、gctrace、execution trace | 单个对象该放栈还是堆 |
二、先回答“它要活多久”,再看栈或堆
Go 规范描述值、地址与可达行为,却不要求实现把某种语法固定放在栈或堆。编译器 escape analysis 的两条核心不变量直接写在源码注释里:
指向 stack object 的指针不能被存入 heap;指针不能比它指向的 stack object 活得更久。
只要编译器能证明地址不越过 frame 生命周期,new(T) 也可以落栈;反过来,普通局部变量被返回地址、放入更长寿命对象或闭包后就可能进堆。
//go:noinline
func stackResultChecksum() int64 {
result := fetchResult{
URL: "https://go.dev", Status: 200, Bytes: 42,
}
return result.Bytes + int64(len(result.URL))
}
//go:noinline
func escapingResult() *fetchResult {
return &fetchResult{
URL: "https://go.dev", Status: 200, Bytes: 42,
}
}
“返回指针一定慢”也不是可靠规则:inline、调用点传播与未来编译器版本都可能改变结果。这里加 //go:noinline 是为了让实验形状稳定,不是生产优化建议。
结论必须读当前构建的报告,再用 allocation count 验证;不能把某次 -m 输出当语言保证。
2.1 编译器建立带权 location graph
escape 包先把变量和隐式分配映射为 location,再把赋值建成有向边。边权是“解引用次数减取地址次数”:
p = &q 为 -1,p = q 为 0,p = *q 为 +1。
随后沿图查找可能破坏两条不变量的路径;某个地址流到 heap 或可能活得更久的位置时,对应 location 被标为必须 heap allocate。
跨函数信息则压缩成 parameter tags,供静态调用点继续传播。

图是保守近似,并不区分 struct 的不同字段或 slice 的不同元素;源码也明确说明分析通常不具备完整的 flow、path 或 context sensitivity。 “escapes to heap”因此表示编译器没有足够证明把它安全留在当前 frame,不表示这个对象一定泄漏,更不表示 GC 当前就有压力。
2.2 读 -m=2:看流向,不数关键词
对整个 fetchd 运行构建报告时,
memory_lab_test.go
中会出现 &fetchResult{...} escapes to heap,以及带运行时长度的
make([]fetchResult, 0, count) backing array 逃逸;真实 handler 的 batch 聚合还会报告
append escapes to heap。重点不是“出现 heap 三次”,而是解释每条数据的 owner 和最长寿命:
指针离开函数、slice backing array 由返回值持有、append 可能发布一个新数组。
cd go-runtime/examples/fetchd
go test -run '^$' -gcflags='-m=2' ./...
编译器还可能报告 interface 装箱、闭包捕获或日志参数的逃逸;它们中有些在热路径值得处理,有些是一次性初始化噪声。
按函数调用链过滤,再与 benchmark 的 allocs/op 对齐。如果报告说可能逃逸而测量仍为 0 alloc/op,优先相信最终生成代码和稳定测量,同时检查 inline、常量折叠与 benchmark 是否被优化掉。
2.3 slice 预分配减少的是 backing array 迁移
slice header 只有 pointer、len、cap;预分配并没有把 header “改成堆对象”,而是让已知上界的结果集合一次拿到足够 backing array。
从空 slice 连续 append 时,容量不足会调用 growslice,新建更大数组并复制旧元素。增长策略是当前 runtime 实现,不能把“永远翻倍”写进业务正确性。
// growth path
var results []fetchResult
for i := 0; i < count; i++ {
results = append(results, fetchResult{N: i})
}
// one backing allocation for a known upper bound
results := make([]fetchResult, 0, count)
for i := 0; i < count; i++ {
results = append(results, fetchResult{N: i})
}
本实验聚合 64 个结果时,按需增长稳定为 7 allocs/op、约 9.1 KiB/op,预分配为 1 alloc/op、约 4.8 KiB/op;返回指针为 1 alloc/op、64 B/op,而只计算校验和的值路径为 0 alloc/op。 这些数字是该实验形状的证据,不是跨平台常量。预分配过大同样会扩大 live heap;更好的输入是可信上界,而不是“越大越安全”。
三、mallocgc 先按大小和指针形状分流
继续跟同一批 64 个结果:
append 容量不足 → 申请更大的 backing array → 复制旧结果
没有预分配时,这个实验发生 7 次分配
每次申请最终都进入分配器
累计的新内存更快逼近下一次 GC 的 heap goal
这里要分清两层:growslice 决定“业务为什么又需要一块内存”,mallocgc 决定“runtime 从哪里拿这块内存”。
调高 GOGC 也许会推迟下一轮回收,却不会消除这七次 backing array 申请;减少分配与调整回收频率是两种不同动作。
Go 1.26.0 的 mallocgc 先处理零大小请求:返回共享的 zerobase。常规路径中,小对象不超过约 32 KiB;
无指针且小于 16 B 的对象可进入 tiny allocator,把多个对象装进一个 16 B block。其他小对象按 size class 和 scan/noscan 分流,
大对象直接从 heap pages 分配。含指针对象还需要正确的 bitmap/zeroing,让 GC 知道哪些 word 需要扫描。

if size == 0 {
return unsafe.Pointer(&zerobase)
}
if gcBlackenEnabled != 0 {
deductAssistCredit(size)
}
// small noscan / small scan / large routing follows
入口与分流:mallocgc。Go 1.26 还包含受 experiment 与 sanitizer 条件控制的 size-specialized 路径;它属于当前内部实现,不应被应用代码依赖。
size class 让固定大小 slot 可以批量管理,却也引入 elemsize - requested size 的内部碎片。tiny allocator 则有另一项权衡:
同一 16 B block 里的任一对象仍可达,整块就不能回收,所以它只适用于 noscan 小对象。应用真正能控制的是对象形状、批量大小与复用边界,不是直接选择 runtime 的 span。
3.1 快路径是当前 P 的 mcache 与 mspan
nextFreeFast 从当前 span 的 allocCache 找 trailing zero,推进 freeindex 并计算 slot 地址。
这条路径不需要去全局 heap 找空间。cached span 满时,mcache.nextFree 调用 refill;
refill 把已满 span 归还给 central,再用 mcentral.cacheSpan 获取有空位的 span。源码要求这段运行在 non-preemptible context,
因为 mcache 的 owner 与 P 绑定,抢占后 P 可能改变。
func nextFreeFast(s *mspan) gclinkptr {
theBit := sys.TrailingZeros64(s.allocCache)
if theBit < 64 {
result := s.freeindex + uint16(theBit)
// advance cache and return s.base + result*s.elemsize
}
return 0
}
nextFreeFast 与 mcache.nextFree;mcache.refill。
“小对象分配很快”描述的是常见 fast path,不代表没有系统成本:refill 可能触发 sweep 工作与 GC trigger 检查,内存清零和 sanitizer 也有成本; 最重要的是累计 allocation rate 最终会推进 heap goal。一次只看纳秒很便宜的分配,在高 QPS 下仍可能把大量 CPU 变成标记与清扫。
四、GC 是两段短 STW 之间的并发标记
先把 GC 想成一次“清点仍被谁拿着的结果”。从当前 goroutine 的栈、全局变量等根开始,沿指针能找到的对象叫可达,本轮不能回收; 沿任何根都找不到的对象才可能被清扫。源码用颜色记录清点进度:白色是尚未确认,灰色是已经找到但还没检查它指向谁, 黑色是已经检查完。颜色不是对象长期属性,只是这一轮标记的工作状态。
runtime 文档把循环写得很清楚:先完成 sweep termination 并短暂停顿,切到 _GCmark、准备 root jobs、启用 write barrier 与 mark workers;
mutator 恢复后并发标记。达到 mark completion 条件后再次 STW,进入 _GCmarktermination 完成标记与统计,
再切回 _GCoff,关闭 write barrier,让 sweep 在后台并发进行。

因此“Go GC 是并发的”和“Go GC 有 STW”都只说了一半。关键是把暂停、并发 CPU、assist 落在哪个请求、以及 live heap 变化一起看。 只记录 pause quantile,可能漏掉请求 goroutine 亲自做 GC work 的尾延迟;只看 GC CPU,也可能把业务分配激增误判为 collector 回归。
phase 与完整循环:mgc.go 概览;gcStart。4.1 混合写屏障防止 mutator 隐藏白对象
concurrent mark 时,应用还在改 heap pointer。Go 的 hybrid write barrier 在发布新指针前 shade 旧 slot 指向的对象; 当当前 goroutine 的 stack 仍是 grey 时,也 shade 新指针。这样 mutator 不能通过把唯一指针从 heap 移到已扫描或未扫描 stack 的组合中,让白对象从标记器视野消失。 barrier 对当前 stack frame 的写入可由编译器省略,但 heap pointer 发布必须遵守 pre-publication 约束。
writePointer(slot, ptr):
shade(*slot)
if current stack is grey:
shade(ptr)
*slot = ptr
算法、正确性解释与省略条件:runtime/mbarrier.go。
这也是 pointer-rich object 与 pointer-free buffer 成本不同的一部分:后者无需让 GC 扫描其内容。不要为了“少扫描”把指针偷偷编码成 uintptr;
那会破坏可达性与移动/生命周期规则。应通过紧凑数据结构、减少无意义对象图和缩短 owner 生命周期来改善扫描量。
4.2 分配过快时,用户 goroutine 要做 mark assist
并发标记不是免费后台服务。GC active 时,mallocgc 先按分配大小扣减 assist credit;
当前 goroutine 的 debt 为负时,gcAssistAlloc 尝试从全局 background credit 偿还,仍不足就直接扫描对象图。
这把“谁制造分配压力”与“谁承担部分标记工作”连接起来,帮助 collector 在 heap goal 之前完成标记。
allocation
→ deductAssistCredit(size)
→ assist debt below zero
→ steal background scan credit or gcAssistAlloc1
→ goroutine pays marking work before continuing
gcAssistAlloc 与 gcAssistAlloc1。
execution trace 中 allocation-heavy goroutine 出现 GC assist,是比“GC 偶尔抖一下”更有行动价值的证据。优先减少请求路径上可避免的 allocation bytes,
再讨论调 GOGC;否则只是改变付款时间,没有改变账单。
4.3 GOGC 与 GOMEMLIMIT 控制不同约束
GOGC 让下一轮 heap goal 随上一轮 live heap 成比例增长;默认 100 可近似理解为允许新分配量接近当前 live heap 后完成下一轮回收。
GOMEMLIMIT 则给 runtime 一个软内存限制,控制器从 runtime 管理的总映射内存估计 memory-limit heap goal,并预留 headroom。
实际目标取 GOGC-derived goal 与 memory-limit-derived goal 的更紧者。
| 旋钮 | 主要交换 | 错误理解 |
|---|---|---|
| 更低 GOGC | 更小 heap、更多 GC CPU | 自动降低业务分配量 |
| 更高 GOGC | 更大 heap、通常更少 GC | 消除内存上限风险 |
| GOMEMLIMIT | 用更多 GC 压力守住软目标 | heap 的硬上限或容器 limit 同义词 |
| 两者一起 | 比例目标与环境约束取更紧者 | 设置 limit 后 GOGC 完全失效 |
soft limit 不是 OOM 保险:non-Go memory、mmap、cgo、kernel page cache 与容器统计口径可能不同;极端 limit 下 runtime 还必须避免 GC thrashing 把应用完全饿死。 生产值应基于容器余量、live set、峰值流量与 CPU budget 联合压测,不要照抄单一百分比。
pacer 目标、25% background utilization 与 memory-limit headroom:mgcpacer.go;配置契约:SetGCPercent 与 SetMemoryLimit。五、把 allocation rate 与 live heap 分开观测
实验通过 runtime/metrics 读取 /gc/heap/allocs:bytes、
/gc/heap/allocs:objects、/gc/heap/live:bytes 和 /gc/cycles/total:gc-cycles。
前两项是累计分配,live bytes 是上一轮 GC 确认仍存活的集合,cycles 是回收节奏;它们的定义和采样时间不同,不能拿 allocs 减 live 直接当精确 reclaimed bytes。
samples := []metrics.Sample{
{Name: "/gc/heap/allocs:bytes"},
{Name: "/gc/heap/allocs:objects"},
{Name: "/gc/heap/live:bytes"},
{Name: "/gc/cycles/total:gc-cycles"},
}
metrics.Read(samples)
| 工具 | 适合回答 | 使用边界 |
|---|---|---|
go test -benchmem | 受控路径的 B/op、allocs/op | 不是生产流量分布 |
| heap profile | 采样时谁仍占用内存 | 默认看 in-use,不是全部历史分配 |
| allocs profile | 累计由谁制造 allocation traffic | 高累计不等于当前泄漏 |
runtime/metrics | 服务级趋势与告警 | 先读 metric description 与 kind |
GODEBUG=gctrace=1 | 单进程 GC cycle 摘要 | 更适合临时诊断,不替代 tracing |
| execution trace | assist、STW 与 goroutine 时间关系 | 有采集开销,需限定窗口 |
cd go-runtime/examples/fetchd
go test -run 'Test(AllocationShape|RuntimeMetrics)' -count=20
go test -bench BenchmarkAllocationShapes -benchmem -count=3
go test ./...
go test -race ./...
go vet ./...
六、回到 fetchd 的内存清单
- 先定 owner 与寿命。请求内临时值尽量不离开 handler;必须异步持有的对象明确交给 process/session owner。
- 用报告解释流向,用 benchmark 判断价值。
-m=2不是性能排行榜。 - 已知上界就合理预分配。避免 slice backing array 反复迁移,也避免按不可信输入无限预留。
- 减少 bytes 往往比只减少 object count 更重要。两者都测,连同 CPU 和延迟一起看。
- 区分 allocation rate 与 live heap。前者驱动 GC 工作,后者决定基础扫描与目标规模。
- 把 mark assist 放进请求延迟模型。GC CPU 不只在后台 worker。
- 调 GOGC/GOMEMLIMIT 前先压测余量。同时记录容器 RSS、runtime memory、GC CPU 和 tail latency。
- 不要依赖 runtime 内部布局。size class、tiny packing 与 specialized malloc 都可能随版本和 experiment 改变。
可复用结论是:逃逸分析决定对象能否安全留在 frame;allocation rate 决定应用制造多少回收工作;live heap 决定 collector 必须持续照看的对象图。 下一篇会把这条请求送回网络:连接能否复用、HTTP/2 怎样在一条连接上并发,以及如何把 httptrace、pprof、trace、metrics 与 race 组合成一次完整诊断。
参考源码与文档
- Go 1.26.0 escape analysis invariants and location graph
- A Guide to the Go Garbage Collector
- nextFreeFast, mcache.nextFree, and mallocgc
- mcache.refill
- Concurrent GC cycle overview
- Hybrid write barrier
- Mark assists
- GC pacer and memory-limit headroom
- runtime/metrics package documentation
- Profiling Go Programs
- Diagnostics
- fetchd memory lab
