Go 请你喝一杯绿茶

如果 CPU profile 里 runtime.gcBgMarkWorker、对象扫描和分配路径长期霸榜,GC 才值得被请出来背锅。高频 JSON 解码、临时字符串和一地鸡毛的小对象,都会把 GC 累得够呛;锁竞争、网络调用和慢查询造成的延迟,则不是给 GC 换个名字就能治好的。

Go 1.26 默认启用 Green Tea。它没有突然变成分代 GC,也不会把对象搬来搬去;底子仍是并发、非移动的标记—清扫。变化发生在标记阶段:以前更像追着一个个对象满堆跑,现在尽量把同一段堆内存里的活攒在一起再扫。

Green Tea 省的是标记阶段的 CPU 和多核协调成本,不会改变 GC 语义,更不会把不必要的堆分配原地超度。Go 1.26 的发布说明预计,GC 占比较高的真实负载通常可减少 10%~40% 的 GC 开销;在 Intel Ice Lake、AMD Zen 4 及更新的 AMD64 CPU 上,小对象扫描还能继续吃到向量指令的红利。官方给的是区间,不是升级以后性能自动起飞的承诺书。

一、在 Green Tea 之前,Go 是怎样回收内存的

1.1 最小认知地图:从根出发找还活着的对象

GC 眼里的堆不是一排变量,而是一张由对象和指针连起来的图。全局变量、goroutine 栈上的指针等是根(root),也就是抄家的起点。从根沿指针能摸到的对象还活着;摸不到的对象才是真的没人要。

根:全局变量与栈对象 A对象 B对象 C对象 D:不可达

Go 使用追踪式(tracing)标记—清扫(mark-sweep)GC:先给存活对象做记号,再把没记号的空间交还给分配器。它不是引用计数,普通指针赋值不用每次记账;代价是隔一段时间,runtime 还是得把存活对象图走一遍。

为了不让业务 goroutine 在 GC 时集体罚站,现代 Go 使用三色抽象:白色是尚未确认存活的对象,灰色是已经发现、但内部指针还没扫的对象,黑色是已经扫完的对象。标记期内,写屏障(write barrier)会在程序改写堆指针时补几步操作,免得新引用把对象塞进 GC 已经查过的角落,最后漏标。

从根或已扫描对象发现扫描内部指针标记结束后仍为白色白色:尚未确认存活灰色:待扫描黑色:扫描完成清扫并复用空间

这套模型定下了 Go 后续十年的取舍:尽量让业务继续跑,把 Stop The World(STW,暂停全部 goroutine)压短,而不是拿一次长时间整堆停顿去换吞吐。服务端程序最怕的不是 GC 干活,而是它挑在 P99 请求头上干活。

一个 GC 周期中,业务和收集器的关系大致如下。图里的两个短 STW 只是概念上的阶段边界;实际暂停时长还受栈、调度、操作系统等因素影响。

标记期继续运行堆指针写入经过写屏障分配过快时协助标记短 STW:准备标记并发标记短 STW:标记终止并发清扫下一轮分配与节奏控制业务 goroutine

1.2 早期版本:先让 GC 精确和并行,再谈低延迟

Go 早期的 GC 也是标记—清扫,只是 STW 的分量更足。后来的演进先补齐两个地基:让 runtime 准确分清指针和普通数据,并让标记、清扫能同时使用多个 CPU。否则一个长得像地址的整数都可能被误留下来,或者一块大堆只能由一个执行流慢慢点数。

这一阶段的矛盾很直白:算法没错,暂停却会跟着堆、栈和根集合一起长胖。对批处理程序,这大概只是进度条卡一下;对 RPC 服务,它可能正好砸在某个请求的 P99(99% 的请求都不超过它的延迟)上。

1.3 Go 1.5:并发三色标记—清扫定下方向

Go 1.5 是现代 Go GC 的分水岭:运行时改为并发、三色、非移动的标记—清扫设计。大部分标记工作与业务并行执行;只在阶段切换等位置短暂停住全部 goroutine。设计重点是控制延迟,并将调优入口保持在较小范围内。

「非移动」尤其值得单独说。复制或压缩 GC 会搬动存活对象,以获得连续空闲空间;Go 则保持对象位置稳定。这样做不一定让分配最快,却避开了更新所有引用、处理内部指针和 cgo(Go 与 C 互操作)持有地址的复杂性。对于重视值语义和 C 互操作的 Go,这是一笔很实际的工程账。

