Go 基础体系 · 第 26/113 篇。示例统一基于 Go 1.26.4;核心片段可能省略 package 与 import,完整程序可直接按文中结构运行。

Go 垃圾回收基础:从对象可达性到生产调优

本文所有代码与运行行为均以 Go 1.26.4 为基准。Go 的垃圾回收器可以概括为“并发、三色抽象、标记清扫”:它判断堆上的对象是否仍能从程序根集合到达,保留可达对象,回收不可达对象。这个概括只说明了结果,真正排查内存问题时还必须理解标记怎样推进、业务 goroutine 同时修改指针为什么危险、写屏障保护了什么,以及运行时怎样在 CPU 与内存之间做预算。

Go 当前的主 GC 不是引用计数,也不是传统意义上的分代、复制或整理式回收器。对象通常不会因为 GC 而被移动,因此应用不能期待 GC 自动消除堆碎片;一轮标记结束后,未存活对象所在的 span 会在清扫阶段变成可复用空间,后台 scavenger 再根据情况把空闲物理页归还给操作系统。这里的“回收为 Go 可复用”和“RSS 立即下降”是两件不同的事。

1. GC 到底在判断什么:根集合、对象图与可达性

把堆看成一张有向图:节点是对象,指针字段是边。GC 不关心某个变量名还在不在源码里,只关心从根集合沿指针边能否找到对象。

典型根包括:

  • 全局变量和包级变量中保存的指针;
  • 当前各个 goroutine 栈帧里的活跃指针;
  • 运行时自身维护、需要参与扫描的数据结构;
  • 某些由 finalizer 等机制显式保持的对象关系。

假设根 R 指向对象 AA.Next 指向 B,那么 AB 都是存活对象。即使业务代码已经不再直接使用 B,只要这条引用链仍存在,GC 就不能回收它。反过来,两个对象互相引用但无法从任何根到达,它们仍然是垃圾;这也是追踪式 GC 相比单纯引用计数的重要差异。

根 R ──> A ──> B       C <──> D
        │              无根可达
        └──> E

存活:A、B、E
垃圾:C、D

“逻辑上不用了”不等于“GC 看来不可达”。无界 map、缓存、订阅者列表、定时器回调或长生命周期 goroutine 只要还保存引用,对象就是可达的。这类问题是内存保留或业务泄漏,不是 GC 漏回收。

2. 三色标记不是三种真实颜色

白、灰、黑是描述标记算法进度的抽象状态,不是对象头里供业务读取的颜色字段:

  • 白色:本轮尚未确认存活。标记结束仍为白色的对象会被视为垃圾。
  • 灰色:已经确认存活,但它内部的指针字段还没有全部扫描。
  • 黑色:已经确认存活,而且它当时可见的指针字段已经扫描完。

一轮理想化的标记过程如下:

  1. 开始时,待检查的堆对象都可看作白色。
  2. 扫描根集合,把根直接指向的白色对象染灰并放进工作队列。
  3. 标记工作线程从灰色队列取出一个对象,枚举它的指针字段。
  4. 该对象指向的白色对象被染灰并进入队列。
  5. 当前对象的字段扫描完成后染黑。
  6. 重复步骤 3 至 5,直到灰色队列为空。
  7. 此时仍为白色的对象无法从根到达,可以在后续清扫中回收。
初始:Root -> A(白) -> B(白) -> C(白)
扫根:Root -> A(灰) -> B(白) -> C(白)
扫 A:Root -> A(黑) -> B(灰) -> C(白)
扫 B:Root -> A(黑) -> B(黑) -> C(灰)
扫 C:Root -> A(黑) -> B(黑) -> C(黑)

如果程序在整个标记期间完全暂停,这套算法很直接。但 Go 希望短暂停顿,所以主要标记工作会与应用 goroutine 并发进行。业务代码在 GC 扫图的同时仍会分配对象、删除旧指针、写入新指针,问题由此出现。

3. 并发修改为什么会让朴素三色算法漏标

考虑已经扫描完成的黑色对象 A、尚未扫描的灰色对象 B,以及只能通过 B 到达的白色对象 C

A(黑)       B(灰) ──> C(白)

标记线程准备扫描 B 时,业务 goroutine 同时执行两步:

  1. C 的引用写入已经扫描过的 A.Child
  2. 删除 B.ChildC 的旧引用。

