Go语言换了新清洁工,垃圾回收快了40%

Go语言换了新清洁工,垃圾回收快了40%

Gogarbage collectionprogrammingperformanceGreen Tea

数据源:HN + web research · HN

2026年2月,Go 1.26 正式发布。这次更新悄悄做了一件事:把整个语言的垃圾回收器(GC)换掉了。新回收器名叫 Green Tea——绿茶。这大概是 Go 语言历史上对运行时(runtime)最大的一次手术。

大多数用户升级后感觉不到变化。代码一行不用改,跑起来就是快了。官方数据说,GC 占用时间平均降了 10%,最理想的情况能降 40%。这是语言基础设施级的升级——影响所有用 Go 写的程序。

垃圾回收是什么?

先说基础。一台程序在运行时会不停地分配内存——创建变量、构建数据结构,用完了就不管了。垃圾回收器的活儿就是找出那些「用完了不管」的内存,清掉,腾给后面的使用。

可以想象一个大型仓库。程序从货架上取货,用完了随手一扔。垃圾回收就是那个清洁工,定期进场,把废弃的货物清走,腾出空货架给新人用。

问题在于:清洁工怎么知道哪些该清、哪些不该清?

Go 的旧方案是「标记-清扫」。从所有已知的「根」出发——全局变量、当前函数里的局部变量——顺着指针一路找。能到达的对象还在用,标记为「活的」;走不到的对象就是垃圾,回收。

这听起来很合理。但这套方案有一个核心性能瓶颈:指针散布在内存各处,清洁工为了找到它们,得在仓库里到处跑。

老 GC 的问题:指针在流浪

Go 的内存分配策略按对象大小分区。32 字节的对象放在一片区域,64 字节的放在另一片,128 字节的又一片。每种尺寸的对象在各自的「页」里紧凑排列。这本身是好事——TCMalloc 这类内存分配器也用类似思路。

但对象的引用关系不受尺寸约束。一个大对象可能引用一个小对象,而这两个对象的物理地址可能相隔很远。GC 做标记阶段时,拿到一个指针就得跳到目标地址去,跳过去发现还有更多指针,再跳。结果就是清洁工的路线图是一张乱窜的地图,在一个个地址之间随机跳跃。

Go堆上三类对象分布的可视化

图:用程序可视化 Go 堆上三类对象的分布。M(Medium)、S(Small)、L(Large)各自在独立区域内连续排列。来源:theconsensus.dev

硬件用缓存来解决延迟问题——CPU 有 L1、L2、L3 三级缓存,热数据放在缓存里,读写快得多。但老 GC 那种随机跳转的扫描方式,正好是缓存最怕的模式。缓存在「预判接下来要访问哪块内存」这件事上几乎完全失效。

The Consensus 的作者 Phil Eaton 用 perf 做过实测。在一台裸金属 x86 机器上,他构造了一个 200 万节点的链表式图,分别用「紧凑排列」和「随机散布」两种方式组织节点,测量老 GC 在不同模式下的表现。

两种模式的核心指标如下——差异非常直观:

工作负载L1 未命中/千指令执行时间
老GC + 紧凑2.23 MPKI4.47秒
老GC + 散布31.57 MPKI11.44秒

同样是 200 万个节点,只是内存布局不同,L1 缓存未命中率差了 14 倍,执行时间差了 2.5 倍。对于服务器后端等内存密集的应用,老GC的随机访问模式就是性能的隐形杀手。

C# 走的是另一条路:它的 GC 会搬动对象——把活着的对象聚到一起,碎片压倒,释放连续大块内存。代价是搬动本身有开销。Go 从设计之初的哲学就是「不搬动」——对象一旦分配,地址终生不变。这让 C 语言互调用(cgo)和 goroutine 栈管理更简单,但把碎片问题的包袱甩给了开发者。

Green Tea 改了啥?

Green Tea 的核心思路听起来简单:不逐个指针跳,按页整块扫。

Go 以 8KB 为一「页」管理内存。同类尺寸的对象放在同一个「span」(若干连续页)里。旧 GC 在标记阶段拿到一个对象后,跟着它的指针跳到其他 span,再跳回来。Green Tea 的做法是:拿到一个 span,把里面的对象和指针先全部扫一遍,记下来,然后再统一处理下一块。

Green Tea vs 传统GC扫描路径对比

图:Green Tea(绿色)与传统 GC(灰色)在内存页上的扫描路径对比。Green Tea 做更少、更长的连续扫描。来源:go.dev/blog/greenteagc

这带来的直接收益是缓存局部性的改善。同一页内的对象在物理上挨得近,连续扫描时 CPU 缓存自然生效,不用反复去主存里捞数据。

The Consensus 的实测数据显示了 L1 缓存层面的改善——这是虚拟机里看不到的,必须上裸金属才能测:

工作负载L1 MPKI(越低越好)执行时间
老GC + 紧凑2.234.47秒
Green Tea + 紧凑1.982.70秒
老GC + 散布31.5711.44秒
Green Tea + 散布14.067.01秒

L1 缓存未命中率的下降是实打实的。紧凑排列的场景 MPKI 从 2.23 降到 1.98,散布场景从 31.57 降到 14.06——降幅超过一半。执行时间也同步缩短了 40% 左右。

有趣的是,L3 缓存的未命中率在新 GC 下反而上升了。官方博客也坦诚讨论了这一点:L3 未命中率上升,但 L1 未命中率大幅下降,总执行时间缩短——说明更多内存访问止步于 L1 缓存,压根没走到 L3。L3 未命中率的绝对值并不等于性能差,关键要看瓶颈在哪一级缓存。