并发不是白送的。标记期间业务还在改堆指针,写屏障得维持三色不变式;runtime 的节奏控制器(pacer)还要掐着表决定何时启动下一轮 GC。分配速度跑过并发标记能力时,正在申请内存的 goroutine 也得下场帮忙,这就是 GC assist。它能拦住堆无限长胖,但 GC 压力也就直接混进了请求路径的 CPU 时间。

1.4 Go 1.6~1.25:继续缩短暂停

Go 1.6、1.7、1.8 继续把与堆规模相关的工作移出 STW 阶段,尤其减少了标记结束时的栈重扫描;Go 1.8 的发布说明称,GC 暂停通常低于 100 微秒,很多情况下可低至 10 微秒。

Go 1.5 之后,主算法始终是非分代、非压缩的并发三色标记—清扫。后续版本主要调整暂停点、屏障协议和调度器边界:

版本阶段runtime 的关键变化解掉的问题留下的代价
Go 1.0~1.4从 STW 标记—清扫,走向精确栈扫描与并行标记/清扫不再把普通数据误作指针,单核收集吞吐也不再是唯一上限大量工作仍在 STW 内,堆变大就拖长暂停
Go 1.5并发三色标记、标记期写屏障、节奏控制器、非移动堆把大部分图遍历移出暂停,适合在线服务并发标记仍须扫描存活图;写屏障与 assist 都有 CPU 成本
Go 1.6~1.7逐项搬走随堆增长的 STW 工作大堆不再直接等于长暂停仍需在结束标记时处理并发变更
Go 1.8混合写屏障(hybrid write barrier)消除结束标记阶段的 STW 栈重扫暂停进入微秒量级标记期仍要承担屏障,并发扫描的内存访问仍不规则
Go 1.13~1.19更积极归还内存、异步抢占、节奏控制器与 CPU 限制、GOMEMLIMIT容器内存预算、尾延迟和空闲时 CPU 峰值更可控没有减少一次标记所需读取的堆数据
Go 1.25~1.26Green Tea 从实验变为默认降低标记扫描时的缓存未命中与多核协调成本依赖对象布局能提供可聚集的局部性

混合写屏障是这段演进的关键。早期并发标记要在结束时确认栈上没有遗漏,通常需要再次停住全部 goroutine 并重扫它们的栈。Go 1.8 的混合写屏障会保守地着色被覆盖的旧指针;当前 goroutine 的栈仍为灰色时,也会着色写入的新指针。因此运行时不再依赖最终的栈重扫来补齐标记。它以标记期间少量的写入成本,替换了一个会随 goroutine 数量和栈规模放大的 STW 阶段。Green Tea 则继续处理并发标记本身的 CPU 成本。

后续版本没有推翻这条路线,而是在调度和内存预算上补齐工程细节:更积极把闲置内存归还操作系统、更及时抢占 goroutine、改进 GC 的内部记账与调度;Go 1.19 引入 GOMEMLIMIT,即供运行时参考的软内存上限,让容器中的程序能把内存预算交给运行时处理。

Go 1.0~1.4精确扫描与并行化Go 1.5并发三色、非移动 GCGo 1.6~1.8移除随堆增长的 STW 工作Go 1.9~1.25节奏控制、抢占、内存上限Go 1.26Green Tea:页级标记调度

到这里最容易犯一个错:暂停已经很短,为什么还要折腾 GC?因为「不怎么停」和「不怎么烧 CPU」根本是两张账单。现代 Go 已把大部分标记搬到并发阶段,业务不用长时间原地罚站;CPU 却还得老老实实扫堆。Green Tea 想省的是后一笔,别拿它去硬拗 STW 已经消失。

二、Green Tea:不再急着追对象,先把一页里的活攒起来

2.1 旧标记器的瓶颈:访存不连续

传统标记器的待办队列里装的是对象。扫描对象 A 时发现它指向 B,就把 B 塞进队列;下一次扫描很可能已经跳到堆的另一头。逻辑上接力得很顺,CPU 看起来却像在满仓库找螺丝。

扫描对象 A发现远处对象 B、M扫描对象 B发现又一页中的对象 Q继续跨页跳转

从图遍历角度看,这没有问题;对 CPU 而言,连续两次扫描却可能落在相距很远的地址。逻辑上相邻的对象在内存中未必相邻,缓存容易未命中,预取器也猜不出下一跳;多个 GC 工作线程还得频繁协调这些碎成渣的任务。

