☰
万字详解 | Go 进阶(二)GC 三色标记:STW 从 10ms 压到 40μs,1 次 Full GC 让线上 P99 抖了 3 秒
2026/10/24 4:13:29 网站建设 项目流程

你好,我是利威尔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 版本引入三色标记法开始,走的路子就是"并发标记、不移动对象":

  1. 对象不移动。Go 的对象分配在 span 上,GC 只回收不标记的,活的对象原地不动。好处是指针安全,坏处是内存碎片只能靠分配器缓解。
  2. STW 时间被压到微秒级。整个 GC 周期里只有两段 STW:标记开始前的"打开写屏障"和标记结束后的"关闭写屏障",Go 1.14 之后实测都在 40 微秒量级。其余时间 GC 和业务 goroutine 并发跑。
  3. 没有分代。因为大部分 Go 对象"朝生夕死"的假说不牢靠——长生命周期的连接对象、缓存太多了,分代回收的收益覆盖不了成本,官方干脆不分。

用一个生活比喻:Java 的 GC 像搬家公司,把还住人的家具搬到新房子,旧房子整体推平;Go 的 GC 像小区物业,挨家挨户贴"有人住"的标签,最后把没贴标签的屋子清退,家具一概不动。

所以 Go 开发者感知 GC 的方式不是"卡顿",而是内存涨得快和GC 期间 CPU 被抢。这两点后面都会给观测命令。

二、标记阶段:黑白灰三色到底在标什么

三色标记是理解全部 GC 行为的地基。把对象图看成一堆节点和指针边:

颜色含义类比
白色还没被访问过,默认是垃圾候选没贴标签的屋子
灰色自身被访问了,但它指向的对象还没扫完贴了标签但物业还在屋里清点
黑色自身和它引用的对象全部扫完清点完毕、确认有人住

标记从一个"根集合"出发:全局变量、各 goroutine 栈上的引用、寄存器里的指针。算法主干就四步:

  1. 初始时所有对象都是白色,把根直接引用的对象涂灰;
  2. 从灰色对象里取一个,把它引用的对象从白涂灰,自己涂黑;
  3. 重复第 2 步,直到没有灰色对象;
  4. 剩下的白色对象就是垃圾,回收。

看着简单,麻烦在于第 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=1

gctrace 输出长这样,每个字段的含义值得逐个数过:

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 删除屏障 + 插入屏障),规则是:

  1. 指向新对象的指针被写入时,新对象涂灰(插入屏障,管"新增引用");
  2. 指向旧对象的指针被删除/覆盖时,旧对象涂灰(删除屏障,管"删除引用")。

两条合起来保证:标记期间任何指针改动都不会让"该活的对象"变白。关键是栈不再需要重新扫描——因为删除屏障已经覆盖了栈上指针被覆盖的场景。这就是 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 全链路

把全文压缩成五句话:

  1. Go GC 是并发三色标记,对象不移动、不分代,业务基本无感;
  2. STW 只有标记开始和结束两个微秒级窗口,混合写屏障是微秒化的关键;
  3. GC 占 25% 的 P,分配太快会触发辅助标记,这是延迟毛刺的主因;
  4. 观测就靠GODEBUG=gctrace=1,先看 STW 再看堆涨幅,别凭感觉调参;
  5. GOGC 管节奏、GOMEMLIMIT 管上限,容器部署两个都要显式设。

配合上一篇 GMP 一起看:GMP 管"活怎么干",GC 管"干完怎么收"。运行时这两块拼齐,后面讲 channel 源码和内存分配器才有着落。

💡本章面试要点

  1. 三色标记的四步主干,以及黑对象引用白对象为什么危险;
  2. 并发标记为什么只需要 25% 的 P,辅助标记在什么场景触发;
  3. 混合写屏障两条规则,以及它为什么消灭了"重新扫栈"的大 STW;
  4. GOGC 和 GOMEMLIMIT 的分工,容器环境的推荐配置;
  5. 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保姆级万字详解》

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询