主题
Go 垃圾回收:先看活着的对象,再谈调参
GC 能回收程序已经不需要的对象,但它判断“不需要”的依据是可达性,不是业务含义。缓存一直保存着过期数据、后台任务一直引用着请求对象时,GC 不知道这些东西已经没价值。反过来,对象已经不可达,也不表示内存马上回到操作系统。
理解 GC,要把两件事接起来:它怎样在程序继续修改数据时安全地找出垃圾,以及为了及时完成这项工作,它要用多少 CPU 和内存。本篇覆盖三色标记、写屏障、并发阶段、STW、GC assist,以及 GOGC、GOMEMLIMIT 的选择。实现与实验固定到 Go 1.27.2、Linux/amd64;Go 语言规范不规定具体收集算法。当前默认收集器包含 Green Tea 的标记扫描机制,不能继续把旧版对象工作队列当成全部现状。[1][2][3]
垃圾是谁决定的:从根走得到,就先保留
GC 从根开始找对象。根包括活跃栈、全局变量,以及运行时维护的相关指针;从这些位置继续沿 Go 指针走,能找到的堆对象需要保留。一个对象有没有“业务价值”,不在这个判定里。[2:1]
| 程序状态 | 对 GC 的意义 | 常见处理方向 |
|---|---|---|
| 全局 map 仍指向某条缓存 | 对象仍可达 | 清理缓存引用、控制容量或有效期 |
| 等待中的任务仍引用请求数据 | 相关数据仍可能被后续执行使用 | 检查任务退出与取消路径 |
| 两个对象互相引用,但从根都走不到 | 循环本身不让它们永远存活 | 追踪式 GC 不靠引用计数决定这件事 |
| 最后一个引用刚被去掉 | 对象有机会在适当的回收周期释放 | 不把赋 nil 当成立刻释放命令 |
这里的“走得到”是指运行时正确识别的 Go 引用,不是把地址转成整数后自己保留。uintptr 不会让对象继续存活;使用 unsafe 或外部资源时还要遵守单独的规则,不能依赖普通局部变量的表面作用域推断 GC 生命周期。[4][5]
三种颜色是在记录扫描进度
| 颜色 | 含义 | 接下来做什么 |
|---|---|---|
| 白色 | 本轮尚未发现 | 可能是垃圾,也可能稍后被发现 |
| 灰色 | 已经发现,但内部指针还没扫描完 | 扫描它指向的对象 |
| 黑色 | 已发现,相关扫描已完成 | 本轮保留,后续修改仍需配合屏障 |
正在绘制图示…
查看图源代码
flowchart LR
Root["根:栈、全局、运行时引用"] --> A["发现白色对象 A<br/>把 A 标记为待扫描"]
A --> Scan["扫描 A 中的指针"]
Scan --> B["发现 B、C<br/>加入待扫描工作"]
Scan --> Black["A 的扫描完成"]
B --> More["继续扫描新发现的对象"]
More --> End["可达对象发现完成后<br/>仍未标记的对象可被清扫"]
这些颜色是解释正确性的抽象状态,不要求每个对象真的存三个颜色值。尤其不能在扫描刚开始时把“白色”直接叫成“垃圾”:根还没遍历完,很多存活对象此时仍是白色。Go 1.27.2 用标记位和扫描相关状态组织工作。[2:2][6]
并发标记最难的地方:程序也在改指针
假设 GC 刚扫描完对象 A,程序随后把一个此前尚未被发现的 B 的指针写入 A,又去掉 B 在其他地方的引用。如果 GC 只相信“黑色 A 已经查过”,就可能漏掉现在仍然可达的 B。
写屏障解决的是这个并发变更问题。编译器会在需要的指针写入处生成配合 GC 的操作:除了写入目标位置,还把相关指针交给 GC 的标记工作。Go 当前使用混合写屏障,结合记录被覆盖的旧引用和新写入的引用;它不是给所有 Go 赋值都套一把 mutex。[7]
正在绘制图示…
查看图源代码
flowchart TD
W["准备写入一个需要屏障的指针槽"] --> Old["保留被覆盖的旧指针信息"]
W --> New["保留新指针信息"]
Old --> Buf["写屏障缓冲与标记工作"]
New --> Buf
Buf --> GC["GC 不会因这次指针搬动漏掉对象"]
W --> Store["完成指针写入"]
图表示职责,不是汇编指令的完整排序图。混合屏障的理论说明会按栈的扫描状态解释何时需要新引用那部分;当前实现为了降低条件判断和内存排序成本,会在相关路径保守处理指针。屏障有编译器插入、缓冲和后续处理的成本,当前帧上的某些已知栈写入则可以省去屏障。[7:1][8]
写屏障也不替应用消除数据竞争。两个 goroutine 无同步地读写同一数据,仍然要按 Go 内存模型处理;GC 保持自己的引用追踪正确,与业务程序是否同步正确,是两个问题。[9]
一轮 GC 哪些时候停,哪些时候并发
“并发 GC”表示大量工作可与业务执行重叠,不等于整个周期都没有暂停。当前标记清扫过程可以按下面的阶段理解。[2:3]
| 阶段 | 业务 G 能否继续执行 | 主要工作 |
|---|---|---|
| sweep termination 与标记准备 | 有全局 STW | 收尾前轮清扫,建立本轮状态,让各 P 启用写屏障 |
| 并发标记与扫描 | 大部分业务继续执行 | 扫根、遍历可达对象,后台 worker 与 assist 推进工作 |
| mark termination | 有全局 STW | 确认标记终止,完成阶段切换与运行时整理 |
| 并发清扫 | 业务继续执行 | 按 span 释放未标记对象,给后续分配复用 |
正在绘制图示…
查看图源代码
flowchart LR
S1["STW<br/>清扫收尾、开启本轮标记"] --> M["并发标记<br/>根、堆对象、写屏障、assist"]
M --> S2["STW<br/>标记终止与状态切换"]
S2 --> Sweep["并发清扫<br/>后台任务及分配路径参与"]
Sweep --> Next["后续分配推进<br/>到下一轮触发条件"]
Next --> S1
并发扫描一个 G 的栈时,还需要暂停这个 G 来读取稳定的栈状态。这和同时停止全部业务 G 的 STW 不是同一个粒度。新分配对象在标记阶段会按当前算法直接标为存活,以便与并发标记协调。[2:4]
当前 Go GC 是精确的、非分代、非压缩的并发标记清扫实现。非压缩意味着它不会为了整理堆而把所有存活对象搬到一边;这也解释了为什么空洞和 span 利用率仍值得关注。这里说的是 堆收集器,不应由此推出“Go 的所有内存地址永不变化”:goroutine 栈仍可能复制增长或缩小。[2:5][10]
Green Tea 改进了哪里,又没有改变什么
三色模型回答“对象为什么不能漏”,Green Tea 更关心“按什么顺序扫描,才能少做低效的内存访问”。Go 1.26 已将 Green Tea 设为默认;本次核对的 Go 1.27.2 构建基线仍启用它。[11][3:1]
传统的逐对象推进很容易跟着指针在堆里跳来跳去。Green Tea 对适用的小对象按 span 聚集扫描工作:发现对象时记下标记,安排它所在 span 的工作;稍后处理这个 span 时,把积累起来的待扫描对象一起处理。相邻对象有机会连续访问,元数据读取与预取也更容易摊薄。[6:1]
正在绘制图示…
查看图源代码
flowchart TB
P1["指针发现:对象 A"] --> Span["同一个 span 的<br/>标记位与扫描位"]
P2["指针发现:对象 B"] --> Span
P3["指针发现:对象 C"] --> Span
Span --> Queue["span 扫描工作队列"]
Queue --> Batch["处理 span 时<br/>批量扫描本轮新发现的对象"]
Batch --> Other["沿对象中的指针<br/>发现其他 span 中的对象"]
Other --> Queue
收益来自局部性与批量工作,代价是维护额外的扫描状态和工作组织;能积累多少批量机会也与对象图、对象大小和硬件有关。不能把“使用 Green Tea”直接变成服务固定提速多少的承诺。这里核对了默认设置与实现,没有做关闭 Green Tea 的性能对照。[6:2]
它没有把 Go 改成引用计数,也没有取消写屏障、STW、标记清扫或 GOGC。读资料时先看讨论的是正确性模型、扫描顺序还是触发策略,这三层不互相替代。[2:6][6:3][12]
GC assist:暂停很短,请求为什么仍然变慢
假如程序产生垃圾的速度快于后台标记推进速度,只让 GC 慢慢追,会在回收完成前耗掉更多内存。当前实现会按分配进度给分配者记下 assist 债务,要求业务 goroutine 做一部分标记工作,或者等待相应工作信用。[13][12:1]
这让产生分配的任务承担一部分回收成本,有助于让 GC 赶上堆增长;代价是分配函数的耗时不再只包含“找一块空内存”。一个请求可能没有遇到很长的全局暂停,却在自己的分配路径上花了时间协助 GC。只盯着 STW 最大值,会漏掉这种延迟来源。[13:1]
| 影响成本的量 | 变大以后通常会增加什么压力 | 可以怎么检查 |
|---|---|---|
| 存活且需要扫描的对象图 | 每轮追踪工作 | 活跃堆和扫描量指标、heap profile |
| 分配速率 | 触发后续 GC 的速度 | 单位时间累计分配字节的增量 |
| 大量栈或全局中的指针 | 根扫描工作 | /gc/scan/stack:bytes、/gc/scan/globals:bytes |
| 过紧的堆增长空间 | 更频繁的回收和 assist | GC 次数增量、assist CPU、请求延迟 |
“存活堆大”与“垃圾产生快”是不同问题。同样每秒申请很多内存,若大部分立刻变成垃圾,和长期留在缓存里,后续每轮的扫描负担并不相同;含大量指针的对象图与相同字节数的无指针缓冲也不等价。表中是机制上的方向,不是只用一个字节数就能预测 CPU 的公式。[12:2][14]
GOGC:允许堆长多少,用空间换回收频率
GOGC 是百分比目标,默认 100;运行时可用 debug.SetGCPercent 修改。为了理解它,先忽略内存限制、最小堆等校正,当前 pacer 采用的目标关系可以写成:[15][12:3]
text
目标堆 ≈ 上轮存活堆 +(上轮存活堆 + 根扫描量)× GOGC / 100这个目标告诉 GC 争取在哪个堆规模附近完成工作,不是承诺“刚达到这个字节数才开始 GC”。pacer 会更早安排触发,并按当前工作量调整;内存限制还可能把目标压低。[12:4]
| 调整 | 想换到什么 | 付出的代价或限制 |
|---|---|---|
提高 GOGC | 两轮之间容纳更多分配,通常减少回收频率 | 通常需要更大的堆余量 |
降低 GOGC | 更积极地回收,减少堆增长空间 | GC 与 assist 可能更频繁,占用更多 CPU |
GOGC=off 或负的 GC 百分比 | 关闭百分比驱动的自动回收 | 软内存限制和显式 runtime.GC() 仍然有效 |
所以 GOGC=off 既不是“彻底禁止 GC”,也不是正常服务避免停顿的通用方案。有没有足够内存余量,以及回收 CPU 是否真的影响业务,都要在实际负载下看。[15:1]
用同一个分配过程看两种百分比
程序先保留 16 个 1 MiB 缓冲并强制 GC,随后连续申请 128 个 1 MiB 临时缓冲,只让全局 scratch 保留最近一个。核心部分是:
go
for i := range 128 {
scratch = make([]byte, 1<<20)
scratch[0] = byte(i)
}
scratch = nil
snapshot("after allocating 128 MiB")两次运行固定 GOMAXPROCS=2,分别设置 GOGC=20 和 GOGC=100。从本次原始输出提取:
| 配置 | 开始临时分配前 NumGC | 临时分配后 NumGC | 这段期间增量 | 分配后 HeapAlloc |
|---|---|---|---|---|
GOGC=20 | 11 | 43 | 32 | 20024096 字节 |
GOGC=100 | 3 | 10 | 7 | 23169728 字节 |
这次较低百分比确实对应更多 GC 周期。这里只做了一轮短程序对照,没有稳定负载、吞吐和 CPU 统计,也没有用某个瞬间的 HeapAlloc 充当峰值;因此它能解释方向,不能证明 GOGC=100 比 20 快多少。计数和快照也会随实际调度变化。[16]
GOGC=100 的同一次运行,随后两行实际输出为:
text
after forced GC: HeapAlloc=16878920 NumGC=11 goal=33918794 live=16879208 runtime-memory=42879256
after dropping live data: HeapAlloc=101256 NumGC=12 goal=4194304 live=101544 runtime-memory=42166552保留的 16 MiB 仍然有全局引用,所以第一行强制 GC 后还在;把 live 置 nil 再 GC,HeapAlloc 才大幅下降。runtime-memory 是 Sys - HeapReleased,此时并没有随堆对象一起降到十万字节。对象回收与归还 OS 空间属于不同步骤,也不能把这个字段直接叫成 RSS。[16:1][17]
GOMEMLIMIT:软上限为什么必须留余量
GOMEMLIMIT 与 debug.SetMemoryLimit 给运行时一个软内存限制。覆盖的口径近似为:[17:1]
text
runtime.MemStats.Sys - runtime.MemStats.HeapReleased它包括运行时管理且尚未归还的内存,不仅是存活堆;但不包含全部进程开销,例如 C 代码分配、手工 syscall.Mmap 的空间、可执行文件和某些 OS 代管资源。因此容器硬限制与 GOMEMLIMIT 不能填同一个数就当作安全证明。[17:2]
| 它会做什么 | 它不能承诺什么 |
|---|---|
| 压低可用堆目标,更积极地回收和归还内存 | 不能回收仍可达的业务对象 |
在 GOGC=off 时仍推动回收 | 不能保证不发生 GC |
| 尽量把运行时管理内存控制在限制附近 | 不是进程 RSS 的硬上限 |
| 在严重回收压力下限制 GC 的 CPU 消耗,保留应用进展 | 不能保证始终不越过软限制或不被容器终止 |
最后一行很重要:如果存活对象本身已经接近甚至超过限制,反复 GC 也变不出空间。运行时有 GC CPU 限制机制,避免所有 CPU 都花在无效回收上;在这种情况下,允许突破软目标也比无限回收更能保留程序进展。提高限制、减少留存数据或限制并发,才可能改变容量问题。[18][19]
用同一个程序运行 GOGC=off GOMEMLIMIT=24MiB,本次输出节选为:
text
settings: GOGC=off GOMEMLIMIT=24MiB
keep 16 MiB: HeapAlloc=16869264 NumGC=1 goal=19635832 live=16869552 runtime-memory=21424408
after allocating 128 MiB: HeapAlloc=20024160 NumGC=90 goal=19314992 live=18975872 runtime-memory=25159960两次快照间没有显式 runtime.GC(),但 GC 次数从 1 增到 90,确认内存限制仍然能推动回收。这个限额故意留得很紧,只用于说明机制,不是推荐配置。实验没有测容器 OOM,也没有证明 24 MiB 能作为这个进程的硬上限。
调参前后要保留哪些证据
| 想确认的问题 | 证据 | 读法 |
|---|---|---|
| 是分配多,还是长期留存多? | 累计分配增量、/gc/heap/live:bytes、heap profile | 累计值要算一段时间的增量;live 是最近一轮标记的结果 |
| GC 的 CPU 去了哪里? | /cpu/classes/gc/total:cpu-seconds 和 assist 等子项 | 是累计 CPU 指标,不能把当前读数当成瞬时占比 |
| 是暂停还是请求路径承担了 assist? | 暂停分布、执行 trace、assist 指标、业务尾延迟 | 一起看,不用一个 STW 数字代替全部影响 |
| 软限制是否太紧? | 堆目标、运行时内存、/gc/limiter/last-enabled:gc-cycle | 检查是否频繁回收但存活数据几乎不降 |
| 进程为何仍占很多内存? | Go 内存分类与 OS RSS、非 Go 分配信息 | 先对齐口径,再找差额 |
这些名称与含义可在当前 runtime/metrics 文档核对。GODEBUG=gctrace=1 也能快速观察周期摘要,但格式可能变化;它不能替代稳定的业务测量。本文程序只读取并展示了堆目标、存活堆和运行时内存等少量值,表中其他指标是排查入口,没有伪称已经采集。[14:1][20]
一个可操作的顺序是:先记录原配置下的内存、GC CPU、吞吐与尾延迟;只改一个旋钮;保持负载和资源条件可比;再决定是否保留变化。若问题来自无界缓存或失控并发,先修对象生命周期。GC 参数适合调整成本分配,不能替业务补上容量边界。
依据与验证边界
本次执行了三组配置的同一分配程序,以及可达循环对象的功能测试、race 和 vet。程序展示可达性与参数行为,没有用短程序代替生产压测,没有测 Green Tea 相对旧收集器的性能,也没有触发容器 OOM。表中的次数与字节是这一轮记录,不要求每次重跑完全相同。
完整代码与复现
下面代码使用全局引用明确控制保留的堆数据,并在恰当位置调用 runtime.KeepAlive。测试只确认仍可达的循环引用在 GC 后有效,不用 finalizer 的时序推断不可达对象何时释放。
go.mod
go.mod
module example.com/runtime/gc
go 1.27main.go
代码块 · 41 行 · 点击收起 / 展开
go
package main
import (
"fmt"
"os"
"runtime"
"runtime/metrics"
)
var live [][]byte
var scratch []byte
func snapshot(label string) {
var m runtime.MemStats
runtime.ReadMemStats(&m)
s := []metrics.Sample{{Name: "/gc/heap/goal:bytes"}, {Name: "/gc/heap/live:bytes"}, {Name: "/gc/gogc:percent"}, {Name: "/gc/gomemlimit:bytes"}}
metrics.Read(s)
fmt.Printf("%s: HeapAlloc=%d NumGC=%d goal=%d live=%d runtime-memory=%d\n", label, m.HeapAlloc, m.NumGC, s[0].Value.Uint64(), s[1].Value.Uint64(), m.Sys-m.HeapReleased)
}
func main() {
fmt.Println("settings:", "GOGC="+os.Getenv("GOGC"), "GOMEMLIMIT="+os.Getenv("GOMEMLIMIT"))
for i := range 16 {
b := make([]byte, 1<<20)
b[0] = byte(i)
live = append(live, b)
}
runtime.GC()
snapshot("keep 16 MiB")
for i := range 128 {
scratch = make([]byte, 1<<20)
scratch[0] = byte(i)
}
scratch = nil
snapshot("after allocating 128 MiB")
runtime.GC()
snapshot("after forced GC")
runtime.KeepAlive(live)
live = nil
runtime.GC()
snapshot("after dropping live data")
}main_test.go
go
package main
import (
"runtime"
"testing"
)
type node struct {
Next *node
Value int
}
func TestReachableCycleSurvivesGC(t *testing.T) {
a, b := &node{Value: 1}, &node{Value: 2}
a.Next = b
b.Next = a
runtime.GC()
if a.Next.Next != a || a.Next.Value != 2 {
t.Fatal("reachable graph corrupted")
}
runtime.KeepAlive(a)
}执行命令
bash
go version
go build -o gc-demo .
GOMAXPROCS=2 GOGC=20 ./gc-demo
GOMAXPROCS=2 GOGC=100 ./gc-demo
GOMAXPROCS=2 GOGC=off GOMEMLIMIT=24MiB ./gc-demo
go test -count=1 -v ./...
go test -count=1 -race -v ./...
go vet ./...Go 1.27.2 runtime/mgc.go,精确、非分代、非压缩的并发标记清扫,阶段、根扫描和新对象标记。 ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
Go 1.27.2 internal/buildcfg/exp.go,
GreenTeaGC: true的默认基线。 ↩︎ ↩︎Go 1.27.2 unsafe.Pointer,
uintptr不保持引用。 ↩︎Go 1.27.2 runtime/mgcmark_greenteagc.go,span 批量扫描、标记/扫描位和工作组织;Go 官方 Green Tea 说明,设计背景。 ↩︎ ↩︎ ↩︎ ↩︎
Go 1.27.2 runtime/mbarrier.go,混合屏障、两类引用搬动、栈写入和发布顺序。 ↩︎ ↩︎
Go 1.27.2 runtime/mwbbuf.go,per-P 写屏障缓冲与 flush。 ↩︎
Go 1.27.2 runtime/stack.go,栈复制与伸缩。 ↩︎
Go 1.27.2 runtime/mgcpacer.go,堆目标、根扫描量、触发、assist 比率。 ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
Go 1.27.2 runtime/mgcmark.go,
gcAssistAlloc;runtime/malloc.go,分配路径的 assist 信用。 ↩︎ ↩︎Go 1.27.2 runtime/metrics,指标单位、累计性质和含义。 ↩︎ ↩︎
Go 1.27.2 runtime.MemStats,
HeapAlloc、NumGC等指标口径。 ↩︎ ↩︎Go 1.27.2 debug.SetMemoryLimit,软限制的内存范围与
Sys - HeapReleased。 ↩︎ ↩︎ ↩︎Go 1.27.2 runtime/mgclimit.go,GC CPU 限制机制。 ↩︎
Go 官方 GC 指南:Memory limit,软限制、CPU 保护和容量留量。 ↩︎
Go 1.27.2 runtime 环境设置,
gctrace。 ↩︎