阅读目标:不需要把“堆”当成坏词。先学会区分三个问题:对象为什么必须活得比当前函数久;程序每秒制造多少新对象;其中多少对象在 GC 时仍然活着。后半篇再进入逃逸分析、分配器与并发 GC 源码。

语言并不承诺对象一定放栈或堆;具体判断固定到 Go 开发分支固定快照(核实于 2026-09-20,不代表稳定发行版)的 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,供静态调用点继续传播。

编译器逃逸分析示意:赋值边按取地址、复制和解引用带不同权重,沿关系图检查指针寿命,再报告是否逃逸
算法不变量、边权和过程:escape.go;批量分析入口:Batch。

图是保守近似,并不区分 struct 的不同字段或 slice 的不同元素;源码也明确说明分析通常不具备完整的 flow、path 或 context sensitivity。 “escapes to heap”因此表示编译器没有足够证明把它安全留在当前 frame,不表示这个对象一定泄漏,更不表示 GC 当前就有压力。

闭包也不一定需要保留一份捕获记录。当前编译器在确认按值捕获后,会用 rewriteClosureVarsWithLiterals 把已知常量改成闭包函数内初始化的局部变量;若不再捕获任何变量,后续可直接使用函数入口。例如 n := 42; return func() int { return n } 的外部变量可以消失。这是有条件的实现优化:被取地址、重新赋值或无法证明为常量的捕获不适用,已内联闭包也会跳过该变换。不能据此断言所有闭包都不分配。

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 三次”,而是解释谁会继续持有每条数据,以及它最长需要活到什么时候: 指针离开函数、返回的 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 申请;减少分配与调整回收频率是两种不同动作。

当前快照的 mallocgc 先处理零大小请求:返回共享的 zerobase。常规路径中,小对象不超过约 32 KiB; 无指针且小于 16 B 的对象可进入 tiny allocator,把多个对象装进一个 16 B block。其他小对象按 size class 和 scan/noscan 分流, 大对象直接从 heap pages 分配。含指针对象还需要正确的 bitmap/zeroing,让 GC 知道哪些 word 需要扫描。

mallocgc 分配工作台:零大小请求直接返回 zerobase;非零请求按 tiny 无指针对象、其他小对象和大对象分流,小对象使用每个 P 的 mcache,耗尽时从 mcentral 补充 span,大对象使用 mheap 页;辅助标记额度只计入非零路径
图中的辅助标记额度只针对非零分配在并发标记期间扣减;零大小请求先返回 zerobase,不经过这一步。
if size == 0 {
    return unsafe.Pointer(&zerobase)
}
if gcBlackenEnabled != 0 {
    deductAssistCredit(size)
}
// simplified fallback; specialized helpers also account for assists
入口与分流:mallocgc。此处只展示零大小与通用路径的 assist 检查,省略了提前返回的专用入口。

当前快照已移除 size-specialized malloc 的实验开关:在非 Plan 9 且没有启用相关 sanitizer 的构建里,表覆盖范围内的请求按大小进入 mallocNoScanTable、mallocScanTable 或 tiny 专用函数。表外请求与受限构建仍有通用路径。这不是跳过 GC:生成专用函数的 模板 仍在需要时扣减 assist credit;两类入口最终都从当前 P 的缓存和 span 获取空间。

size class 让固定大小 slot 可以批量管理,却也引入 elemsize - requested size 的内部碎片。tiny allocator 则有另一项权衡: 同一 16 B block 里的任一对象仍可达,整块就不能回收,所以它只适用于 noscan 小对象。应用真正能控制的是对象形状、批量大小,以及缓存何时归还或丢弃,不是直接选择 runtime 的 span。

3.1 快路径是当前 P 的 mcache 与 mspan

nextFreeFast 统计当前 span 的 allocCache 末尾连续 0 位,找到第一个值为 1 的空闲位,推进 freeindex 并计算 slot 地址。 这条路径不需要去全局 heap 找空间。cached span 满时,mcache.nextFree 调用 refill; refill 把已满 span 归还给 central,再用 mcentral.cacheSpan 获取有空位的 span。源码要求这段运行在 non-preemptible context, 因为 mcache 属于当前 P,抢占后 goroutine 可能换到另一个 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 在后台并发进行。

并发 GC 周期:两次短暂停顿之间进行并发标记,后台标记、写屏障和分配协助共同维持正确性与进度,随后并发清扫;堆目标受 GOGC 和 GOMEMLIMIT 影响

因此“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; 那会破坏可达性与生命周期规则。应通过紧凑数据结构、减少无意义对象图,并让请求、session 或 cache 及时释放引用来降低扫描量。

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 按上一轮存活堆加上栈和全局变量的扫描规模,决定允许增加多少堆空间。忽略最小目标等修正,基础公式是 live heap + (live heap + roots) × GOGC / 100;因此默认 100 也不能一概说成“堆翻倍”。 GOMEMLIMIT 给 runtime 一个软内存限制,控制器从 runtime 管理的内存估计另一个堆目标,并预留余量。内存限制目标更小时优先采用它;否则还可能为清扫距离和标记进度保留最小空间,所以把最终目标简单写成两个数的 min 只是近似。 对应实现是 比例目标计算 与 heapGoalInternal。

旋钮主要交换错误理解
更低 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 traceassist、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 的内存清单

  1. 先写清谁继续持有、何时释放。请求内临时值尽量不离开 handler;必须异步保存的对象放进明确的 session 或进程级结构,并在结束时删掉引用。
  2. 用报告解释流向,用 benchmark 判断价值。-m=2 不是性能排行榜。
  3. 已知上界就合理预分配。避免 slice backing array 反复迁移,也避免按不可信输入无限预留。
  4. 减少 bytes 往往比只减少 object count 更重要。两者都测,连同 CPU 和延迟一起看。
  5. 区分 allocation rate 与 live heap。前者驱动 GC 工作,后者决定基础扫描与目标规模。
  6. 把 mark assist 放进请求延迟模型。GC CPU 不只在后台 worker。
  7. 调 GOGC/GOMEMLIMIT 前先压测余量。同时记录容器 RSS、runtime memory、GC CPU 和 tail latency。
  8. 不要依赖 runtime 内部布局。size class、tiny packing 与 specialized malloc 都可能随版本和 experiment 改变。

可复用结论是:逃逸分析决定对象能否安全留在 frame;allocation rate 决定应用制造多少回收工作;live heap 决定 collector 必须持续照看的对象图。 下一篇会把这条请求送回网络:连接能否复用、HTTP/2 怎样在一条连接上并发,以及如何把 httptrace、pprof、trace、metrics 与 race 组合成一次完整诊断。

参考源码与文档