Go · GC 三色标记与混合写屏障

并发标记-清除 (非分代·非压缩): 两次极短 STW + 并发标记 — GOGC / GOMEMLIMIT 的 pacer 逻辑

扫描根 并发修改! 写屏障拦下 白 · 未访问 初始颜色, 候选垃圾 标记结束仍是白 → 回收 灰 · 待扫描 自身已标记, 子对象未扫 工作队列 = 灰色集合 黑 · 存活 自身与子对象都已扫过 本轮 GC 不再碰它 用户 goroutine 标记期间照常跑 随时改指针 → 危险! 不变式: 黑不能指向白 (除非灰也指向它) — 混合写屏障保驾护航 Yuasa 删除屏障 被覆盖的旧指针 标灰 删掉的边不丢 → 不漏标 代价: 浮动垃圾 (下轮收) Dijkstra 插入屏障 新写入的指针 标灰 黑→白的新边不丢 堆上用屏障, 栈上免 (Go 1.8+) Go 1.8+ 混合屏障 两者结合 + 栈完全免屏障 STW 从 ~10ms 降到 <1ms 指针写在栈上零开销 一次 GC 的时间线 (<1ms · 并发 · <1ms · 并发) STW① 开场 开写屏障·扫根 <1ms 并发标记 GC 占 ~25% CPU, 与用户 goroutine 并行 STW② 收尾 标记终止 <1ms 并发清扫 惰性回收: 分配时顺手清, 无集中停顿 Go GC 的承诺: 两次 STW 均在亚毫秒级 — 停顿与堆大小基本无关 (代价: 标记期 25% CPU + 内存峰值 ~2x) GODEBUG=gctrace=1 可打印每轮 GC 耗时与回收量 Pacer: 下次 GC 触发堆 = live × (1 + GOGC/100) — 默认 GOGC=100 即"堆翻倍才触发" GOMEMLIMIT (1.19+): 设内存软上限, 逼近时加速 GC — 容器防 OOMKill 的利器 Legend 白/未访问 灰/待扫 · pacer 黑/存活 · 并发阶段 STW / 风险

三色抽象

  • • 白=候选垃圾, 灰=待扫描队列, 黑=确认存活
  • • 标记 = 不断把灰变黑、白变灰直到灰色清空
  • • 不变式被并发破坏 → 写屏障来守

并发 = 短停顿

  • • 只有开场/收尾两次亚毫秒 STW
  • • 标记期占 25% CPU, 与业务并行
  • • 清扫惰性: 分配时顺手清, 无集中停顿

代价与调节

  • • 内存峰值 ≈ 2× live heap (GOGC=100)
  • • 降 GOGC = 更省内存更费 CPU, 反之亦然
  • • GOMEMLIMIT 兜底容器 OOM

💡 一句话理解

Go GC 是并发三色标记-清除: 把对象涂成白(垃圾候选)/灰(待扫)/黑(存活), 从根出发染色; 染色期间用户代码照常跑, 靠混合写屏障保证并发不漏标。停顿亚毫秒、与堆大小基本无关; 代价是标记期吃掉 ~25% CPU, 且默认策略下内存峰值约 2 倍存活堆 — 调 GOGC / GOMEMLIMIT 就是在这笔账上做取舍。

🧠 必知必会 必考 & 必会