Go 团队给出的数据是:GC 成本约 90% 在标记阶段;标记时间中,通常至少 35% 花在等堆内存。问题不只是某条扫描指令慢,而是访问顺序太随机,CPU 经常在等数据送上门。

2.2 任务单位从对象改为页

Green Tea 改按页安排工作,不再见一个对象就急着追一个对象。为了好懂,下文先叫它「页」;源码里真正的单位是 span,后面再拆这个词。

Go 堆本来就按固定大小的页组织。小对象按大小分类(size class,例如 16 字节对象和 32 字节对象分开存放),同一页通常只放同一类对象。Green Tea 不是看见一页就从头到尾扫一遍,它会在页尾放额外状态:

  • marks:GC 已经发现这个对象;
  • scans:GC 已经扫描过这个对象内部的指针。

当指针指向一个新对象时,runtime 设置该对象的 marks 位,再把它所在的页放进待处理队列。轮到处理这页时,只扫描 marks 已设置、scans 还没设置的对象,并尽量按内存顺序一口气做完。真正想围剿的不是每个对象,而是热路径里无休止的跨页横跳。

Green Tea:页是任务单位发现对象 1、2、4都在 A 页A 页进入队列连续扫描 A 页中的对象 1、2、4处理下一页旧标记器:对象是任务单位扫描对象 1跳到远处对象 7跳回对象 2再次跨页

它使用先进先出(FIFO)的页队列,不会刚发现一页就立刻扑上去。等一会儿,其他扫描任务可能又在同一页发现对象;页出队时,就能连续处理更多工作。之后发现新对象,同一页仍可再次入队。这是拿少量重复调度,换取更高的局部性,账算得过来就行。

2.3 从 workbuf 到 spanq:源码里究竟换了什么

从算法上看,两边都是「灰对象出队、扫描指针、把新灰对象入队」。换的不是 GC 的灵魂,而是 runtime 怎样保存待办任务、工作线程按什么顺序取活。

Go 1.26 的 src/runtime/mgcwork.go 中,旧路径的中心是 workbuf,即保存待扫描对象地址的工作缓冲区。一个 gcWork(绑定到一个 P 的 GC 工作状态)维护两个本地缓冲区;取任务时从数组尾部弹出。因此局部工作天然是后进先出(LIFO):刚扫描 A 发现 B,GC 工作线程很可能立刻扫描 B。这对图遍历很自然,却放弃了「先让同一页多来几个任务」的机会。

直接把 runtime 源码整段贴出来,只会让人开始数括号。下面留一个只保留关键动作的骨架:

go
func scanObject(obj uintptr, w *gcWork) {
    for _, slot := range pointerSlots(obj) {
        if target := *slot; markIfWhite(target) {
            w.putObj(target) // 目标对象直接成为待办任务
        }
    }
}

Green Tea 没有把 workbuf 一刀砍掉。根扫描、写屏障缓冲区,以及不能走新快路径的对象仍会生成单对象任务;它只是在 gcWork 里加了 spanq(span 队列)和 ptrBuf(暂存已读指针的缓冲区)。gcDrain 是 GC 工作线程取任务的主循环:它会依次尝试本地对象、本地 span、全局对象、当前 P 已发布的 span,最后才从其他 P 偷取 span。P 是 Go 调度器里的逻辑处理器,不等同于操作系统线程。源码注释特意点明了这个顺序:先消化单对象任务,让 span 多等一会儿,给同一 span 里的对象攒一攒。

否是否是根扫描、写屏障或对象扫描发现指针目标是否能走小对象 span 快路径?放入 workbuf:单对象任务设置 span 内 marks 位该 span 是否已被某个 worker占有?把代表对象对应的 span 放入本地spanq只累积 marks 位gcDrain 优先处理scanSpan 批量扫描 marks &^scans

这里得把名词掰清楚。Green Tea 文中的「页」在代码里更准确地说是 span。mspan 是 Go runtime 对一段连续堆内存的描述;在小对象快路径中,一个 gc.PageSize 大小的 span 才是聚集和扫描单元。叫它「页」只是为了突出局部性:它既不是操作系统虚拟内存页,也不是 G1 的 region(回收分区),别看名字像就强行认亲。

