Skip to content

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/>仍未标记的对象可被清扫"]
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
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["完成指针写入"]
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
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
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
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
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
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
过紧的堆增长空间更频繁的回收和 assistGC 次数增量、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=2011433220024096 字节
GOGC=100310723169728 字节

这次较低百分比确实对应更多 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.27

main.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 ./...

  1. Go 语言规范,语言与具体收集器实现的边界。 ↩︎

  2. Go 1.27.2 runtime/mgc.go,精确、非分代、非压缩的并发标记清扫,阶段、根扫描和新对象标记。 ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎

  3. Go 1.27.2 internal/buildcfg/exp.go,GreenTeaGC: true 的默认基线。 ↩︎ ↩︎

  4. Go 1.27.2 unsafe.Pointer,uintptr 不保持引用。 ↩︎

  5. Go 1.27.2 runtime.KeepAlive。 ↩︎

  6. Go 1.27.2 runtime/mgcmark_greenteagc.go,span 批量扫描、标记/扫描位和工作组织;Go 官方 Green Tea 说明,设计背景。 ↩︎ ↩︎ ↩︎ ↩︎

  7. Go 1.27.2 runtime/mbarrier.go,混合屏障、两类引用搬动、栈写入和发布顺序。 ↩︎ ↩︎

  8. Go 1.27.2 runtime/mwbbuf.go,per-P 写屏障缓冲与 flush。 ↩︎

  9. Go 内存模型,数据竞争与同步要求。 ↩︎

  10. Go 1.27.2 runtime/stack.go,栈复制与伸缩。 ↩︎

  11. Go 1.26 发布说明:New garbage collector。 ↩︎

  12. Go 1.27.2 runtime/mgcpacer.go,堆目标、根扫描量、触发、assist 比率。 ↩︎ ↩︎ ↩︎ ↩︎ ↩︎

  13. Go 1.27.2 runtime/mgcmark.go,gcAssistAlloc;runtime/malloc.go,分配路径的 assist 信用。 ↩︎ ↩︎

  14. Go 1.27.2 runtime/metrics,指标单位、累计性质和含义。 ↩︎ ↩︎

  15. Go 1.27.2 debug.SetGCPercent。 ↩︎ ↩︎

  16. Go 1.27.2 runtime.MemStats,HeapAlloc、NumGC 等指标口径。 ↩︎ ↩︎

  17. Go 1.27.2 debug.SetMemoryLimit,软限制的内存范围与 Sys - HeapReleased。 ↩︎ ↩︎ ↩︎

  18. Go 1.27.2 runtime/mgclimit.go,GC CPU 限制机制。 ↩︎

  19. Go 官方 GC 指南:Memory limit,软限制、CPU 保护和容量留量。 ↩︎

  20. Go 1.27.2 runtime 环境设置,gctrace。 ↩︎

保留各书来源与许可 · 示例运行条件以正文为准