为什么非分代
Go 的逃逸分析+值语义让"朝生夕死"对象大多直接在栈上消失(根本不进堆), 堆里活下来的平均更长寿 — 分代的收益前提被削弱, 于是选择简单并发标记。
func sum(a []int) int {           // 值语义: 中间量在栈上
    t := 0
    for _, v := range a { t += v }  // 不进堆 → 不给 GC 添活
    return t
}
// 关键: 短命对象大多栈上消失, 分代 scavenging 收益薄
为什么非压缩
Go 用 TCMalloc 风格的 size-class 分配器, 天然低碎片; 挪动对象要修全部指针(STW 变长), 不划算 — 但堆不会主动归还 OS 给 OS 侧看 RSS 常驻偏高。
type node struct{ next *node }   // size-class 分配, 天然低碎片
// 挪对象 = 修全部指针 = STW 拉长 → 不压缩
// 关键: 堆不主动还 OS, RSS 常驻偏高是常态
debug.FreeOSMemory()              // 唯一主动归还口 (慎用)
漏标 vs 多标
漏标 = 活对象被回收(致命); 多标 = 浮动垃圾(无害, 下轮再收)。写屏障宁可多标不漏标 — 一切设计都围绕这个安全倾斜。
// 并发标记中用户改指针 — 黑→白 新边:
black.ptr = white        // 若无屏障可能漏标 → 活对象被收, 致命
// 写屏障: 把 white 标灰, 宁可多标成浮动垃圾
// 关键: 多标=下轮再收(无害), 漏标=数据损坏(致命)
GOGC 的算法
触发点 = live × (1 + GOGC/100)。GOGC=100: 4G 存活 → 堆涨到 8G 触发下一轮。调小省内存但 GC 更频繁; GOGC=off 只在批处理+显式 runtime.GC() 场景使用。
// live heap = 4G 时:
GOGC=100   // → 4G × 2.0 = 8G 才触发 (默认)
GOGC=50    // → 4G × 1.5 = 6G 触发, 更勤更省内存
debug.SetGCPercent(50)       // 关键: 运行时同样可调
GOMEMLIMIT
1.19+ 的软内存上限(含堆外): 逼近时 Pacer 提前/加速 GC, 超限也只加大 GC 力度而不 OOM。容器里设为 limit 的 ~90%, 防被 OOMKill。
# K8s memory limit = 1Gi 时:
GOMEMLIMIT=921MiB ./app            // ~90%, 含堆外
debug.SetMemoryLimit(921 << 20)     // 代码内同理
// 关键: 逼近时加速 GC 而非 OOM; 超限只加力度
25% CPU 从哪来
标记期 GC 辅助 goroutine 与后台标记 worker 竞争 CPU, 目标占 25%; 业务分配太快还会被"帮工"逻辑(GC assist)强制拉来一起标 — 分配越多惩罚越重。
buf := make([]byte, 16<<20)  // 大分配 → assist 债务暴涨
// 该 goroutine 被强征去帮 GC 标记 → 请求变慢
// 关键: 根因是分配速率, 降分配才是根本解
观测
GODEBUG=gctrace=1 每轮打印: 触发堆/目标堆/存活/暂停; runtime/metrics 或 pprof 看 GC CPU 占比与分配速率。
# GODEBUG=gctrace=1 ./app
# gc 12 @8.301s 4%: [8.2 8.4 8.8] ms → ...
#    ^第12轮 ^GC占CPU ^三阶段耗时
// 关键: live 周期性增长=泄漏; % 持续高=该降分配率

🏭 生产实战 real world

场景 1 · 容器内存只给 1Gi, 防 OOMKill

服务 live heap ~400MB, 默认 GOGC=100 会放任堆冲到 800MB+, 加上堆外余量逼近 limit:

// 方案: 直接限定内存软上限, 让 Pacer 自己算频率
GOMEMLIMIT=900MiB GOGC=100 ./app
# 内存紧张时它自动加大 GC 频率换安全; 空闲时不浪费 CPU

场景 2 · 低延迟 API: 降 GOGC 换更平稳

8G 堆的服务, GOGC=100 意味着每"多 4G 垃圾"才 GC, 单轮标记久、CPU 抖动明显:

GOGC=50   // 触发点 = live × 1.5: GC 更勤但每轮更轻, 毛刺变小
# 内存富余 (机器 16G 只用 6G) 时这是免费的质量提升

场景 3 · 看清每轮 GC 的账本

GODEBUG=gctrace=1 ./app
# gc 12 @8.301s 4%: [8.2 8.4 8.8] ms →  
#  ^第12轮 ^触发时刻 ^GC占CPU  ^三个阶段耗时
# 后面跟 live/新分配/... — 周期性增长 = 泄漏; %持续高 = 该降分配率

场景 4 · 批处理任务: GOGC=off + 分段收口

跑完就退出的批任务, 中途 GC 纯属浪费 — 关掉, 分段手动收:

func runBatch(chunks []Chunk) {
    old := debug.SetGCPercent(-1)          // GOGC=off: 不自动 GC
    defer debug.SetGCPercent(old)           // 退出前恢复(进程复用时安全)
    for i, c := range chunks {
        process(c)
        if i%100 == 0 { runtime.GC() }      // 每 100 段收一次, 内存有界
    }
}