最终对象图变成 A(黑) -> C(白)。由于 A 已经扫描过,标记器不会自然回头;扫描 B 时旧引用又已经消失,C 可能一直保持白色。但程序明明仍能通过 A.Child 使用 C,若 GC 回收它就会破坏内存安全。

三色算法常用的一条强不变式是“黑色对象不能直接指向白色对象”;另一个较弱的表达是,即使出现黑到白的边,也必须仍有一条由灰色对象出发的路径保护那个白色对象。并发写指针会破坏这些条件,因此仅仅让标记线程并发运行是不够的,所有相关指针写入还要由写屏障协助记录。

4. 写屏障是什么,Go 的混合写屏障保护什么

写屏障是编译器在特定指针写操作周围插入的一小段运行时代码。它不是数据库屏障,也不是 CPU 内存屏障;它的目的,是在并发标记期间通知 GC 某条引用关系发生了变化。非标记阶段通常走很轻的快速路径,标记开启后才承担主要工作。

用简化伪代码表示 Go 的混合写屏障:

writePointer(slot, newPointer):
    shade(*slot)                    // 保护将被覆盖的旧指针
    if currentGoroutineStackIsGrey:
        shade(newPointer)           // 当前栈尚未完成扫描时保护新指针
    *slot = newPointer

shade 可以理解为“如果对象仍是白色,就把它标记并加入待扫描工作”。实际实现包含批量缓冲、对象颜色检查、栈与堆写入差异等优化,上面的伪代码只用于解释约束。

这套屏障结合了两类思路:

  • 删除屏障保护即将从槽位中消失的旧引用,避免对象在标记器看到它之前失去最后一条可追踪路径;
  • 插入屏障在必要时保护新写入的引用,避免尚未扫描的栈把白色对象转交给已经扫描的黑色堆对象。

Go 会在标记开始的短暂停顿中开启写屏障并准备根任务,各 goroutine 的栈在被扫描后可视为黑色。混合屏障使运行时不必在标记结束时再次完整重扫所有 goroutine 栈,这是控制 STW 时间的重要条件。

写屏障不意味着每次普通整数赋值都有同样成本。GC 关注的是指针图;包含指针的数据结构、指针密集对象和高频堆指针更新,会比无指针标量带来更多扫描与屏障负担。业务代码不应尝试绕过屏障,unsafe 写指针若违反运行时约束,结果可能不是“更快”,而是不可预测的内存错误。

5. 一轮 Go GC 经历哪些阶段

从应用视角可以把一轮周期拆成以下阶段:

  1. 清扫终止和标记准备(短 STW):完成必要的上一轮清扫工作,开启写屏障,建立本轮根扫描任务。
  2. 并发标记:恢复业务 goroutine。专用或分散的 GC worker 扫描根和堆对象,业务分配过快时还会参与 Mark Assist。
  3. 标记终止(短 STW):确认标记工作收敛,完成必须在全局一致状态下处理的收尾,关闭标记写屏障。
  4. 并发清扫:将未标记对象占用的空间变回分配器可复用状态。部分清扫也可能由后续分配路径按需完成。
  5. 后台 scavenging:识别长时间空闲的页并向操作系统释放物理内存。它与“对象是否存活”的标记工作不是同一步。

“并发 GC”不等于“零暂停”。Go 追求的是把必须全局暂停的工作控制得很短,把主要扫描工作放到并发阶段。暂停时间、GC CPU 占比、业务尾延迟和堆峰值必须一起看;只盯平均 STW,可能漏掉 Mark Assist 对请求延迟的影响。

6. GC Pacer、堆目标与 Mark Assist

运行时的 pacer 要解决两个问题:下一轮应该在什么时候开始,以及并发标记要投入多少 CPU 才能在堆达到目标前完成。开始太早会浪费 CPU,开始太晚则可能在高分配速率下越过内存预算。

GOGC 给出按上一轮存活数据量计算的相对增长目标。Go GC 指南使用的近似关系是:

目标堆 ≈ 存活堆 + (存活堆 + GC 根大小) × GOGC / 100

实际触发点会考虑本轮预计分配速率、扫描吞吐、历史反馈和内存上限,因此不能把公式当成字节级承诺。pacer 会调整后台标记 worker 的投入;如果某个 goroutine 分配得太快,它会积累“标记债务”,随后在自己的分配路径上协助完成一部分标记,这就是 Mark Assist

Mark Assist 是重要的反馈机制:谁快速制造需要回收器处理的堆工作,谁就可能分担标记成本。线上看到 GC 总暂停很低,但接口延迟在分配高峰抖动,应同时检查分配速率、assist CPU 和 heap profile,而不是直接认定 GC 没影响。

