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)。根因是存活对象太多,解法是降存活(缓存上限、对象复用、分片)而不是继续调参。
关联
- 前置:15.3 内存分配与逃逸分析
- 并列:15.5 pprof (CPU / 内存 / 阻塞 / 互斥)、11.2 内存管理与虚拟内存
- 下游:6.3 namespace (隔离) 与 cgroups (限制)、34.6 性能反模式