这也是 Green Tea 论文在社区讨论中遇到的一个质疑点:如果你只看 L3 指标,结论会是「新 GC 更差」。这提醒我们,性能不是单一指标的比较,必须理解测量对象和测量手段的局限。

补不上的窟窿:非移动GC的顽疾

Green Tea 改善了扫描效率,但 Go GC 有一个更底层的限制没解决:不搬动对象。

因为 Go 保证对象地址不变,GC 无法做内存整理。释放掉的对象留下一堆空洞,幸存对象散布在各页上,GC 不能把它们搬到一起。结果就是:你释放了 90% 的对象,但占用的物理内存可能只降了很少一点。

释放90%对象后Go堆的内存散布

图:释放 90% 对象后,存活的对象散落在各页中,Go 无法将同页空洞合并返还给 OS。来源:theconsensus.dev

The Consensus 的程序演示了这一点。50000 个对象,释放 90%,活下来的 5000 个散布在 463 个 8KB 页上。如果它们能搬到一起,理论上只需要 46 页。结果 HeapInuse 完全没降,6320KB 堆内存里只能释放 5000KB 左右给 OS——因为每页上还有 1-2 个幸存对象占着,OS 不能回收。

C# 就没这个问题。同样释放 90%,C# 的 GC 会搬动幸存对象、压缩堆,页数从 458 降到了 47,几乎达到了理论极致。搬动有开销,但带来的空间效率很实在。

Go 的社区对此有清醒的认识。Green Tea 的两位作者 Michael Knyszek 和 Austin Clements(也是 Go 团队的核心成员)在 GopherCon 2025 的演讲中坦言:Green Tea 是 GC 优化的第一大步,不是最后一步。非移动 GC 的碎片问题,需要后续的 heap compaction 等机制逐步解决。

谁受益,谁不受益?

Green Tea 的收益分布并不均匀。官方博客给出的结论是「大部分工作负载 GC 开销降低 10% 左右,少数场景可达 40%」。哪些场景受益最大?

受益明显的场景: 大量指针链遍历的图算法、缓存敏感的服务端中间件、需要频繁 GC 的高吞吐系统。这些场景的共同特征是——扫描路径长、指针随机度大,正好是 Green Tea 的优化目标。

受益有限的场景: 嵌入式系统、tinygo 目标、极少分配内存的数字计算。如果程序本身几乎不触发 GC,换什么回收器差别都不大。

还有一个参数需要注意:程序自身的分配模式和存活率。如果大部分对象都在短时间内死亡(typical for request-handling servers),GC 的负担本来就不重,优化空间有限。反之,如果对象存活时间长、指针图复杂,Green Tea 的优势就更明显。

官方博客引用了 bleve-index 基准测试,这个测试的堆拓扑结构对 Green Tea「不太友好」,整体性能基本持平。社区有人推断这可能是因为该测试的指针路径本身就比较规整,随机性低,Green Tea 的缓存优化无从发挥。

一次谨慎的升级

Go 1.25 时 Green Tea 以实验开关(GOEXPERIMENT=greenteagc)首次出现,给出了整整一个版本周期让社区试用和反馈。到 1.26 才正式设为默认。任何时候觉得不满意,还可以用 GOEXPERIMENT=nogreenteagc 编译回退到旧 GC。

这种渐进式的策略体现了 Go 团队对生产环境稳定性的重视。回想当初 Go 1.5 把纯 C 实现的 GC 换成 Go 实现时,也是类似的节奏——先作为实验,收集反馈,确认没有回归性能再做默认。

对于普通 Go 开发者,这次升级几乎没有迁移成本。代码不改、依赖不动、构建脚本不变——升级到 1.26 后默认就用了新 GC。唯一可能需要注意的:如果你的项目对 GC 行为做了深度定制(比如主动调 debug.SetGCPercent、使用 runtime.GC() 的手动触发模式),建议先用 GOEXPERIMENT=nogreenteagc 跑一轮基准测试对比,确认新 GC 的缓存行为不会打乱原有的调优参数。

基础设施升级的启示

Green Tea GC 这次升级提供了一个值得回味的观察:在「不搬动」这个硬约束下,Go 团队通过改善扫描顺序——本质上是在做算法级的内存布局优化——拿到了 10% 到 40% 的性能提升。

这不是重写运行时,也不是引入宏伟的架构变革。它就是优化了一个关键环节的访问模式。做对了,效果就是系统性的。

非移动 GC 的碎片问题是 Go 接下来需要啃的硬骨头。社区已经有所讨论,未来是否引入移动式 GC 或部分压缩机制,现在还没有明确的时间表。但从 Green Tea 的推进节奏来看,Go 团队对这种基础设施级的改动非常谨慎——先用实验开关收集一年数据,确认后才翻成默认。下一个大改动,大概率也是这个节奏。

对于关注编程语言发展的开发者来说,Green Tea GC 是一个不错的「回头看」案例:当系统已经优化到一定程度后,微架构层面的缓存行为会成为新的增长空间。

参考链接:

  • The Consensus: Watching Go’s new garbage collector move through the heap
  • Go Blog: The Green Tea Garbage Collector
  • HN 讨论 (item?id=49075296)
  • Go 1.26 Release Notes
  • GitHub Issue #73581: Green Tea Garbage Collector