7. GOGC 的准确含义与数值示例

GOGC 默认值是 100。它控制空间换时间的基本比例,而不是“每 100 秒 GC 一次”,也不是“内存达到 100 MB 时触发”。

假设上一轮标记结束后:

  • 存活堆为 200 MiB;
  • goroutine 栈和全局变量等可扫描根合计约 20 MiB。

忽略 pacer 的动态修正,目标大致为:

GOGC 计算 近似目标堆 典型取舍
50 200 + (200 + 20) × 0.5 310 MiB 更频繁 GC,较低峰值,较高 CPU
100 200 + (200 + 20) × 1 420 MiB 默认平衡点
200 200 + (200 + 20) × 2 640 MiB 更少 GC,更高峰值

调大 GOGC 不会修复内存泄漏,只会让不可达垃圾积累得更多后再回收;如果存活堆本身持续增长,目标也会跟着增长。调小则不保证 RSS 等比例下降,因为栈、运行时元数据、外部内存和尚未归还给 OS 的页仍存在。

可以通过环境变量或运行时 API 设置:

# 使用默认增长比例
GOGC=100 ./service

# 禁用按 GOGC 触发的常规回收;只有明确理解内存预算时才这样做
GOGC=off ./batch-job
package main

import (
    "fmt"
    "runtime/debug"
)

func main() {
    previous := debug.SetGCPercent(150)
    fmt.Printf("previous GOGC=%d\n", previous)
}

生产环境不要只根据一次压测把 GOGC 固定成“经验最优值”。不同流量、缓存命中率、对象存活率和容器规格会改变结果,应比较吞吐、P99、GC CPU、heap goal、live heap 与 OOM 风险。

8. GOMEMLIMIT 是软预算,不是容器硬限制

GOMEMLIMIT 为 Go 运行时提供一个软内存预算。可用带单位形式,例如:

GOMEMLIMIT=768MiB GOGC=100 ./service

运行时评估的内存量可以用下面的指标关系理解:

Go runtime memory ≈ /memory/classes/total:bytes
                  - /memory/classes/heap/released:bytes

旧接口中的近似表达:MemStats.Sys - MemStats.HeapReleased

它覆盖堆、goroutine 栈、运行时元数据等 Go 运行时管理的主要内存,但不等于进程 RSS,通常也看不到 cgo 直接分配、外部 mmap、共享库映射、内核 socket 缓冲等全部进程成本。因此容器 limit 为 1 GiB 时,把 GOMEMLIMIT 也设成 1 GiB 通常没有安全余量。

“软”有两层含义:

  • 运行时会更积极地 GC 和归还页,努力围绕预算工作,但在瞬时分配、不可回收存活数据或运行时必须使用内存时可以超过它;
  • 为避免内存设得过低导致应用几乎只做 GC,运行时有 GC CPU 限制机制,不会为了死守预算无限占用全部 CPU,所以硬内存上限仍可能被突破并触发 OOM。

一个常见起点是从容器 limit 中扣除非 Go 内存和突发余量。例如 1 GiB 容器可先以 700 至 800 MiB 作为实验值,再根据 RSS、cgo、连接规模和压测数据校准,而不是机械套用固定百分比。

GOGCGOMEMLIMIT 会共同作用:前者给出自然增长目标,后者在内存紧张时压低实际目标。内存预算足够时主要由 GOGC 决定频率;接近软上限时,即使尚未达到按比例计算的目标,也可能提前启动 GC。

9. 内存上限过低为什么会 GC Thrashing

假设服务稳定存活堆已经有 600 MiB,栈和运行时元数据需要 120 MiB,却把 GOMEMLIMIT 设为 650 MiB。GC 无论执行多少次都无法回收仍然可达的 720 MiB 数据,于是可能出现:

  • GC 周期紧密相连;
  • 大量 CPU 用于重复扫描同一批存活对象;
  • Mark Assist 增多,业务 goroutine 分配路径变慢;
  • 吞吐下降、P99 上升,但内存仍降不到目标;
  • 最终仍可能超过容器硬限制。

这就是典型的 GC thrashing。正确处理不是再把 GOGC 调低,而是确认存活集为何这么大:缓存上限、队列积压、连接状态、批处理窗口、对象图指针密度或容器规格是否合理。

10. 用 runtime/metrics 观测,而不是只读一个 HeapAlloc

