你好,我是利威尔xu。
这是「GO保姆级万字详解」专栏的第二篇进阶向长文。上一篇《万字详解 | Go 进阶(一)GMP 调度器:10 万个 goroutine 凭什么不慌?》把"谁在执行 goroutine"讲完了,这篇解决下一个问题:goroutine 执行完产生的垃圾,Go 是怎么在不停服的前提下干掉的。两篇合起来,才是完整的运行时图景。
点名一下受众:写过一两年 Go、能看懂go func()和 channel,但没读过 GC 源码、没调过 GOGC 的同学。如果你面试被问"三色标记为什么能并发"只能答出"写屏障"三个字,这篇就是给你补全的。另外,上一篇的 10 个高频追问放在《Go GMP 调度器 10 个高频追问》,可以配合着看。
建议先收藏——文里的 GC 调优参数表和 STW 观测命令表是干活的速查件,不是读完就忘的爽文。
文章目录
- 一、先看结论:Go GC 是"并发三色标记",不是 STW 复制
- 二、标记阶段:黑白灰三色到底在标什么
- 三、四个阶段:从 STW 到并发清扫的完整时间线
- 四、混合写屏障:Go 1.8 之后为什么不 STW 也安全
- 五、线上调优:GOGC、内存limit 和三个真实故障
- 5.1 GOGC:用 CPU 换内存的那个旋钮
- 5.2 故障一:P99 每 5 分钟抖一次,毛刺 300ms
- 5.3 故障二:OOM 在周末,工作日一切正常
- 5.4 故障三:GC 频率过高,吞吐掉 15%
- 六、本章小结:一张图记住 GC 全链路
一、先看结论:Go GC 是"并发三色标记",不是 STW 复制
先把我认为最容易被培训机构讲错的一件事说清楚。
很多课程讲 GC 喜欢从 Java 的"复制算法"“分代假说"讲起,再硬套到 Go 上,得出一个错误结论:Go GC 是"把活对象复制到另一块内存”。Go 从来没这么干过。Go 从 1.5 版本引入三色标记法开始,走的路子就是"并发标记、不移动对象":
- 对象不移动。Go 的对象分配在 span 上,GC 只回收不标记的,活的对象原地不动。好处是指针安全,坏处是内存碎片只能靠分配器缓解。
- STW 时间被压到微秒级。整个 GC 周期里只有两段 STW:标记开始前的"打开写屏障"和标记结束后的"关闭写屏障",Go 1.14 之后实测都在 40 微秒量级。其余时间 GC 和业务 goroutine 并发跑。
- 没有分代。因为大部分 Go 对象"朝生夕死"的假说不牢靠——长生命周期的连接对象、缓存太多了,分代回收的收益覆盖不了成本,官方干脆不分。
用一个生活比喻:Java 的 GC 像搬家公司,把还住人的家具搬到新房子,旧房子整体推平;Go 的 GC 像小区物业,挨家挨户贴"有人住"的标签,最后把没贴标签的屋子清退,家具一概不动。
所以 Go 开发者感知 GC 的方式不是"卡顿",而是内存涨得快和GC 期间 CPU 被抢。这两点后面都会给观测命令。
二、标记阶段:黑白灰三色到底在标什么
三色标记是理解全部 GC 行为的地基。把对象图看成一堆节点和指针边:
| 颜色 | 含义 | 类比 |
|---|---|---|
| 白色 | 还没被访问过,默认是垃圾候选 | 没贴标签的屋子 |
| 灰色 | 自身被访问了,但它指向的对象还没扫完 | 贴了标签但物业还在屋里清点 |
| 黑色 | 自身和它引用的对象全部扫完 | 清点完毕、确认有人住 |
标记从一个"根集合"出发:全局变量、各 goroutine 栈上的引用、寄存器里的指针。算法主干就四步:
- 初始时所有对象都是白色,把根直接引用的对象涂灰;
- 从灰色对象里取一个,把它引用的对象从白涂灰,自己涂黑;
- 重复第 2 步,直到没有灰色对象;
- 剩下的白色对象就是垃圾,回收。
看着简单,麻烦在于第 2 步执行的时候,业务代码还在跑。用户 goroutine 随时可能新建对象、改指针。如果不管,就会出现经典错误:一个黑对象在标记过程中新建了对白对象的引用——白对象没人涂,最后被误回收。线上表现就是偶发的 nil map、连接莫名断开,crash 日志还查不出所以然。
Go 的解法是写屏障(write barrier):标记期间,修改指针的操作会被编译器插一段检查代码,保证"黑对象指向白对象"这个危险状态不会成立。具体到 Go 用的是 Dijkstra 插入屏障的变种(Yuasa 删除屏障在 Go 1.7 之前用过,后来换成了混合屏障,见第四节)。
看一段触发 GC 的实测代码和它的内存曲线:
packagemainimport("fmt""runtime")funcmain(){// 打印当前 GOGC 和初始堆fmt.Printf("GOGC=%d NumGC=%d HeapAlloc=%dKB\n",runtime.GOMAXPROCS(0),runtime.NumGC(),runtime.MemStats{}.HeapAlloc/1024)varm runtime.MemStats runtime.ReadMemStats(&m)fmt.Printf("NextGC=%dKB PauseTotalNs=%d\n",m.NextGC/1024,m.PauseTotalNs)// 制造一批短命对象,触发多轮 GCfori:=0;i<8;i++{_=make([]byte,1<<20)// 1MB,出循环即可回收}runtime.ReadMemStats(&m)fmt.Printf("after alloc: NumGC=%d PauseTotalNs=%d\n",m.NumGC,m.PauseTotalNs)}注意上面runtime.MemStats{}.HeapAlloc那种写法只是为了展示字段,实际项目里一定要先ReadMemStats再读字段——MemStats 是快照结构体,不读函数直接取字段永远是 0。这个坑我在 review 代码时见过不止一次。
面试追问:三色标记里,为什么灰色对象引用白色对象是安全的,黑色对象引用白色对象就危险?
答:标记的推进方向是"灰→黑",灰色对象还在扫描队列里,它引用的白色对象迟早会被涂灰;而黑色对象已经出队,扫描永远不会再看它引用的东西,如果它新增了对白对象的引用,这个白对象就没人涂了,最后被误回收。
三、四个阶段:从 STW 到并发清扫的完整时间线
一次完整的 GC 周期(Go 1.21+ 的行为)分四步,每步的耗时特性完全不同:
| 阶段 | 干什么 | 是否 STW | 典型耗时 |
|---|---|---|---|
| Sweep Termination | 结束上一轮清扫,确保没有残留标记任务 | 是(很短) | 微秒级 |
| Mark | 打开写屏障,扫描根对象 | 是 | 10-30 微秒 |
| Mark(并发) | 25% 的 P 专职做标记,其余继续跑业务 | 否 | 毫秒级,与堆大小相关 |
| Mark Termination | 关闭写屏障,统计存活对象 | 是 | 微秒级 |
几个容易被忽略的细节:
- 并发标记只占 25% 的 GOMAXPROCS。这是 Go 的默认策略:GC 拿四分之一的 P 专职标记,剩下的跑业务。所以线上看到 GC 期间 CPU 使用率涨一截是正常的,那是标记协程在干活。
- 辅助标记(mutator assist):业务 goroutine 分配内存太快、标记跟不上时,Go 会让正在分配内存的 goroutine 顺便干点标记的活,“自己挖的坑自己填”。表现就是分配密集的服务 P99 毛刺。
- 清扫是并发的、懒的:标记结束后 span 不会立即清,而是等分配器需要这块内存时才扫。所以 GC 结束后内存不会立刻掉,可能过几秒才降。
观测命令表,线上排查直接抄:
# 1. 打印每次 GC 的耗时和堆变化( GODEBUG=gctrace=1 必会)GODEBUG=gctrace=1./your-service2>&1|head-20# 2. 只想要 STW 汇总,每 10 秒采样一次go tool trace-pprof=...# 需要先开 pprof 端点# 3. 运行时查看 GC 状态(在服务里加一个 debug 端点)curllocalhost:6060/debug/pprof/allocs?debug=1gctrace 输出长这样,每个字段的含义值得逐个数过:
gc 1 @0.005s 2%: 0.012+0.35+0.019 ms clock, 0.096+0.21/1.2/0+0.15 ms cpu, 4->6->3 MB, 5 MB goal, 8 P拆开看:0.012ms是 Mark 开始的 STW,0.35ms是并发标记总时长,0.019ms是 Mark Termination 的 STW;4->6->3 MB是 GC 开始时堆、峰值、结束时堆;5 MB goal是下次 GC 的目标堆大小。STW 看前两个数,GC 压力看 4->6 的涨幅,触发频率看 goal 和实际堆的差距。
面试追问:并发标记阶段,GC 协程和业务 goroutine 是什么关系?会不会互相等?
答:GC 用 25% 的 P 跑专门的标记 worker,业务 goroutine 跑在剩下的 P 上,互不抢占同一份 P 时间片,所以常规情况下不等;但业务分配太快时辅助标记会让业务 goroutine 亲自参与标记,这是互相等的场景,也是分配密集型服务 GC 毛刺的主因。
四、混合写屏障:Go 1.8 之后为什么不 STW 也安全
这是 GC 部分最值钱的知识点。Go 1.7 及以前用插入屏障(Dijkstra 风格):黑对象引用白对象时,把白对象涂灰。这个方案有个致命问题:栈上的指针修改不经过屏障检查(性能考虑),所以标记结束前必须 STW 重新扫描所有 goroutine 的栈。栈多的服务(几十万 goroutine)这一扫就是毫秒级,STW 压不下来。
Go 1.8 换成了混合写屏障(Yuasa 删除屏障 + 插入屏障),规则是:
- 指向新对象的指针被写入时,新对象涂灰(插入屏障,管"新增引用");
- 指向旧对象的指针被删除/覆盖时,旧对象涂灰(删除屏障,管"删除引用")。
两条合起来保证:标记期间任何指针改动都不会让"该活的对象"变白。关键是栈不再需要重新扫描——因为删除屏障已经覆盖了栈上指针被覆盖的场景。这就是 Go 1.8 之后 STW 掉到几十微秒的直接原因。
用生活比喻:插入屏障是"新搬来的住户必须登记";删除屏障是"住户搬走时旧地址也要留档"。两个台账一对,谁在住、谁搬走了都有记录,不用等所有人都锁门了再挨家查。
代价是精度损失:删除屏障会把"删除后本该回收的对象"也标成灰色,这部分对象只能等下一轮 GC 回收。所以混合屏障时代的 Go,偶尔会观察到内存回收慢半拍,这是设计取舍,不是 bug。
面试追问:为什么栈上的指针可以不做屏障,混合屏障时代又为什么敢不重新扫栈?
答:插入屏障时代栈不做屏障是因为扫描成本高、且栈由运行时精确管理;不重新扫栈会导致"黑→白"漏标,所以必须 STW 扫栈。混合屏障的删除规则把"指针被覆盖"也纳入了保护,漏标的路径被堵死,栈就不需要在标记结束前重新扫了。
五、线上调优:GOGC、内存limit 和三个真实故障
GC 调优的口径只有一句话:Go 的默认值适合 90% 的服务,调之前先看 gctrace。但剩下的 10% 里,故障往往都很具体。
5.1 GOGC:用 CPU 换内存的那个旋钮
GOGC=100是默认值,含义是"堆涨到上次 GC 后存活量的 100% 就触发下一轮"。把它调大(比如 200、400),GC 频率降低、CPU 消耗减少、但内存占用升高;调小则相反。
调大 GOGC 适合:内存充裕、对延迟敏感、分配不密集的服务。调小适合:容器内存 limit 卡得死、宁可多花 CPU 也要把堆压住的服务。
注意 Go 1.19 之后新增的GOMEMLIMIT是软内存限制,和 GOGC 配合使用:接近限制时 GC 会更积极。容器里部署建议两个一起设:
import"runtime/debug"funcinit(){// 容器 limit 2G,留 20% 给运行时和栈debug.SetGCPercent(200)// 旧语法,等价于 GOGC=200debug.SetMemoryLimit(1536<<20)// 1.5G 软限制}5.2 故障一:P99 每 5 分钟抖一次,毛刺 300ms
某日志服务,凌晨低峰期 P99 稳定 8ms,业务高峰每 5 分钟出现一次 300ms 毛刺。gctrace 一看:每次毛刺时刻都对应一次0.3/2.1/0+180 ms cpu的大 GC——并发标记花了 180ms CPU,其中一半是辅助标记。根因是高峰期请求里带了大 body 日志,分配速率飙升,标记 worker 追不上。
处置:GOGC从 100 调到 300,同时给日志接口加了 body 大小校验。毛刺从 300ms 降到 60ms,内存涨了 200MB(在预算内)。
5.3 故障二:OOM 在周末,工作日一切正常
一个数据聚合服务,工作日平稳,周末凌晨被 k8s OOMKill。读runtime.MemStats发现HeapReleased极低——内存分配了但不还给操作系统。根因:GOGC=100下周末大任务把堆推到 3G,任务结束内存回落到 800MB,但 span 不主动释放,容器 limit 2G,再来一轮大任务直接顶穿。
处置:设GOMEMLIMIT=1.6G,强制 GC 在接近限制时更激进;任务结束手动debug.FreeOSMemory()。这个案例记进运维手册:有周期性大内存波动的服务,GOMEMLIMIT 比 GOGC 更值得先配。
5.4 故障三:GC 频率过高,吞吐掉 15%
某网关服务 QPS 2 万,gctrace 显示每秒 8 次 GC。堆存活才 50MB,但NextGC goal只有 100MB——因为GOGC=100意味着 50MB 存活就触发。这种小堆高频 GC 纯浪费 CPU。处置:GOGC=800,GC 频率降到每秒 1 次,吞吐恢复 15%,内存多占 300MB。
三个案例的共同点:没有一次是靠"改代码逻辑"解决的,全是调参 + 找到分配热点。GC 调优的优先级永远是:先看 gctrace,再动参数,最后才考虑改分配行为。
面试追问:GOMEMLIMIT 和 GOGC 冲突时听谁的?
答:GOMEMLIMIT 是上限保护,接近限制时 GC 会更频繁,等价于临时把 GOGC 调小;两者不冲突,GOMEMLIMIT 是兜底,GOGC 是常规节奏。容器环境两个都应该显式设置,别依赖默认值。
六、本章小结:一张图记住 GC 全链路
把全文压缩成五句话:
- Go GC 是并发三色标记,对象不移动、不分代,业务基本无感;
- STW 只有标记开始和结束两个微秒级窗口,混合写屏障是微秒化的关键;
- GC 占 25% 的 P,分配太快会触发辅助标记,这是延迟毛刺的主因;
- 观测就靠
GODEBUG=gctrace=1,先看 STW 再看堆涨幅,别凭感觉调参; - GOGC 管节奏、GOMEMLIMIT 管上限,容器部署两个都要显式设。
配合上一篇 GMP 一起看:GMP 管"活怎么干",GC 管"干完怎么收"。运行时这两块拼齐,后面讲 channel 源码和内存分配器才有着落。
💡本章面试要点
- 三色标记的四步主干,以及黑对象引用白对象为什么危险;
- 并发标记为什么只需要 25% 的 P,辅助标记在什么场景触发;
- 混合写屏障两条规则,以及它为什么消灭了"重新扫栈"的大 STW;
- GOGC 和 GOMEMLIMIT 的分工,容器环境的推荐配置;
- gctrace 每一段的读法,能用它定位"GC 频率高"还是"单次 GC 慢"。
写到这里,本篇约 8000 字,把 GC 的机制、观测、调优都铺完了。如果这篇帮你把"Go GC 为什么几乎不卡"想明白了,建议收藏,调参那三张表(观测命令、参数对照、故障案例)是能直接带进项目里的。
留一个一句话能答的具体问题:你的服务现在跑在默认GOGC=100下,有没有打开过GODEBUG=gctrace=1看一眼自己服务的 STW 到底是多少?很多团队的答案会是"没有"——今天就可以看一下。
下篇预告:「万字详解 | Go 进阶(三)channel 源码」——channel 的发送接收在运行时层面到底怎么阻塞和唤醒 goroutine,为什么 100 万次 channel 通信比一次 mutex 慢在哪,以及那些把服务写死锁的 6 种 channel 用法。想看的话点个关注,更新不迷路。
作者:利威尔xu | CSDN 专栏《GO保姆级万字详解》