初版实现还用过全局 span 队列;提交 d7a38adf4c81 把它移除了,改成每个 P 一条本地队列,必要时再偷活。否则所有 GC 工作线程又会围着同一条队列抢,刚从对象队列省下的同步开销立刻原样奉还。

2.4 两套位图与一次状态机:为什么不会重复扫描或漏扫

Green Tea 的关键结构是 spanInlineMarkBits,它把记录 GC 状态的额外数据放到 span 尾部。它至少包含三类状态:

  • marks:对象已经被发现,相当于传统标记位;
  • scans:对象已经扫描过其指针;
  • owned:该 span 是否已经入队,以及排队后是否又来了更多标记。

对第 i 个对象,真正要扫描的集合是:

text
toScan = marks &^ scans

这里的 &^ 是 Go 的按位清除运算:保留 marks 中为 1、但 scans 中仍为 0 的位置,也就是「已经发现、还没扫描」的对象。扫描时会把 marks ∪ scans 写回 scans。绕开位图细节,记住三个不变量就够了:

marks=0, scans=0首次发现指针,设置 marksspan 出队,计算 marks &^scans再次发现同一对象此转移不存在白色已发现未扫描已扫描

最后一条不是说对象以后不能再被引用,而是说内部指针已经扫过,就不用重复扫。标记期里新变得可达的对象,仍会通过写屏障或对象扫描设置自己的 marks 位;结束标记前,runtime 会确认本地 workbuf 和 spanq 都排空。少扫和漏扫只差一个字,后者可不是优化。

owned 是用原子操作维护的占有标记,防止同一 span 被反复塞进队列;它不是一把长时间攥着的大锁。tryAcquire 会把状态从 unowned 推进到「一个新增 mark」或「可能有多个新增 mark」;scanSpan 释放它后,再按排队期间的新标记数决定扫一个还是批量扫。这条分支就是 Green Tea 在对象稀疏时不给自己加戏的关键。

2.5 scanSpan:按对象密度选择扫描路径

scanSpan 先根据 marks &^ scans 得到本轮对象掩码,再按密度选择路径:

  1. 只有一个新对象时,直接扫描这个代表对象;
  2. 对象密度不足时,使用局部的稀疏扫描,仍只在当前 span 内处理对象;
  3. 对象密度足够且平台支持时,调用 internal/runtime/gc/scan.ScanSpanPacked,按照该大小分类的规则布局批量提取指针。

Go 1.26 的 scanSpan 只有在扫描内核可用、且新标记对象达到 span 可用槽位约 1/8 时,才走批量路径。这个阈值只服务性能,不服务正确性:对象稀得像凌晨的工位时,构造和遍历整块位图掩码,可能比扫几个对象更贵。即便走稀疏路径,Green Tea 仍能少绕几次跨页弯路。

01稀疏足够密且支持内核span 出队计算 toScan = marks &^scans新增对象数结束代表对象快路径局部稀疏扫描与预取ScanSpanPacked发现的指针再入队

这也解释了 SIMD(单指令多数据,可一次处理多组数据)为什么是 Green Tea 的后续收益,而不是前提。旧对象队列交给 SIMD 的是一串地址不可预测、类型和布局乱七八糟的小任务;span 内同一大小分类对象的布局重复又连续,指针位图才有机会批量处理。相关内核最初由提交 5d040df09271 接入 scanSpan。不过别把「新 CPU 再快 10%」理解成所有机器、所有对象一视同仁;是否走向量路径,还要看 CPU 特性、对象大小、指针布局和扫描密度。

2.6 额外状态和坏布局带来的边界

Green Tea 新增了双位图、span 占有状态、队列,以及兼容旧路径的分支。小对象 span 的尾部通常塞得下这些状态,连续扫描也能摊薄读状态和排队的成本;但对象分布很稀疏时,新机制本身也得吃饭,不能假装没有开销。

go#73581 的数据很能说明边界:GC 密集微基准里,GC CPU 可下降 10%~50%,一级/二级缓存未命中约减半;但低扇出、频繁旋转、节点又散落在大堆里的树,可能让一半 span 扫描只捞到一个对象,整体接近持平,局部甚至倒退。Green Tea 能放大已有局部性,不能凭空把已经打散的对象图收拾整齐。

Green Tea 上线前后的提交也反映了这些工程约束:

  • 1b40dbce1ab0 建立整 span 标记与扫描的主路径;
  • f8ccda2e0574 修复初始化额外状态时,隐式空指针检查触发的页映射与 TLB(地址转换缓存)抖动;
  • d7a38adf4c81 去掉全局 span 队列,避免多核下的新热点;
  • 5d040df09271 把规则的 span 扫描接到扫描内核。

