Go GC 演进史:从标记清扫到混合写屏障,一文讲透垃圾回收为什么这样设计

张开发
2026/7/1 8:10:11 15 分钟阅读
Go GC 演进史:从标记清扫到混合写屏障,一文讲透垃圾回收为什么这样设计
Go GC 演进史从标记清扫到混合写屏障再到 Green Tea一、大纲二、先别急着谈算法先搞懂 GC 到底在干嘛三、Go 早期的基本盘标记-清扫Mark-Sweep1Mark标记2Sweep清扫四、Go 1.3先把地基打稳暂停先砍一刀第一件事GC 性能明显加速第二件事栈扫描变精确了所以说五、Go 1.5真正的转折点并发三色标记登场六、为什么会出现三色标记三种颜色分别是什么为什么三色标记比“普通标记”更重要七、并发标记真正的难点业务代码还在改指针八、强三色不变量最直观但也最贵九、写屏障到底是什么十、插入写屏障先保护“新写进去的指针”十一、删除写屏障再保护“被覆盖掉的旧指针”十二、为什么插入写屏障还不够问题出在栈上十三、弱三色不变量允许“暂时违规”但不能真的丢对象十四、Go 1.7 的最后痛点长尾暂停还是可能来自栈重扫十五、Go 1.8真正封神的一刀混合写屏障来了混合写屏障到底混了什么它到底解决了什么十六、混合写屏障不是白来的它是一种权衡1会产生更多 floating garbage2会限制一部分优化空间3仍然不是“零成本”十七、GOGCGo 为什么一直强调“单旋钮哲学”GOGC 控制的到底是什么初学者怎么理解最合适十八、Go 1.19容器时代来了GOMEMLIMIT 也来了为什么说它是“软”的十九、Go 现在的 GC 还是那一套吗是也不是二十、Green TeaGo 1.25 试验Go 1.26 默认启用1. 解决的核心痛点CPU 缓存未命中 (Cache Miss)2. Green Tea 的三大核心操作3. 这对实际业务有什么影响4. 官方给的说法二十一、那初学者学 Go GC到底该记住什么第一层Go GC 的大框架没变第二层Go GC 演进的真正目标不是“换算法”而是“持续降停顿”第三层后面那些名词本质都在解决一个问题二十二、结尾Go GC 这条线最值得学的不是“答案”而是“为什么会变成今天这样”参考资料一、大纲本篇文章我会紧贴着Go演进的顺序来讲Go 1.3精确栈扫描、并发 sweep、并行化先明显降暂停Go 1.5并发三色 mark-sweep 成型目标是低延迟Go 1.7继续压暂停但栈重扫仍是长尾痛点Go 1.8混合写屏障消灭 STW 栈重扫常见 pause 压到 100 微秒量级Go 1.19GOMEMLIMIT 让 GC 更适配容器和内存预算场景Go 1.26Green Tea 默认启用继续从 locality 和 CPU scalability 上挖性能之所以这样是因为Go GC 的每一次变化几乎都不是“换个更时髦的算法”而是前一代方案在真实工程里碰到了具体问题于是 Go 团队再补一刀。最早是STW 太长后来变成怎么并发标记再后来变成并发标记时怎么不漏对象然后又变成怎么把尾部停顿继续压低再往后就是怎么让 GC 和容器时代、现代 CPU、更大的并发规模更匹配。你读完之后至少应该能回答三个问题Go 的 GC 到底在回收什么Go 的 GC 为什么会从“标记清扫”一路演进到“混合写屏障”Go 现在的 GC大致又发展到了什么阶段二、先别急着谈算法先搞懂 GC 到底在干嘛我在这篇文章Go GC 非玄学而是 CPU 和内存的权衡已经详细探讨过了)GC全称 Garbage Collector垃圾回收器。它解决的不是“怎么分配内存”而是“哪些内存已经没用了可以自动收回”。你写 Go 的时候经常会这样funcmain(){a:User{Name:zhangsan}fmt.Println(a.Name)}这个User对象在程序运行过程中会占用内存。当后面再也没有任何变量能访问到它时这块内存如果不被回收就会一直占着。对象越来越多程序内存就会越来越大最后把机器吃爆。所以 GC 的核心任务本质上就一句话找出“还活着”的对象回收“已经死了”的对象。这里最关键的判断标准不是“我觉得这个对象没用了”而是这个对象是否仍然可达reachable只要一个对象还能从一组“根对象roots”出发通过指针一路找到它就还活着。如果完全找不到了它才算垃圾。那什么叫 roots通常可以先把它理解成这些起点goroutine 栈上的变量全局变量runtime 自己维护的一些根结构所以GC 不是从天而降扫描整片宇宙而是从这些起点出发一路沿着指针往下找。能找到的说明还活着找不到的才有资格被回收。三、Go 早期的基本盘标记-清扫Mark-SweepGo GC 的大框架长期以来都属于tracing mark-sweep也就是“追踪式标记-清扫”。拆成两步来理解1Mark标记从 roots 出发顺着指针往下遍历把所有还能到达的对象都标出来。2Sweep清扫标记完成后没被标记到的对象就是不可达垃圾把这些内存回收掉。你可以把它想成一次宿舍大扫除先检查哪些东西还有人在用贴个“保留”标签再把没贴标签的杂物都清出去。这个思路非常朴素也非常经典。但它有一个很现实的问题如果整个标记和清扫过程都要求业务线程停下来那停顿会非常长。这就是早期 GC 最核心的痛点不是不能回收而是回收的时候“全世界一起停下来”对服务型程序很难接受。四、Go 1.3先把地基打稳暂停先砍一刀(Go-1.3)很多人看 Go GC 演进会直接从 Go 1.5 开始讲三色标记。从而忽略了 Go 1.3其实这会漏掉一个很重要的铺垫。因为Go 1.3 做了两件特别关键的事第一件事GC 性能明显加速垃圾回收器做了加速使用了concurrent sweep做了更好的并行化使用了更大的 pages累积效果可以带来50%–70% 的 collector pause time 降低也就是说Go 1.3 时代Go 团队已经开始非常明确地在做一件事把“停顿太长”这个问题往下压。第二件事栈扫描变精确了很多人都有这样的疑问1、GC 主要回收的是堆不是栈那为啥还要和栈扫描搭上关系2、但 GC 必须扫描栈因为栈上有可能保存着指向堆对象的指针。3、Go 1.3 的“栈精确扫描”本质就是让运行时更准确地区分栈上的指针和普通值。Go 1.3 让栈上的值也变成了 precise。什么意思简单说GC 以后不再会把一个普通整数错认成指针了。这意味着非指针值不会再被误判成“可能引用了某块内存”本来应该释放的对象不会因为误判而被错误保活GC 的准确性更高了所以说虽然Go 1.3时GC 还在用标记-清扫的大思路但运行时已经开始大幅优化停顿并发化同时把栈扫描做得更精确给后面更激进的并发化打地基所以 Go 1.3 的意义不是“革命”而是把旧路修平让后面的车能跑得更快。五、Go 1.5真正的转折点并发三色标记登场(Go-1.5)注Go 1.5 说的“并发”不是“很多 GC 线程自己互相并发”这么简单核心是GC 在标记对象时用户程序也还能继续跑。如果说 Go 1.3 是“铺路”那 Go 1.5 就是真正的“拐弯”。这一代 GC 的核心变化是Go 的 GC 变成了 concurrent, tri-color, mark-sweep collector。这句话非常重要。因为从这里开始Go GC 的逻辑变成了不是简单地做标记清扫而是并发的三色标记-清扫而且 Go 1.5 官方给的数据STW 阶段通常都能做到低于 10ms。(Go)注(STW Stop The World。垃圾回收器在某些关键时刻会先把用户程序暂时全部停下来。)对于服务端程序来说这个意义很大。因为这意味着 Go 团队不再满足于“把停顿减少一点”。六、为什么会出现三色标记其实三色标记只是给 GC 的遍历进度做了一个颜色分类。三种颜色分别是什么白色还没被发现的对象灰色已经发现了但它指向的对象还没全部检查完黑色自己已经检查完了自己指向的对象也都处理进流程了整个过程是这样的一开始所有对象默认都是白色GC 从 roots 出发把直接能碰到的对象先染成灰色然后不断从灰色集合里拿对象出来扫描扫描完一个灰对象后把它染成黑色如果它指向了新的白对象就把那些白对象染成灰色直到再也没有灰对象可处理最后剩下还白着的对象就是不可达垃圾。为什么三色标记比“普通标记”更重要因为它天然更适合描述并发标记的过程。如果没有颜色状态你只知道“扫过没扫过”但有了白、灰、黑三种状态后GC 就能在“边扫描、边推进”的过程中知道哪些对象完全没碰过哪些对象已经进入待处理队列哪些对象已经安全处理完所以三色标记是为了让并发标记有一个清楚的状态模型。七、并发标记真正的难点业务代码还在改指针到这里一个更麻烦的问题出现了。如果 GC 自己安安静静扫堆那事情很简单。但真实业务场景往往是GC 在标记你的业务 goroutine 也没停业务代码还在不断修改对象之间的指针关系这就会引出一个致命风险GC 扫描过程中对象图在变。比如某个黑对象本来已经扫描完了结果业务代码突然给它塞进来一个新的白对象引用那 GC 可能再也不会重新扫描这个黑对象于是这个白对象明明还活着却被 GC 当成垃圾误回收了这就是并发 GC 的核心难题怎么保证“活对象不会被漏标”Go 官方 Go 1.5 GC 博文对这个问题的表述非常直接必须维护某种不变量避免黑对象指向白对象。而后面所有的“强三色不变量、弱三色不变量、写屏障”其实都只是围绕这一件事展开。八、强三色不变量最直观但也最贵所谓强三色不变量最经典的是黑对象不能指向白对象。这条规则的好处非常明显很好理解且明确一旦成立GC 就不容易漏掉活对象但问题也明显你要一直保证它成立。只要 mutator 在运行它就会不停改指针。那你就必须在某些写指针的地方做额外处理防止黑对象突然接到一个白对象。也就是说强三色不变量虽然直观但维护它是有成本的。这个成本最终就落到了“写屏障”上。九、写屏障到底是什么很多人一看到“写屏障”三个字就慌以为是特别底层的东西。其实可以把它理解成一句话写指针时顺便帮 GC 做个登记。也就是说平时你写代码只是a.nextb但在并发 GC 期间运行时可能会在这个写操作前后插入一点额外逻辑确保这次指针变更不会把某个活对象藏起来。Go runtime 的mbarrier.go是这样描述的Go 编译器会在可能影响堆对象指针关系的更新处发出写屏障调用。所以你第一次学写屏障不要把它想成“新的 GC 算法”它只是一个辅助机制GC 在并发标记时用来维护正确性的护栏。注插入/删除写屏障都是为了防止误删。插入写屏障是为了防止把新加进来的给删除掉了。也就是黑对象挂白对象的问题。删除写屏障是为了防止把旧的指针却仍然可能有用的给删除掉了。十、插入写屏障先保护“新写进去的指针”Go 在一段时期里使用的是 Dijkstra 风格的写屏障。它的核心思想很简单当你准备把一个新指针写进某个 slot 之前先把这个新指针指向的对象 shade 一下。也就是常说的插入写屏障。伪代码可以先这么理解shade(new) *slot new它保护的是什么保护的是“新边”。也就是防止你把一个本来还是白色的对象直接挂到某个已经黑化的对象下面导致它逃过 GC 的视线。你可以把它理解成一个新住户要搬进已经检查过的小区楼栋那就先给这个新住户做登记确保后面还能追踪到他十一、删除写屏障再保护“被覆盖掉的旧指针”通常和弱三色标记的场景结合在一起另一条路线是 Yuasa 风格的删除写屏障。它的想法不是盯着你“写进去什么”而是盯着你“删掉了什么”。也就是说当你覆盖一个旧指针时先把旧指针原来指向的对象 shade 一下。伪代码大概像这样shade(old *slot) *slot new它保护的是什么保护的是“旧边”。因为有一种危险场景是某个对象当前只有这一条引用链还连着你一改指针把这条边删了这个对象就可能一下子从 GC 视野里消失所以删除写屏障的本质是我允许你删引用但删之前先让我记住这个对象。Go 现在的 runtime 写屏障注释里明确提到Go 使用的混合屏障包含了 Yuasa-style deletion barrier 和 Dijkstra insertion barrier 两部分。十二、为什么插入写屏障还不够问题出在栈上这里是 Go GC 演进史里最关键的转折点之一。如果只有堆对象那插入屏障已经很有用了。但 Go 运行时还有一个特别麻烦的存在goroutine 栈。问题在于Go 有很多 goroutine每个 goroutine 都有自己的栈栈会动态增长和收缩栈上的写入非常频繁如果你给栈上每一次指针写入都加写屏障成本很高于是 Go 早期做了一个折中栈上的写不全部加屏障。但这就带来一个后果栈可能把白对象“藏起来”于是 Go 的处理方式变成GC 开始时先扫一次栈但因为后面栈还会继续变化所以在 GC 结束前还得再把这些栈重新扫一遍这一步为了保证一致性通常需要 STW这就是非常著名的STW 栈重扫stack re-scanning十三、弱三色不变量允许“暂时违规”但不能真的丢对象这时候就轮到弱三色不变量出场了。和强三色不变量不同弱三色不变量不是死板地要求黑对象绝不能指向白对象它允许某种“看起来暂时违规”的情况出现但要求满足一个条件这个白对象必须仍然被某条灰路径保护着。Go 官方设计文档里把这种状态叫做grey-protected。你可以把它理解成强三色不变量要求“现场绝对不能乱”弱三色不变量允许“现场暂时有点乱”但必须保证这件东西没有真的离开监控范围这就是两者最核心的差异强三色要求现在立刻就正确弱三色允许过程里稍微绕一点但最终不能漏对象而 Go 后面要走向混合写屏障本质上就是因为强三色 栈重扫 的代价已经越来越难接受了。十四、Go 1.7 的最后痛点长尾暂停还是可能来自栈重扫(Go-1.7)Go 1.7版本时 官方已经提到某些程序的 GC 暂停会明显缩短尤其是大量 idle goroutines栈大小波动大包级变量很多 的场景。Go 团队那时已经在持续优化暂停问题。但注意这不代表问题彻底没了。因为只要STW 栈重扫还存在它就始终是一个潜在的长尾来源。对于后端服务来说平均延迟下降当然是好事。但很多时候真正杀服务的不是平均值而是 P99、P999。注P99999.9% 的请求都不超过这个时间最慢的 0.1% 会超过它而栈重扫恰恰就是那种平时你可能不太感觉到但一旦 goroutine 多起来或者栈形态复杂起来它就会在尾部给你来一刀所以我认为 Go 1.7 是一个很典型的“过渡版本”并发三色 GC 已经成型但尾延迟的大刺还没完全拔掉真正把这根刺拔掉的是 Go 1.8。十五、Go 1.8真正封神的一刀混合写屏障来了(Go-1.8)此时如果你只记 Go GC 历史里的一个版本节点那就是 Go 1.8。因为 Go 1.8 做成了一件非常棒的事消灭了 STW 栈重扫因为GC pause(GC暂停) 通常已经低于 100 微秒很多场景甚至能低到 10 微秒左右背后的关键原因就是消除了 stack re-scanning 这类 STW 工作。混合写屏障到底混了什么Go runtime 的mbarrier.go已经把伪代码直接写出来了writePointer(slot, ptr): shade(*slot) if current stack is grey: shade(ptr) *slot ptr也就是先 shade 被覆盖掉的旧指针如果当前 goroutine 的栈还是灰的再 shade 新写入的指针最后再真正写入*slot ptr这正是Yuasa 删除屏障加上Dijkstra 插入屏障再配上一个“当前栈是否还是灰”的条件所以它叫hybrid write barrier混合写屏障。它到底解决了什么它解决的不是“对象还要不要继续置灰”而是已经扫过的栈还要不要在 GC 尾部被 STW 整体重扫一遍。Go 1.8 的混合写屏障通过在指针写入时提前兜底从而避免 STW 对栈整体重扫。当然新置灰对象当然还会继续被正常标记处理。十六、混合写屏障不是白来的它是一种权衡看到这里很多人容易有一个错觉那混合写屏障是不是完美方案这不是废话吗哪里有完美的它只是一种工程权衡。比如它会带来什么1会产生更多 floating garbage也就是某些对象在这一轮 GC 中虽然按直觉“已经快死了”但由于并发过程中的保守处理它们可能还会先被保留到下一轮再真正清掉。2会限制一部分优化空间有些原本可以在别的屏障模型里做的 barrier elimination在混合屏障下未必仍然安全。3仍然不是“零成本”写屏障本身就是额外工作只不过 Go 团队判断这些成本比 STW 栈重扫带来的长尾停顿更值得接受。这也是为什么工程里经常不是“哪个理论最优”而是哪个方案在真实服务里更划算。十七、GOGCGo 为什么一直强调“单旋钮哲学”Go 团队有一个很鲜明的风格不希望 GC 调优变成“旋钮地狱”。Go 1.5 的官方 GC 博文就明确强调过他们更倾向于提供一个核心旋钮而不是堆一大堆复杂参数。这个旋钮就是GOGCGOGC 控制的到底是什么Go 官方 GC 指南给了一个非常关键的公式Target heap Live heap (Live heap GC roots) * GOGC / 100第一次看这个公式可能有点硬。你可以先记住它背后的直觉GOGC越大→ 堆可以长得更大再触发 GC→ GC 次数变少→ GC CPU 压力通常更低→ 但内存占用更高GOGC越小→ 更早触发 GC→ 内存更省→ 但 GC 会更频繁→ CPU 代价更大官方指南甚至直接给了非常实用的认知模型GOGC翻倍内存开销大致翻倍GC CPU 成本大致减半。这当然不是精确到每个程序都一模一样但作为工程判断已经非常够用了。初学者怎么理解最合适你先不要把GOGC想成“性能开关”它更像CPU 和内存之间的交易比例。Go 把这个权衡尽量压缩成一个核心旋钮这就是它所谓的“简单调优哲学”。十八、Go 1.19容器时代来了GOMEMLIMIT 也来了到了现代服务端环境问题又变了。以前你可能只关心GC 别太卡内存别太高但容器时代里还有一个现实问题很多服务有非常明确的内存上限。比如你的容器就给 512MiB。那你不能只盯GOGC因为GOGC本质上是在调“增长比例”不是在直接约束总内存。所以 Go 1.19 引入了一个特别关键的新能力soft memory limit也就是我们平时说的GOMEMLIMIT或者runtime/debug.SetMemoryLimit。Go 1.19 官方 release notes 明确写到这是一个软内存上限包含 Go heap 和其他由 runtime 管理的内存不包含程序二进制映射、本进程里其他语言管理的内存、操作系统代持内存等外部来源即使GOGCoff它也仍然会被尊重。为什么说它是“软”的因为官方 GC 指南对一个问题讲得非常透如果你把 limit 设得过低GC 可能会进入一种接近“永远在收、永远收不完”的状态。这个状态叫thrashing(抖动)为了避免程序被 GC 活活拖死Go runtime 还专门加了一个限制GC CPU 利用率大约限制在 50% 左右宁可稍微超一点内存也要保证程序还能继续推进。这个设计特别能体现 Go runtime 的工程取舍它不是死守 limit而是优先防止程序彻底失去进展能力所以GOMEMLIMIT很适合容器场景但它也不是“设了就万事大吉”。它本质上是在告诉 runtime“这台机器上的内存预算大概到这儿了你尽量别冲太高。”十九、Go 现在的 GC 还是那一套吗是也不是如果你学到这里再回头看最开头那种老式视频目录标记清扫三色标记强弱三色不变量插入写屏障删除写屏障混合写屏障你会发现它其实没过时。因为这些确实构成了 Go GC 的核心演进主线。但如果你问Go 现在的 GC还是停在 Go 1.8 那套吗答案是核心框架没变但实现还在继续优化。也就是说Go 现在仍然是tracing GC追踪式GC比如通过root顺藤摸瓜mark-sweep标记清除non-moving非移动地址定死了concurrent并发precise精确确保是整数还是指针基于写屏障维护并发标记正确性。但这不代表 Go 团队后面什么都没做。后面的重点更多放在更好的节奏控制更好的资源利用更适配容器更适配现代 CPU 和缓存层次这条线最终就引到了 Green Tea。二十、Green TeaGo 1.25 试验Go 1.26 默认启用(Go- Green Tea)如果你耐心读到这里那恭喜你已经接触到了 Go 语言非常前沿的底层技术了。Green Tea绿茶是 Go 语言在近期版本Go 1.25 实验性引入2026年2月刚发布的 Go 1.26 中已默认开启中对垃圾回收器进行的一次底层核心大升级。它把 GC 的工作模式从“盯着单个对象顺藤摸瓜”变成了“以内存页为单位进行的批量扫荡”从而将 CPU 缓存的性能压榨到了极致。为了方便你直观的理解我将用它与传统方式做一个对比1. 解决的核心痛点CPU 缓存未命中 (Cache Miss)在 Green Tea 之前标记阶段Mark是一个典型的“追着指针跑”的过程。比如对象 A 引用了对象 B但 A 和 B 在物理内存上可能隔得十万八千里。GC 扫完 A 去找 B 时由于内存地址跨度太大CPU 的高速缓存L1/L2 Cache里根本没有 B 的数据。此时 CPU 只能停下来干等着数据从慢吞吞的主存RAM里加载进来。注(L1一级缓存L2二级缓存)据官方统计在内存密集型应用中旧版 GC 有高达 35% 的 CPU 周期都浪费在了这种等待上。2. Green Tea 的三大核心操作为了消除这种浪费Green Tea 改变了玩法从“按对象”变成“按页”扫描 (Page-centric Scanning)它不再去全局的工作队列里追踪一个个零散的指针而是按“内存页 (Page / Span)”来批量处理。它把标记对象的状态维护在每个页面内部而不是整个堆上。极致的空间局部性 (Spatial Locality)因为是以页为单位按顺序扫描当 GC 读取页头的数据时CPU 硬件的预取机制会顺手把紧挨着的数据也提前加载进高速缓存。这样在处理相邻对象时几乎是瞬间读取极大地消除了 CPU 的等待时间。巧妙的位图运算 (Bitmap Magic)它给每一页维护了几个位图就像一排 0 和 1 的开关分别记录哪些对象“被看见了 (seen)”、哪些“已经扫过了 (scanned)”。通过极其高效的底层二进制计算比如取差集就能瞬间算出这一页里还有哪些对象处于活跃状态需要扫描彻底告别了繁琐的对象级状态跟踪。如果一页里只有一个活对象它甚至有专门的短路逻辑连整个页面都不用扫。3. 这对实际业务有什么影响作为开发者业务代码一行都不用改就能直接获得底层的性能红利更低的 CPU 开销在那些疯狂分配和销毁小对象的重度负载中GC 带来的 CPU 开销直接降低了 10% 到 40%。简单来说之前的 GC 是个尽职尽责但有点死脑筋的清洁工到处跑着捡垃圾而 Green Tea 是个懂硬件脾气的规划大师它把垃圾按区域划分好开着扫地车一路平推效率自然大幅提升4. 官方给的说法每看一遍我还是觉得很酷。可以按“问题 → 做法 → 结果”这条线用 1 分钟总结旧 GC 的问题不是“不会标记”而是标记阶段更像在堆上做 graph flood刚扫完一个对象马上被指针带去很远的另一个对象内存访问很分散。Go 官方说旧版 GC 的标记成本里通常至少有35%是耗在访问堆内存时的停顿上。Green Tea 的核心思路是“work with pages, not objects”工作队列更偏向跟踪页而不是零散对象页内再用元数据记录哪些对象已经被发现、哪些已经扫过于是扫描时可以尽量把同一页里该处理的对象顺着扫掉。这样并不是“不认对象之间的引用关系”了而是对象关系照样通过指针发现只是处理顺序更有局部性。所以它优化的是标记阶段的内存访问方式不是改掉 GC 的基本规则。如果被别人追问“按页扫了跨页引用怎么办”你就接这一句Green Tea 不是按对象满堆乱跳而是从 roots 出发把“要处理的页”放进工作队列扫描某一页时只处理本页里已经被发现seen但还没扫描scanned的对象遇到跨页指针时就给目标页里的对象打 seen 并把目标页排进后续处理从而把对象关系的发现和扫描顺序的组织分开。二十一、那初学者学 Go GC到底该记住什么如果你是第一次系统学 GC但已经有一定 Go 语法基础我建议你先把下面这条主线彻底记住第一层Go GC 的大框架没变Go 长期以来的核心框架就是tracing mark-sweep concurrent non-moving第二层Go GC 演进的真正目标不是“换算法”而是“持续降停顿”版本为啥这样做采用了什么技术优点缺点 / 代价Go 1.3早期 GC 停顿太长先把地基打稳精确栈扫描、concurrent sweep、并行化、更大 pages暂停先明显下降栈上“指针 / 普通值”区分更准误保活更少还没有真正进入成熟的并发低延迟 GC 阶段整体框架仍偏传统Go 1.5不能再接受“大停顿”目标转向低延迟并发三色 mark-sweep、写屏障开始成为核心辅助机制GC 标记时业务还能继续跑STW 大幅缩短Go GC 真正进入现代阶段并发标记下对象图会变化正确性维护变复杂后面必须引入不变量和屏障来兜底Go 1.7已经并发了但还想继续压暂停尤其是尾延迟延续并发三色 mark-sweep继续优化 pause但栈因为不全面加屏障仍需STW 栈重扫某些场景 pause 继续变短栈重扫还是长尾刺goroutine 多、栈波动大时P99/P999 仍可能被打爆Go 1.8目标就是拔掉 Go 1.7 剩下的那根大刺STW 栈重扫混合写屏障删除屏障 插入屏障 “当前栈是否还是灰”的条件已扫描栈不必在 GC 尾部整体 STW 重扫常见 pause 压到100 微秒量级尾延迟显著更稳不是零成本写屏障本身有额外开销还会带来更多floating garbage属于工程权衡Go 1.19容器时代需要“内存预算”视角不能只靠 GOGC 控比例GOMEMLIMIT / soft memory limit更适合容器和受限内存环境即使GOGCoff也能约束 runtime 内存行为是软上限不是绝对红线设太低会导致 GC thrashing只能在“限内存”和“保进展”之间折中Go 1.26继续优化 GC但这次重点不是 pause而是CPU 缓存局部性和可扩展性Green Tea从更偏“按对象乱跳”改成更偏“按页组织扫描”页内维护seen/scanned状态减少 cache miss降低 GC CPU 开销在重 GC 负载下更有价值复杂度更高它优化的是标记阶段的访问方式不是改掉 GC 的基本可达性规则第三层后面那些名词本质都在解决一个问题不管你看到的是强三色不变量弱三色不变量插入写屏障删除写屏障混合写屏障它们其实都在回答同一个问题并发标记时mutator 还在改指针GC 怎么保证活对象不会丢。([Go][38])只要你把这三层想明白Go GC 这块就已经不是“背概念”而是真正入门了。二十二、结尾Go GC 这条线最值得学的不是“答案”而是“为什么会变成今天这样”很多底层知识第一次学时都容易变成“记结论”。但 Go GC 这块我反而特别建议你别只背结论。因为它最有价值的地方不是某个版本具体做了什么而是你能从中看到一条非常典型的工程演进路径从最初的做对到把系统做快最后又把停顿做小现在又开始结合容器现代CPU…所以 Go GC 真正值得学的不只是“混合写屏障是什么”。更重要的是你会慢慢理解一个成熟的运行时不会靠一招鲜吃天下而是靠很多次“看起来不酷、但特别值钱”的优化一点点走到今天。而 Go GC 的演进史恰恰就是这样一个特别好的例子。参考资料Go 1.3 Release Notes (Go)Go 1.3 is released (Go)Go GC: Prioritizing low latency and simplicity (Go)Go 1.7 Release Notes (Go)Go 1.8 Release Notes / Go 1.8 is released (Go)A Guide to the Go Garbage Collector (Go)Go 1.19 Release Notes (Go)runtime/mgc.go 注释 (Go)Proposal: Eliminate STW stack re-scanning (Go Git Repositories)The Green Tea Garbage Collector (Go)Go 1.26 Release Notes (Go)

更多文章