场景 5 · 大堆缓存服务: 双参数组合

10GB 级缓存服务单独调哪个参数都不稳, 组合拳:

GOGC=80 GOMEMLIMIT=12GiB ./cache-service
// GOGC=80: 单轮 GC 更轻(触发更早), 毛刺小
// GOMEMLIMIT: 硬兜底 — 逼近 12G 时 Pacer 强制加速, 防 OOMKill
// 观测确认: gctrace 里 heap goal 与 total alloc 速率是否匹配预期

场景 6 · 低峰期主动归还内存

凌晨批处理吃掉 8G 后白天空闲, RSS 居高不下 — 低峰主动归还(慎用):

if lowTraffic() {                       // 流量谷底窗口才执行
    runtime.GC()                           // 收一轮: 活对象压实
    debug.FreeOSMemory()                // 把空闲 span 还给 OS (STW 短暂停)
}
// 注意: FreeOSMemory 全局暂停, 高峰调用 = 自造毛刺; 只配定时低峰任务

场景 7 · 减少指针密度: 值切片优于指针切片

GC 扫描成本正比于指针数 — 同样 100 万元素, 指针切片扫描量差一个数量级:

type item struct{ id int64; score float64 }  // 无指针字段!

items := make([]item, 1_000_000)         // 好: 连续内存, span 无指针, GC 跳过
// 对比 make([]*item, 1_000_000): 百万个指针全要三色标记
// 大索引/缓存结构优先值切片 + 下标代替引用

场景 8 · runtime/metrics 精确观测

gctrace 之外, 程序内可按需采样官方指标:

import "runtime/metrics"

sample := []metrics.Sample{
    {Name: "/gc/heap/allocs:bytes"},        // 累计分配
    {Name: "/gc/heap/live:bytes"},         // 存活堆
    {Name: "/gc/cycles/total:gc-cycles"},  // GC 轮数
}
metrics.Read(sample)
// 差值除以时间 = 分配速率; live 持续增长 = 泄漏

场景 9 · GC 压力 A/B 实验

调参必须同负载对比, 固定流程四步:

// 1. 固定压测负载与数据集 (重放流量)
wrk -t8 -c200 -d120s --latency http://svc/api
# 2. A 组: 默认 GOGC=100; B 组: GOGC=50
GOGC=50 ./svc &  wrk ... > b.txt
# 3. 对比: P99 / GC次数(gctrace) / 峰值RSS 三张图
# 4. 内存富余→选 GOGC 小的(毛刺低); 内存吃紧→保守值+GOMEMLIMIT

场景 10 · sync.Pool 与 GC 周期对齐

池在 GC 时被清空, 大 buffer 池要意识到这个节律:

var bigBufPool = sync.Pool{
    New: func() any { b := make([]byte, 64<<10); return &b },
}
// Get/Hit 率打进指标: GC 后命中率骤降属正常(victim 池缓冲一轮)
// 若业务流量周期 ≈ GC 周期, 池形同虚设 → 改为结构体持有 + 复用切片字段

⚠️ 编码注意与常见坑 pitfalls