难点从来不是写出 marks &^ scans,而是处理完页映射、缓存层级和多核同步,仍让新路径比旧路径便宜。runtime 优化最怕拿一个热点换另一个热点,最后只剩 benchmark 图比较热闹。

2.7 Green Tea 到底提升了什么

Green Tea 没有减少语义上必须扫描的存活对象,也没有改变标记—清扫的最终结果。它只是在做同一份苦力时,少交一点硬件税。

维度旧对象图遍历Green Tea
待处理单位单个对象堆页
访问模式沿指针频繁跨页跳转页内聚集后连续扫描
缓存与预取不规则、难预测局部性更好,可积累更多有效工作
多核协调细粒度对象任务较多页任务更粗,扩展性更好
GC 语义并发、非移动、三色标记—清扫保持不变

更连续的扫描,大致有三层收益。

第一层是缓存局部性。同一页的对象和状态数据更可能已经在 CPU 缓存中,一次取回的数据可以服务多个对象扫描。第二层是内存级并行度:连续处理多个对象时,CPU 更有机会预取或重叠多个访存请求,不必每读一个指针就原地等待下一个地址。第三层是多核扩展性。工作单位变粗后,GC 工作线程更少围绕对象队列争用。

Go 1.26 还会在新一些的 amd64 CPU 上,对适合的小对象扫描使用向量指令。这里的先后关系很重要:不是「有 SIMD 就会快」,而是 Green Tea 先把扫描变得足够连续和规则,SIMD 才有可吃的工作。

2.8 适用负载与非目标

Green Tea 不是分代 GC,不会只回收「新生代」;不是压缩 GC,不会整理碎片;也不改变逃逸分析的结果。

因此下面几类程序的感受会不同:

  • 堆上小对象多、指针密、GC 已经占据明显 CPU 的服务,通常更值得期待;
  • GC 本来只占极小比例的程序,端到端性能大概率没有戏剧性变化;
  • 一页中待扫描对象极少、对象分布很稀疏的负载,局部性优势也会被削弱。运行时对只含一个待扫描对象的页有专门处理,但无法消除所有回归可能。

所以官方只敢给区间,不敢发性能承诺书。升级后若发现性能或行为有问题,Go 1.26 仍可在构建时用 GOEXPERIMENT=nogreenteagc 暂时关闭;该退出开关预计会在 Go 1.27 移除。它适合拿来排查和反馈,不适合变成祖传环境变量。

三、放进更大的 GC 世界:Go 为什么没有照搬别人的方案

Green Tea 的「页」和 Java G1 的 region 看上去有点像,职责却完全不同。把几类常见 GC 拉到一起比一比,Go 的取舍会清楚得多,也能少掉不少「Go 为什么不照搬 X」的灵魂拷问。

3.1 分代 GC:少扫一点,代价是持续维护跨代关系

分代 GC 建立在「多数对象朝生暮死」的经验上。对象先进入新生代;新生代满时做一次新生代回收(Minor GC),只扫描这个较小区域;活得够久的对象晋升到老年代,老年代再较少地做更大范围的收集。

新生代满死亡存活多次空间紧张老对象指向新对象为新生代回收提供入口新对象新生代新生代回收释放老年代大范围 / 全堆回收跨代引用记录

Java 和 .NET 都广泛采用这条路线。Java 文档指出,大多数对象会分配在新生代并在其中死亡,因此新生代收集的成本通常很低;老年代收集涉及更多存活对象,通常也更久。

它的收益是每次收集少看一片对象;代价是持续维护跨代引用。老对象一旦指向新对象,runtime 必须记住这件事,不然 Minor GC 根本不知道从哪里找新生代里的活对象。常见做法是 card table(把堆按小块编号,记下哪些块发生过指针写入)或 remembered set(记录指向某个区域的外部引用位置),并靠常驻写屏障不断更新它们。

Go 曾认真评估过分代设计,但逃逸分析和值语义会让不少短命值直接留在栈上,函数一返回就自然消失。对这部分对象,分代 GC 根本插不上手;反过来,常驻写屏障会让每次普通指针写入长期缴税,所以 Go 团队没有把分代 GC 当成默认方向。

Green Tea 的回答不同:它不想让收集器「少看几代」,而是让现有全堆并发标记「看得更顺」。

