15.4 GC 原理与调优 (GOGC / GOMEMLIMIT)

一句话

Go 的 GC 是并发三色标记 + 混合写屏障,只有两次极短的 STW;调优不是 “调参数”,而是先降分配速率与存活堆,再让 GC 目标与容器内存对齐。

一个 GC 周期的五个阶段

阶段 是否 STW 说明
Mark Setup 是(极短,微秒级) 开启写屏障
并发标记 否 与用户代码并行,占 25% CPU(可调)
Mark Termination 是(极短) 收尾、关闭写屏障
并发清扫 否 回收 span,供后续分配
(辅助) — 分配太快时用户 goroutine 被迫 “帮 GC 干活”(mark assist)

三色:白(未访问)→ 灰(已发现待扫描)→ 黑(已扫描);混合写屏障保证 “并发标记期间新增/修改的引用不丢”。

两个旋钮

旋钮 默认 作用 调大的后果
GOGC 100 下次 GC 的目标堆 = 存活堆 × (1 + GOGC/100) 堆更大、GC 更少、内存更高
GOMEMLIMIT(Go 1.19+) 无 软上限:到限就强制多 GC,避免被 OOM Kill 接近上限时 GC 变频繁,CPU 上升

两者关系:GOGC 管 “多久跑一次”,GOMEMLIMIT 管 “最多用多少”。容器里应设 GOMEMLIMIT ≈ limit × 0.8,并保留 GOGC 默认或适度调大。

调优顺序(别跳步)

步 动作 判据
① 看分配速率与存活堆 alloc_space 高 → 先降分配(见 15.3 内存分配与逃逸分析)
② 看 GC 占比 gctrace / runtime/metrics 的 GC CPU 比例
③ 再调 GOGC / GOMEMLIMIT 内存换 CPU,或 CPU 换内存
④ 与容器 limit 对齐 避免 “刚好用满” 被 OOM Kill
GODEBUG=gctrace=1 ./app     # 每个 GC 周期打印:堆大小、STW 时长、GC 占比

落到数字:GOGC 与目标堆

存活堆 GOGC 目标堆(触发下一轮 GC) 峰值风险
800MB 100 1.6GB 容器 limit 1GB 会被 OOM
800MB 50 1.2GB 更频繁,CPU 上升
800MB 200 2.4GB GC 少,内存高
800MB 100 + GOMEMLIMIT=900MiB 触限即回收 安全,但可能 “GC 饥饿”
GODEBUG=gctrace=1 ./app 2>&1 | head -5     # gc N @Ts X%: ... 看堆大小与 STW
go tool pprof -sample_index=alloc_space ./app mem.prof   # 谁在分配

反模式

反模式 后果
GOGC=off “先跑起来” 内存单调上涨直到 OOM
只调 GOGC 不看分配速率 治标不治本,GC 仍然高频
容器 limit 设 1GB,堆上限也设 1GB 没有余量,page cache/栈一来就 OOM
用 runtime.GC() 手动触发 打乱自调节奏,抖动加剧
忽略 mark assist 分配过快时用户代码被拖慢,表现为 “GC 不频繁但延迟高”

三条铁律

🔑 先降分配,再调参数;分配速率降一半,GC 频率跟着降一半。

🔑 GOMEMLIMIT 必须小于容器 limit(留 20% 余量)。

🔑 GC 的目标是 “可接受的延迟与内存”,不是 “零 GC”。

自检

Q1 堆稳定在 800MB,GOGC=100 时 GC 的目标堆大约是多少?

▸ 答案:约 1.6GB(存活堆 800MB × (1 + 100/100))。也就是说 GC 会等到堆长到 1.6GB 才触发下一轮。如果容器 limit 是 1GB,就会在触发前被 OOM Kill——这正是 “必须让 GC 目标与 limit 对齐” 的原因。

Q2 为什么说 “GC 不频繁但延迟高” 可能是 mark assist?

▸ 答案:因为分配速率过高时,GC 来不及并发完成,运行时会强制分配方 “帮忙标记”(mark assist),把 GC 的工作摊到用户 goroutine 上,表现为个别请求被拉长。此时 GC 次数看起来不多,但延迟分布恶化——解法仍是降分配速率。

Q3 GOMEMLIMIT 能完全避免 OOM 吗?

▸ 答案:不能保证。它是软限制:能约束 Go 堆,但管不了栈、cgo 分配、内核缓冲、page cache。且当存活堆本身超过限制时,GC 会持续高强度运行(“GC 死亡螺旋”),CPU 打满但无法回收。所以它只能作为 “防 OOM 的保险”,前提是存活堆确实小于限制。

Q4 什么时候该调大 GOGC?

▸ 答案:当 CPU 是瓶颈、内存有余量 时:调大 GOGC 让堆更大、GC 更少,用内存换 CPU。反之,内存紧张时应调小 GOGC(更频繁回收)。判断依据是 “GC CPU 占比”(如 > 25% 且内存充足 → 调大)。

Q5 容器里 Go 服务的内存参数怎么配?

▸ 答案:GOMEMLIMIT 设为 limit 的 70%–80%,GOMAXPROCS 与 CPU limit 对齐(见 15.1 GMP 调度模型),并监控 container_memory_working_set_bytes 与 GC 指标。不要用宿主机内存算,也不要把 limit 与堆上限设成同一个数。

Q6 为什么 “存活堆接近 GOMEMLIMIT” 会导致 CPU 飙升而吞吐下降?

▸ 答案:因为此时 GC 只能靠提高频率来压住内存,几乎没有回收空间:每轮收集都回收不了多少,却消耗 CPU。这叫 GC 饥饿(death spiral)。根因是存活对象太多,解法是降存活(缓存上限、对象复用、分片)而不是继续调参。

关联