坑 1 · "Go 没有 GC 停顿" — 有, 只是亚毫秒; 更常见的影响是标记期 25% CPU 与 assist。正解: 低延迟目标下压分配率, 而不是指望 GC 免费。
# 错: "Go GC 零停顿, 不用关心"
GODEBUG=gctrace=1 ./svc
# gc 12 @8.301s 4%: [8.2+8.4+8.8] ms  ← 有停顿+吃 CPU
# 对: 低延迟目标下压分配率 (pprof alloc_objects)
坑 2 · 内存翻倍当泄漏 — GOGC=100 下 RSS 接近 2×live 是设计使然。正解: 先看 gctrace 的 live 值是否稳定; 稳定就不是泄漏, 需要省内存就调 GOGC/GOMEMLIMIT。
# 错: 看到 RSS ≈ 2×live 就报"泄漏"
GODEBUG=gctrace=1 ./app
# ... 4->8->4 MB live 稳定波动 ← 设计使然
GOGC=50 ./app                # 对: live 稳定时调参省内存
坑 3 · sync.Pool 当缓存用 — 池在 GC 周期会被清空(victim 缓解但仍有损失), 它只做"短期复用"。正解: 真缓存用带 TTL 的结构; Pool 的语义是"降低分配率"。
var p = sync.Pool{New: func() any { return fetch() }}
// 错: 把 Pool 当缓存 — GC 时被清空, 数据"凭空丢失"
// 对: Pool 只做短期复用降分配率
//     真缓存用带 TTL 结构 (ristretto / bigcache)
坑 4 · 大 map 删数据不还内存 — runtime 不会把稀疏 span 主动还给 OS。正解: 定期重建(map 换新)或分片轮转; 监控 live heap 而非 RSS。
delete(m, k)                // 错: bucket 稀疏, span 不还给 OS
m = make(map[K]V, n)       // 对: 定期重建换新实例
// 或分片轮转; 监控看 live heap 而非 RSS
坑 5 · 盲目 GOGC=off — 常驻服务里等于无限攒垃圾直到 OOM。正解: 仅限"跑完就退出"的批处理, 且配合显式 runtime.GC()。
debug.SetGCPercent(-1)          // 错: 常驻服务 = 攒垃圾到 OOM
for i, c := range chunks {        // 对: 批任务+显式收口
    process(c)
    if i%100 == 0 { runtime.GC() }  // → 内存有界
}
坑 6 · 调参不看分配率 — GC 频率的根因是分配速率(alloc MB/s)。正解: 先 pprof 降分配热点(呼应逃逸分析), 参数调节是最后一步。
# 错: 一上来就调 GOGC 硬扛
go tool pprof http://:6060/debug/pprof/alloc_objects
# top10 → 对: 先降分配热点 (见逃逸分析三板斧)
# 参数调节是最后一步, 不是第一步
坑 7 · GOMEMLIMIT 设得离谱 — 高于真实可用内存失去保护意义; 贴近 live heap 则 GC 颠簸(thrashing)吃光 CPU。正解: 设为容器 limit 的 ~90%, 且与 live heap 保持足够余量。
# 错: limit=1Gi 却设 GOMEMLIMIT=8Gi (无保护)
#     或 GOMEMLIMIT=live+10MB (GC 颠簸吃光 CPU)
GOMEMLIMIT=921MiB ./app      # 对: limit 的 ~90%
# 且与 live heap 保持余量, 观测 GC 频率不飙升
坑 8 · 把 GOGC 当内存上限理解 — GOGC=100 是"堆翻倍触发", 不是"最多用 100%"。正解: 控内存用 GOMEMLIMIT; GOGC 只调节 CPU/内存的兑换比例。
GOGC=100                 // 错: 理解成"最多用 100% 内存"
// 实际: live×2 才触发, 完全没有上限概念
GOMEMLIMIT=900MiB         // 对: 控内存用软上限
// 关键: GOGC 只调 CPU/内存兑换比例
坑 9 · 期待 GC 压缩内存 — Go GC 不搬对象(非压缩), 大量删除后堆内空洞仍在。正解: 周期性重建大 map(换新实例); 接受 RSS 常态偏高, 监控看 live 而非 RSS。
delete(m, oldKeys...)       // 错: 以为堆会被"压紧"
fresh := make(map[K]V, len(m))  // 对: 重建换新实例
for k, v := range m { fresh[k] = v }   // → 空洞随旧 map 释放
坑 10 · SetFinalizer 拖延回收 — 带 finalizer 的对象要多轮 GC 才真正释放, 还容易注册重复。正解: 显式 Close/资源管理器; finalizer 只当兜底报警用。
runtime.SetFinalizer(f, cleanup)   // 错: 当资源释放主通道
// 对: 显式 Close 为主, finalizer 只兜底报警
defer f.Close()                  // → 一轮 GC 即真正释放
坑 11 · finalizer 里引用对象本身 — finalizer 闭包捕获 obj = 对象永远不可达不了 → 永不释放。正解: finalizer 只接收 ID 等值参数。
runtime.SetFinalizer(obj, func(*Obj) { save(obj.path) })
// 错: 闭包捕获 obj → 复活, 永不释放
runtime.SetFinalizer(obj, func(o *Obj) { save(o.path) })
// 对: 用收到的参数 o, 不额外捕获
坑 12 · 忽视 GC assist 的惩罚 — 分配太快的 goroutine 会被强征去帮 GC 干活, 表现为个别请求延迟飙升。正解: 根因还是分配速率 — 降分配(逃逸分析页), 而不是调参硬扛。
buf := make([]byte, 16<<20)  // 错: 热路径反复大分配
// → 该请求被 assist 拉去标记, 延迟飙升
buf = bufPool.Get().(*[]byte) // 对: 复用降分配率才是根本
坑 13 · 只盯 pause 不看 CPU 占比 — 亚毫秒 pause 很美, 但 GC CPU 占 30% 同样拖垮吞吐。正解: gctrace 第一个百分比字段(GC CPU 占比)与 go_gc_duration_seconds 一起看。
# 错: 只看 pause ns 就宣布"GC 无影响"
# gc 12 @8.301s 4%: [8.2+8.4+8.8] ms
#              ^^ 对: 这个 GC CPU 占比字段一起看
# + go_gc_duration_seconds 趋势; 30% 即拖垮吞吐
坑 14 · 忘了堆外也吃 limit — goroutine 栈、cgo 内存、mmap 文件都算 RSS, 只按堆大小设 limit 会 OOMKill。正解: GOMEMLIMIT 设为 limit 的 90% 留堆外余量。
# 错: live=800M 就把 GOMEMLIMIT 设 850M
#     goroutine 栈/cgo/mmap 也占 RSS → OOMKill
GOMEMLIMIT=921MiB ./app      # 对: limit=1Gi 的 ~90%
# → 堆外余量留出来, Pacer 自动加速兜底
坑 15 · 高峰期跑 FreeOSMemory — 全局 STW 操作放在高峰 = 人为故障。正解: 只在定时低峰窗口执行; 平时靠 GOMEMLIMIT 自动管理。
debug.FreeOSMemory()              // 错: 高峰调用 = 自造全局 STW 毛刺
if lowTraffic() {                // 对: 只在定时低峰窗口
    runtime.GC()
    debug.FreeOSMemory()         // → 空闲 span 还给 OS
}
坑 16 · 升级 Go 不回归 GC 行为 — 每个版本 Pacer/写屏障都有微调(如 1.18 恢复软限制、1.19 GOMEMLIMIT)。正解: 升级后跑同负载压测, 对比 GC 次数与 RSS。
# 错: 升级 Go 版本后直接上生产
# 对: 同负载压测, 对比 GC 次数与 RSS
wrk -t8 -c200 -d60s --latency http://svc/api > new.txt
# 与旧版本 old.txt 对齐对比; gctrace 看 GC 轮数变化
坑 17 · 大量小对象用指针网络 — 链表/树节点全是指针, 标记成本与缓存不友好双杀。正解: 大数据结构用值切片 + int32 下标模拟指针(数据库引擎的常规做法)。
type node struct{ next *node }    // 错: 全指针, 标记+缓存双杀
type table struct{ nodes []node }  // 对: 值切片连续存放
next := int32(i + 1)            // int32 下标当"指针"
坑 18 · 把 GC 停顿当万灵药背锅侠 — P99 毛刺先看是不是锁竞争/IO 重试/调度, 再看 GC。正解: 用 continuous profiler 确认毛刺时刻与 GC 周期是否真正重合。
# 错: P99 毛刺先怪 GC
curl -s :6060/debug/pprof/profile?seconds=30 > cpu.pb
# 对: 对齐毛刺时刻看 profile — 锁竞争/IO 重试同样常见
# 毛刺时刻与 gc 周期真正重合才能定 GC 的罪
坑 19 · 生产用 GODEBUG 全开 — gctrace=1 等调试输出有开销且日志量大。正解: 采样式开启(故障复现窗口), 常态用 runtime/metrics + Prometheus。
# 错: 常年 GODEBUG=gctrace=1,schedtrace=1000 跑生产
# 对: 采样式开启 (故障复现窗口才打开)
# 常态观测用指标: go_gc_duration_seconds
#              go_memstats_alloc_bytes_total
坑 20 · 调参不写注释不留档 — 半年后没人知道 GOGC=37 是为什么。正解: 参数与压测报告链接写进部署配置注释; 每次调整走变更评审。
# 错: GOGC=37 无注释 → 半年后没人知道为什么
env:
  - name: GOGC
    value: "37"   # 2026-03 压测: P99 -18%, 报告 docs/bench-gc-37.md
# 对: 参数+压测报告链接进配置注释, 调整走评审