分代收集的细节比「新生代快、老年代慢」多得多。一次 Minor GC 不能只从新生代根开始,因为老年代对象也可能指向新对象;因此运行时需要记住从老年代指向新生代的引用。常见的后写屏障(post-write barrier)会在赋值后把对应 card 标为脏;Minor GC 再扫描这些 card,而不是重扫整个老年代。

新生代回收新生代跨代引用记录老年代对象应用线程新生代回收新生代跨代引用记录老年代对象应用线程新生代空间耗尽写入指向新对象的字段写屏障记录跨代引用读取发生过写入的记录只扫描可能指向新生代的位置复制存活对象或晋升

它和 Go 的成本模型,差的当然不只是「有没有年龄」。分代运行时拿一部分全堆扫描成本,换成长期维护跨代引用的写入成本;很多实现还把新生代做成复制空间,死亡对象占多数时,挪一下分配指针(bump pointer)就能继续分配。Go 的 GOGC 则用堆增长目标在 CPU 和内存之间做取舍:愿意多占些内存,就能少跑几轮全堆 GC;写屏障主要只在并发标记期启用。两边各有适合的负载,别看一次 Minor GC 跑得快就急着判高下。

3.2 复制和压缩 GC:解决碎片,代价是搬家

复制式 GC 会把存活对象复制到新的空间;压缩式 GC 则把存活对象向一端搬移。空闲空间因此连续,分配可以简单地移动一个指针,碎片也不容易累积。

这条路线很适合对象引用完全由运行时掌控的环境,但对象一搬,所有引用都得更新,或者经由转发指针间接访问。和原生代码互操作时还得考虑固定对象(pinning):C 代码已经攥住一个地址,对象就不能说搬家就搬家。

Go 的非移动设计正是为了绕开这些边界。Green Tea 继承了这条路:它优化扫描路径,不给对象搬家。因此拿不到压缩 GC 的连续空闲空间,却保住了地址稳定性和 cgo 的工程友好性。

还要区分「复制」和「压缩」的暂停形状。复制新生代时,收集器主要处理存活对象;压缩整个老年代时,还要计算新位置、移动对象并修复引用。若把这些动作放在 STW 中,暂停可能随存活集增长。后面 G1、ZGC、Shenandoah 的复杂性,本质上都在回答同一个问题:怎样既移动对象消除碎片,又不让这份引用修复工作一次性挡住应用。

3.3 G1 一类区域化 GC:按回收收益排优先级

Java G1 也把堆切成 region(固定大小的分区),但 region 是分配、迁移和回收的单位。它在并发标记后评估老年代各分区的垃圾比例,再把值得回收的分区加入回收集合(collection set),在停顿期间把其中的存活对象搬到其他分区。为了能只收一部分分区,G1 需要维护跨分区引用的 remembered set。

Green Tea 的页没有承担这些职责:

问题Java G1Go Green Tea
区域单位的用途回收、迁移、暂停预算标记任务调度与局部扫描
是否移动存活对象是,疏散(evacuation)否
是否分代是否
长期状态维护成本跨分区引用集span 内 marks / scans 状态

因此,Green Tea 不是「Go 版 G1」;它借助 Go 既有的堆页组织,解决并发标记器的局部性问题。

G1 的另一个关键差异是并发标记的正确性协议。它使用 Snapshot-At-The-Beginning(SATB,标记开始时的逻辑快照):并发标记开始时,把当时的存活对象图视作快照。若应用随后覆盖一个旧引用,前写屏障(pre-write barrier)会把被覆盖前的旧值记录下来,让 GC 仍能完成这张快照。这样会保守地多保留一些已经死亡的对象,但能缩短重新标记(remark)阶段需要追赶的变更量。

并发标记开始SATB 逻辑快照应用覆盖一个旧引用pre-write barrier 记录旧引用并发标记继续remark:完成快照挑选垃圾多的分区暂停中搬迁存活对象,修复引用

相比之下,Green Tea 沿用 Go 已有的并发三色与写屏障协议,改变的是「灰色工作怎样被安排和扫描」。它没有 SATB 保存旧引用的队列,也不维护按分区回收所需的跨区引用集,更不会在本轮中搬迁对象。少掉这些机制,也意味着能力边界更窄:Green Tea 不会像 G1 那样主动选择垃圾比例最高的老年代分区回收,更不能通过搬迁修复碎片;换来的则是运行时路径和 cgo 边界更简单。