runtime/metrics 提供稳定、类型化的运行时指标。下面的 Go 1.26.4 程序读取堆目标、存活堆、GC 周期、运行时内存和已释放堆页:

package main

import (
    "fmt"
    "runtime/metrics"
)

func main() {
    names := []string{
        "/gc/cycles/total:gc-cycles",
        "/gc/heap/live:bytes",
        "/gc/heap/goal:bytes",
        "/memory/classes/total:bytes",
        "/memory/classes/heap/released:bytes",
    }

    samples := make([]metrics.Sample, len(names))
    for i, name := range names {
        samples[i].Name = name
    }
    metrics.Read(samples)

    for _, sample := range samples {
        switch sample.Value.Kind() {
        case metrics.KindUint64:
            fmt.Printf("%-45s %d\n", sample.Name, sample.Value.Uint64())
        case metrics.KindFloat64:
            fmt.Printf("%-45s %.4f\n", sample.Name, sample.Value.Float64())
        default:
            fmt.Printf("%-45s kind=%v\n", sample.Name, sample.Value.Kind())
        }
    }
}

观察时至少把这些信号放在一起:

  • live heap 是否随业务规模稳定,还是长期单调增长;
  • heap goal 与软内存上限是否挨得过近;
  • GC 周期频率和 GC CPU 占比是否在流量不变时持续上升;
  • 分配字节率、对象分配数与请求量之间是否成比例;
  • 暂停分布和接口 P99 是否同时恶化;
  • total - heap/released、进程 RSS 与容器 working set 的差异来自哪里。

runtime.ReadMemStats 仍可用于临时诊断,但不要在高频请求路径重复读取;长期监控优先接入 runtime/metrics 或成熟的 Prometheus collector。

11. 用 gctrace 看一轮 GC,但不要死记字段位置

临时启动时可以开启:

GODEBUG=gctrace=1 ./service

输出会包含 GC 序号、开始时间、各阶段耗时、CPU 使用、回收前后堆大小、目标堆和 P 数量等信息。具体文本格式属于诊断输出,可能随 Go 版本调整,不应写生产解析器依赖固定列。

判断方向时可以问:

  • 回收后 live heap 是否下降,还是每轮都在增加;
  • 两轮 GC 间隔是否越来越短;
  • 标记 CPU 是否与分配速率一起异常升高;
  • heap goal 是否被 GOMEMLIMIT 压得接近 live heap;
  • P 数量、业务并发和测试环境是否与生产一致。

如果只看到“GC 很频繁”,还不能断言原因。它可能是正常的高吞吐短命对象,也可能是内存上限太紧、存活集太大或代码意外分配。下一步必须用 profile 定位。

12. Heap Profile:区分谁在分配和谁仍占着内存

服务引入 net/http/pprof 并只暴露在受保护的诊断端口后,可以采集堆 profile:

go tool pprof -http=:0 http://127.0.0.1:6060/debug/pprof/heap

# 累计分配量:找高 churn 的分配热点
go tool pprof -sample_index=alloc_space http://127.0.0.1:6060/debug/pprof/heap

# 当前仍存活的对象:找缓存、队列和引用保留
go tool pprof -sample_index=inuse_space http://127.0.0.1:6060/debug/pprof/heap

alloc_space 高但 inuse_space 低,通常表示大量短命对象,主要成本是分配与 GC CPU;inuse_space 持续增长则更像对象被长期引用。采样 profile 不是逐字节账本,应该在可比流量下抓多份 profile 做 diff,并结合业务指标判断。

常见优化顺序:

  1. 找出具体分配或保留调用栈;
  2. 修复无界缓存、队列积压和错误生命周期;
  3. 在热点处预分配切片/map、减少转换或流式处理大对象;
  4. 用 benchmark 与 profile 验证改动确实降低成本;
  5. 最后才调整 GOGCGOMEMLIMIT,并重新压测尾延迟和 OOM 余量。

13. 一个可运行的 GC 实验

下面程序周期性制造短命数据,同时保留一部分长期对象。可以修改 GOGCGOMEMLIMIT,观察堆目标、GC 次数和吞吐变化。代码在 Go 1.26.4 下可直接运行。

package main

import (
    "flag"
    "fmt"
    "runtime"
    "runtime/debug"
    "time"
)

var retained [][]byte

