你好我是利威尔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 分钟抖一次毛刺 300ms5.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(fmtruntime)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;i8;i{_make([]byte,120)// 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 的耗时和堆变化 GODEBUGgctrace1 必会GODEBUGgctrace1./your-service21|head-20# 2. 只想要 STW 汇总每 10 秒采样一次go tool trace-pprof...# 需要先开 pprof 端点# 3. 运行时查看 GC 状态在服务里加一个 debug 端点curllocalhost:6060/debug/pprof/allocs?debug1gctrace 输出长这样每个字段的含义值得逐个数过gc 1 0.005s 2%: 0.0120.350.019 ms clock, 0.0960.21/1.2/00.15 ms cpu, 4-6-3 MB, 5 MB goal, 8 P拆开看0.012ms是 Mark 开始的 STW0.35ms是并发标记总时长0.019ms是 Mark Termination 的 STW4-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 换内存的那个旋钮GOGC100是默认值含义是堆涨到上次 GC 后存活量的 100% 就触发下一轮。把它调大比如 200、400GC 频率降低、CPU 消耗减少、但内存占用升高调小则相反。调大 GOGC 适合内存充裕、对延迟敏感、分配不密集的服务。调小适合容器内存 limit 卡得死、宁可多花 CPU 也要把堆压住的服务。注意 Go 1.19 之后新增的GOMEMLIMIT是软内存限制和 GOGC 配合使用接近限制时 GC 会更积极。容器里部署建议两个一起设importruntime/debugfuncinit(){// 容器 limit 2G留 20% 给运行时和栈debug.SetGCPercent(200)// 旧语法等价于 GOGC200debug.SetMemoryLimit(153620)// 1.5G 软限制}5.2 故障一P99 每 5 分钟抖一次毛刺 300ms某日志服务凌晨低峰期 P99 稳定 8ms业务高峰每 5 分钟出现一次 300ms 毛刺。gctrace 一看每次毛刺时刻都对应一次0.3/2.1/0180 ms cpu的大 GC——并发标记花了 180ms CPU其中一半是辅助标记。根因是高峰期请求里带了大 body 日志分配速率飙升标记 worker 追不上。处置GOGC从 100 调到 300同时给日志接口加了 body 大小校验。毛刺从 300ms 降到 60ms内存涨了 200MB在预算内。5.3 故障二OOM 在周末工作日一切正常一个数据聚合服务工作日平稳周末凌晨被 k8s OOMKill。读runtime.MemStats发现HeapReleased极低——内存分配了但不还给操作系统。根因GOGC100下周末大任务把堆推到 3G任务结束内存回落到 800MB但 span 不主动释放容器 limit 2G再来一轮大任务直接顶穿。处置设GOMEMLIMIT1.6G强制 GC 在接近限制时更激进任务结束手动debug.FreeOSMemory()。这个案例记进运维手册有周期性大内存波动的服务GOMEMLIMIT 比 GOGC 更值得先配。5.4 故障三GC 频率过高吞吐掉 15%某网关服务 QPS 2 万gctrace 显示每秒 8 次 GC。堆存活才 50MB但NextGC goal只有 100MB——因为GOGC100意味着 50MB 存活就触发。这种小堆高频 GC 纯浪费 CPU。处置GOGC800GC 频率降到每秒 1 次吞吐恢复 15%内存多占 300MB。三个案例的共同点没有一次是靠改代码逻辑解决的全是调参 找到分配热点。GC 调优的优先级永远是先看 gctrace再动参数最后才考虑改分配行为。面试追问GOMEMLIMIT 和 GOGC 冲突时听谁的答GOMEMLIMIT 是上限保护接近限制时 GC 会更频繁等价于临时把 GOGC 调小两者不冲突GOMEMLIMIT 是兜底GOGC 是常规节奏。容器环境两个都应该显式设置别依赖默认值。六、本章小结一张图记住 GC 全链路把全文压缩成五句话Go GC 是并发三色标记对象不移动、不分代业务基本无感STW 只有标记开始和结束两个微秒级窗口混合写屏障是微秒化的关键GC 占 25% 的 P分配太快会触发辅助标记这是延迟毛刺的主因观测就靠GODEBUGgctrace1先看 STW 再看堆涨幅别凭感觉调参GOGC 管节奏、GOMEMLIMIT 管上限容器部署两个都要显式设。配合上一篇 GMP 一起看GMP 管活怎么干GC 管干完怎么收。运行时这两块拼齐后面讲 channel 源码和内存分配器才有着落。本章面试要点三色标记的四步主干以及黑对象引用白对象为什么危险并发标记为什么只需要 25% 的 P辅助标记在什么场景触发混合写屏障两条规则以及它为什么消灭了重新扫栈的大 STWGOGC 和 GOMEMLIMIT 的分工容器环境的推荐配置gctrace 每一段的读法能用它定位GC 频率高还是单次 GC 慢。写到这里本篇约 8000 字把 GC 的机制、观测、调优都铺完了。如果这篇帮你把Go GC 为什么几乎不卡想明白了建议收藏调参那三张表观测命令、参数对照、故障案例是能直接带进项目里的。留一个一句话能答的具体问题你的服务现在跑在默认GOGC100下有没有打开过GODEBUGgctrace1看一眼自己服务的 STW 到底是多少很多团队的答案会是没有——今天就可以看一下。下篇预告「万字详解 | Go 进阶三channel 源码」——channel 的发送接收在运行时层面到底怎么阻塞和唤醒 goroutine为什么 100 万次 channel 通信比一次 mutex 慢在哪以及那些把服务写死锁的 6 种 channel 用法。想看的话点个关注更新不迷路。作者利威尔xu | CSDN 专栏《GO保姆级万字详解》