3.4 低延迟并发压缩 GC:把暂停再压低,但运行时更重

ZGC、Shenandoah 这类低延迟收集器,目标是让标记、转移和对象重定位尽可能与业务并发执行。它们适合超大堆和极端延迟目标,但通常需要读屏障(每次读取引用时检查并修正地址)、转发机制、更多状态数据和更复杂的运行时配合。

这是一种很合理的交换:用更高的实现复杂度、运行时开销和内存余量,换取非常小的停顿。Green Tea 没有冲进这片战场。Go 已经通过并发标记和短 STW 获得较低延迟,Green Tea 选择继续降低「并发部分」的 CPU 成本。

这里还有一条经常被混淆的边界:Green Tea 使用的 marks / scans 位图是 GC 的内部状态,不是 ZGC 的「带颜色指针」。后者把对象状态编码进引用表示,并在每次加载引用时通过读屏障校正转发地址,因而可以让对象在应用运行时迁移;Green Tea 不改程序持有的对象地址,只在 GC 工作线程扫描时改变任务粒度。前者优化的是标记器的访问顺序,后者解决的是并发重定位时地址可见性的问题,复杂度完全不同。

3.5 .NET:同样分代,但后台收集的暂停边界不同

.NET 也是分代 GC,但它把高代收集放到后台执行:后台 GC 处理第 2 代期间,第 0、1 代仍可在需要时进行前台收集;这些前台年轻代收集会暂停托管线程。Server GC 会为逻辑处理器使用多个专用 GC 线程,目标是吞吐和扩展性;Workstation GC 更偏客户端响应与较少资源占用。

别把它缩成「.NET 并发,Go 也并发,所以差不多」。两者并发的层次不同:.NET 分代让年轻对象多数只经历小范围前台暂停,后台工作集中在第 2 代;Go 以非分代的全堆标记为主,再靠节奏控制器和分配协助把进度摊进并发周期。Green Tea 优化的是后者每个扫描周期的访存方式,并没有给 Go 凭空加出「只收第 0 代」这种操作。

3.6 引用计数:及时释放,但每一次写入都要付费

引用计数在对象不再被引用时能立即释放内存,也不必周期性从根遍历整张对象图。但每次引用赋值可能都要更新计数,多线程时还会有原子操作成本;对象成环时,需要额外循环检测。

这和 Go 的选择正相反。Go 宁愿在 GC 周期里集中追踪,也不想让每一次普通指针写入都背上常驻成本。Green Tea 仍站在这条路上,只是让集中追踪时的内存访问别那么东一榔头西一棒子。

四、升级以后,应该怎么判断它是否真的帮了忙

升级到 Go 1.26 后,别一上来就调 GOGC、压 GOMEMLIMIT,然后宣布又完成一轮玄学调优。先在接近生产的负载下测量,再决定要不要改参数;不合适的内存目标只会让 runtime 更频繁地跑 GC。

可以从最简单的 GC 跟踪输出开始:

bash
GODEBUG=gctrace=1 ./your-program

再把 CPU、allocs profile 和 runtime/metrics 摆在一起,看下面几件事:

  • GC CPU 占比是否下降,而不是只盯一次 STW 暂停;
  • 吞吐、P99 / P999 延迟(99% / 99.9% 请求不超过的延迟)是否真的改善;
  • 每秒分配字节数和 /gc/scan/heap:bytes 是否仍异常高;
  • 是否有逃逸、map[string]any、临时字符串、无边界缓存或对象生命周期过长等上游原因。

若业务主要耗在 JSON 解码、锁竞争或数据库上,Green Tea 不会替你解决那些问题。反过来,一次请求撒出成片小对象,也不该拿 Green Tea 当继续滥分配的许可证。它只是让「不可避免的标记」便宜一些;让对象根本别落到堆上,通常才是更大的胜利。

Go GC 的路线一直很一致:先从长暂停里逃出来,再让并发工作少打扰业务,最后让这些工作更懂现代硬件。Green Tea 的价值不在于多了个花哨名字,而在于它承认了一个朴素事实:CPU 越来越快,内存却没按同样速度跟上;GC 想继续进步,就不能只会沿着指针满堆乱跑。

作者Aoang
发布于2026-08-31
更新于2026-08-31
许可协议CC BY-NC-SA 4.0
转载或引用本文时请遵守许可协议,并注明出处。