func main() {
    gcPercent := flag.Int("gogc", 100, "GC growth percentage")
    memoryMiB := flag.Int64("limit-mib", 512, "runtime soft memory limit")
    rounds := flag.Int("rounds", 80, "allocation rounds")
    flag.Parse()

    debug.SetGCPercent(*gcPercent)
    debug.SetMemoryLimit(*memoryMiB << 20)

    started := time.Now()
    for round := 0; round < *rounds; round++ {
        temporary := make([][]byte, 0, 256)
        for i := 0; i < 256; i++ {
            block := make([]byte, 64<<10)
            block[0] = byte(round)
            temporary = append(temporary, block)
        }

        // 每 8 轮保留 64 KiB,模拟稳定增长的业务状态。
        if round%8 == 0 {
            retained = append(retained, temporary[0])
        }
        temporary = nil

        if round%10 == 0 {
            printStats(round)
        }
    }
    printStats(*rounds)
    fmt.Printf("elapsed=%s retained=%d KiB\n", time.Since(started), len(retained)*64)
    runtime.KeepAlive(retained)
}

func printStats(round int) {
    var stats runtime.MemStats
    runtime.ReadMemStats(&stats)
    fmt.Printf(
        "round=%02d heap_alloc=%6d MiB heap_sys=%6d MiB num_gc=%d\n",
        round,
        stats.HeapAlloc>>20,
        stats.HeapSys>>20,
        stats.NumGC,
    )
}

运行对比:

go run . -gogc=50  -limit-mib=512
go run . -gogc=100 -limit-mib=512
go run . -gogc=200 -limit-mib=512
GODEBUG=gctrace=1 go run . -gogc=100 -limit-mib=96

不要用这段微型程序得出生产参数,它只用于建立因果直觉。真实服务还包含网络缓冲、goroutine 栈、数据库驱动、cgo、缓存和流量波动,需要在真实对象图与负载下评估。

14. 常见误区

误区一:调用 runtime.GC() 就能解决内存高。 强制 GC 只能回收已经不可达的对象,无法回收仍被缓存或 goroutine 引用的数据,还可能增加暂停和 CPU。除少数阶段明确、内存峰谷很大的离线程序外,不应把它放进常规请求逻辑。

误区二:HeapAlloc 下降,RSS 就应该立刻下降。 清扫后的页可能先留给 Go 分配器复用,scavenger 归还 OS 也有节奏;此外 RSS 还包含栈、代码、共享库、cgo 和映射内存。

误区三:指针越少,业务一定越快。 指针密度会影响扫描成本,但为了“无指针”做复杂编码、复制大对象或破坏清晰数据模型,可能总成本更高。先 profile,再优化热点。

误区四:提高 GOGC 可以提升所有服务性能。 它通常减少 GC CPU,却提高内存峰值和 OOM 风险;内存紧张时还可能被 GOMEMLIMIT 覆盖。

误区五:GOMEMLIMIT 就是进程最大内存。 它是运行时软预算,不覆盖所有 RSS,也不会替代容器、systemd 或操作系统的硬限制。

误区六:GC 频繁一定是泄漏。 高频短命分配会让 GC 频繁但 live heap 稳定;泄漏更典型的证据是同等负载下 live heap 或 in-use profile 长期增长。

15. 生产调优清单

上线或调参前可以按下面顺序核对:

  • 明确容器硬内存、Go 运行时预算、非 Go 内存和突发余量;
  • 记录 Go 版本、GOGCGOMEMLIMITGOMAXPROCS,避免环境漂移;
  • 监控 live heap、heap goal、分配速率、GC CPU、GC 周期与暂停分布;
  • 同时监控 RSS、working set、OOM 事件和业务 P95/P99;
  • 对增长问题比较多时点 heap profile,不凭单张图判断;
  • 先治理无界数据和分配热点,再改变运行时参数;
  • 用真实并发、对象规模和缓存预热状态压测;
  • 每次只改变一个主要变量,并保留可回滚配置;
  • 升级 Go 版本后重新验证,pacer、编译器逃逸与运行时实现都会演进。

理解 GC 的核心不是记住一组“最佳参数”,而是建立完整因果链:根集合决定可达性,三色标记发现存活对象,写屏障保证并发修改下不漏标,pacer 根据分配与扫描速度安排工作,GOGC 负责相对增长预算,GOMEMLIMIT 负责软内存预算,profile 和指标负责告诉你真正的问题在哪里。


系列导航与关联阅读

官方资料

本文依据 Go 官方规范、标准库文档和 Go 官方博客重新梳理;正文与示例由 WR BLOG 编写。