04 GC:从 STW 到三色标记再到混合写屏障,低延迟的代价

开篇提问

先问你一个关于 Go 设计哲学的问题:

Go 的 GC 有个非常著名的目标——把单次停顿(STW,Stop The World)压到亚毫秒甚至更低。但你知道吗,Go 为了这个目标,主动放弃了几乎所有主流 GC 都在用的一个东西。这个东西是什么?

提示一下:Java 的 G1、ZGC,.NET 的 GC,V8 的 GC,全都在用;唯独 Go 不用。

先想三十秒。这一章的答案,会颠覆你对”GC 优化”的很多默认认知。


子主题一:Go 放弃了什么——分代

答案是:分代回收(generational collection)。

分代假设(generational hypothesis)是 GC 领域最著名的经验法则:绝大多数对象都是”朝生暮死”的——刚刚分配的对象,很快就会被丢弃;能活过一段时间的,往往能活很久。

基于这个假设,主流 GC 把堆分成”新生代”和”老年代”:新生代频繁、快速地回收(因为大部分垃圾都在这),老年代偶尔才回收。这样能把 GC 精力集中在”垃圾最多的地方”,效率极高。

Java 的分代 GC、V8 的 Scavenger,都是这么干的。但 Go 不做分代。为什么?

Go 团队的官方回答,浓缩下来是几条:

第一,Go 已经用别的手段拿到了分代的大部分收益。 还记得上一章讲的逃逸分析吗?逃逸分析在编译期就把海量短命对象留在了栈上——栈上对象函数返回即清零,根本不进堆、不参与 GC。这等于”提前把新生代的问题解决在堆之外”。分代回收要解决的”海量朝生暮死对象”,Go 靠逃逸分析已经挡掉了大半。既然短命对象都不进堆,分代的价值就大打折扣。

第二,分代有代价:写屏障更重。 分代回收需要**写屏障(write barrier)**来记录”老年代对象指向新生代对象”的跨代引用,否则回收新生代时会把被老年代引用的活对象误回收。Go 现在已经有写屏障(后面讲),但分代要求的那套屏障更重,会拖慢所有写操作。

第三,分代引入的复杂度,和 Go 的”简单”哲学冲突。 分代意味着多套回收算法、多套内存布局、更复杂的调参。Go 团队宁可要一个”够好、简单、可预测”的单一算法,也不要一个”更高效但复杂得多”的分代系统。

这是一个非常值得你记住的取舍:Go 的 GC 不是在”堆里”解决短命对象问题,而是在”堆外”(栈上,靠逃逸分析)就把它解决了。 这是 Go 内存体系里,逃逸分析、连续栈、分配器、GC 四者咬合成一个整体的最好例子——你不能单独理解 GC,必须把它放进整个体系里看。


子主题二:GC 的三个经典问题

要理解 Go GC 的演进,先要建立 GC 的三个核心问题:

问题一:怎么找到垃圾(标记什么)。 GC 从一组**根对象(roots)**出发——全局变量、栈上的变量、寄存器——顺着指针可达的图去遍历。能到达的,是”活”的;到不了的,是垃圾,回收。这套”根可达性”是几乎所有追踪式 GC 的共同基础。

问题二:什么时候标记(要不要 STW)。 遍历对象图的过程中,如果程序还在同时改指针,GC 就会”看到一半”——某个对象刚被 GC 判断成垃圾,下一秒程序又通过新指针指向它。为了避免这种”标记和修改赛跑”的竞态,最简单的办法是停掉所有 goroutine(STW),专心标记。但 STW 就是卡顿的来源。

问题三:标记完之后怎么办(怎么清理)。 三色标记里的”白色”对象(不可达)要回收。回收有几种方式:直接清扫、复制整理、原地回收……

Go 的 GC 演进史,就是在这三个问题上不断做选择、再推翻重选的历史。


子主题三:演进——从 STW 到并发三色标记

Go 1.3 之前:简单的 STW 标记-清扫。 全程 STW,停顿随堆大小线性增长。堆一大,卡顿几百毫秒甚至秒级。对服务端应用是灾难。

Go 1.5:并发三色标记(concurrent tri-color marking)。 这是 Go GC 历史上最大的一次飞跃。核心是把”标记”这个最耗时的阶段,放到后台和程序并发执行,只有极短的时间需要 STW。它是怎么做到”边跑边标记”的?靠两个关键设计:

一是三色抽象。 把对象分成三种颜色:

  • 白色:还没被扫描到(初始所有对象都是白)。
  • 灰色:已被发现、但它的引用还没被扫描完。
  • 黑色:已被扫描完,它指向的对象也都被发现了。

标记的过程是”把灰色逐个变黑”,当没有灰色对象时,剩下的白色就是垃圾。

二是写屏障。 并发标记最大的风险是:程序在 GC 扫描的同时改指针,可能把”黑色对象刚指向的白色对象”漏掉——这个白对象会被当成垃圾误回收。写屏障就是在这时插一道检查:当程序写指针时,如果满足某些条件(比如黑色对象写入了一个白色对象的引用),就把那个白对象”救”回灰色,保证不漏。

这就是著名的 Dijkstra 写屏障和后来 Go 采用的混合写屏障(hybrid write barrier,Go 1.8 引入)。混合写屏障把”插入屏障”和”删除屏障”结合起来,用很小的代价换来了正确的并发标记。

Go 1.8:混合写屏障。 在这之前,并发标记的正确性依赖”栈需要短暂 STW 重新扫描”等开销。混合写屏障大幅削减了栈重扫的成本,让 STW 时间进一步下降。

Go 1.9 之后:持续打磨。 优化清扫、优化大对象处理、优化 STW 的边界。到近年的版本,Go 的 GC 停顿目标稳定在亚毫秒量级(官方说”数百微秒”,但实际场景中,大堆、大量 goroutine 的停顿仍可能到毫秒级,别把宣传数字当承诺)。


子主题四:三色标记的细节与”漏标”的灾难

这里必须给你讲透一个 GC 领域最经典、也最容易栽跟头的概念:漏标(missing mark)。

并发标记的正确性,不是”别把垃圾误回收”这么简单,而是要满足一个精确的条件——三色不变量。最常见的是”强三色不变量”:

黑色对象不能指向白色对象。

只要这个不变量成立,标记结束时,白色对象一定都是垃圾,回收它们就安全。

写屏障的全部意义,就是在程序违反这个不变量的瞬间,把它修回来。我们来模拟一个会漏标的场景(不用写屏障的话):

  1. 黑色对象 A 指向白色对象 B;
  2. 程序把 A 指向 B 的指针改掉,改成指向白色对象 C;
  3. 同时,程序又让另一个黑色对象 D 指向了 B。

现在 B 被 D 引用,是活对象。但 GC 的视角里:A 已经扫完了(黑色),它不会再扫 A 的指针;D 也已经扫完了(黑色),不会发现”D 新指向了 B”。于是 B 在 GC 眼里一直是白色 → 被当成垃圾回收 → 程序后续通过 D 访问 B,就是访问已回收内存,直接崩溃或数据错乱。

写屏障就是要在第 2、3 步这种”黑色对象写指针”的瞬间,把被指向的白色对象(C 或 B)标成灰色,让它重新进入扫描队列。这样 B 就不会漏。

这解释了为什么写屏障是”所有写操作”都要付出的一点代价——它拖慢了 Go 的指针写入。这个代价,就是 Go 换”低 STW”付的账。


子主题五:GC 的代价与调优

理解了机制,你就能理解 Go 性能调优里几个著名现象:

第一,GC 的目标是”停顿”不是”吞吐”。 Go 的 GC 为了亚毫秒停顿,牺牲了吞吐(写屏障拖慢写、并发标记占用 CPU)。所以对”吞吐极致、能容忍长停顿”的场景(比如纯计算批处理),Go 的 GC 不是最优解;但对”延迟敏感、请求并发”的服务端,这个取舍是对的。理解一个 GC 前,先问它优化的目标是什么。

第二,GC 百分比(GOGC)。 GOGC 是 Go GC 的经典调参旋钮,默认 100:当堆大小比上次 GC 后增长了一倍时,触发下一次 GC。 调大 GOGC(比如 400),GC 更不频繁,吞吐更高但堆占用更大、单次停顿更长;调小 GOGC,GC 更频繁,堆占用小但总 CPU 花在 GC 上的更多。它本质是”用内存换 GC 频率”的旋钮。

第三,GOMEMLIMIT(Go 1.19)。 现代版本引入的另一个旋钮:设一个内存软上限,让 Go 在逼近上限时更积极地 GC,避免容器 OOM。在容器化环境里,它比单纯调 GOGC 更贴合”我只有 2GB 内存”的真实约束。

第四,“GC 压力”是真实可观测的。 频繁分配会推高 GC 频率,进而推高 CPU 占用和延迟。所以优化 Go 性能的两大抓手——减少分配(靠逃逸分析、对象复用、池化)和减少存活对象——本质都是在给 GC 减负。上一章讲的逃逸分析,和这一章的 GC,在这里完全贯通。


子主题六:三色标记的完整走线——把一次标记从头到尾走一遍

上一节我们说了”三色”这个抽象,但抽象是抽象,你得能在脑子里真的跑一遍,否则后面所有关于屏障的讨论都是空中楼阁。

我们先把三色的操作语义钉死。注意这三句话的精确措辞,很多人就是因为把它们记混了,才会觉得写屏障”玄”:

  • 白色 = 未被发现的对象(初始状态,全部对象都是白)。
  • 灰色 = 已被发现,但它内部的引用还没扫完——等价于”它正躺在 GC 的工作队列里”。
  • 黑色 = 已被发现,且它内部的引用全部扫完——等价于”它已经出队了,不会再被扫第二次”。

看清楚:灰不是一种”颜色状态”,灰就是”在队列里”这个事实的可视化。 黑也不是”活”,黑是”扫完了”。整个标记过程,其实就是一句话:

反复从灰色队列取出一个对象,把它引用的所有白色对象涂灰(入队),然后把自己涂黑(出队)。直到队列空。

队列空的那一刻,剩下的白色就是垃圾。灰色集合的那条边界,就是标记推进的”波前”。

来,走一个具体的例子。假设堆里有四个对象,还有一个全局变量(根):

type Node struct {
    name string
    next *Node
}
 
var root *Node // 全局变量:根对象之一
 
func build() {
    a := &Node{name: "a"}
    b := &Node{name: "b"}
    c := &Node{name: "c"}
    d := &Node{name: "d"} // 没人引用它
    a.next = b
    b.next = c
    root = a
}

第 0 步(初始): a、b、c、d 全是白色,灰色队列为空。

第 1 步(扫根): GC 扫全局变量,发现 root 指向 a。于是 a:白 → 灰,入队。顺带一提,root 自己是个指针变量,不是堆对象,它存在于数据段,GC 是把它当作”根”来读的,不给它上色。

第 2 步(出队 a): 取出 a,扫它的字段。注意 name 是 string,string 内部有个指向字节数组的指针,那字节数组也是个堆对象,也要白 → 灰入队(这是个容易忘的细节:扫一个对象,扫的是它所有含指针的字段,展开来可能牵出一串隐藏对象)。然后 next 指向 b,b:白 → 灰,入队。a 的字段扫完了,a:灰 → 黑。

第 3 步(出队 b): 扫 b。next 指向 c,c:白 → 灰入队。b:灰 → 黑。

第 4 步(出队 c): 扫 c。next 是 nil,跳过。c:灰 → 黑。(顺带把刚才那个字符串字节数组也出队涂黑。)

第 5 步(终止): 灰色队列空了。黑色集合 = {a, b, c, 字符串字节数组},白色集合 = {d}。d 被回收。

就这么简单。整个过程没有任何魔法,就是个带队列的图 BFS/DFS。三色抽象的价值,不在于让遍历变快,而在于让”遍历到一半”这个状态变得可以描述、可以推理——而”遍历到一半”恰恰就是并发标记的全部难点所在。

一个狠类比:把堆想成一片地形,标记想成一场正在上涨的洪水。黑是已经被水淹没的地方,灰是水线,白是水还没漫到的地方。 单线程标记,等于先把整个程序冻住,让洪水一次涨到位。并发标记,等于水在涨,而地面上的居民还在疯狂地架桥、拆桥、搬家(mutator 改指针)。你架桥的速度要是比水涨得快,就会有地方明明连着陆地,水却永远漫不过去——那地方就会被误判成”孤岛”给推平。这就是漏标。

再补两个容易被忽略但很重要的实现细节:

细节一:标记阶段新分配的对象,直接标黑。 Go 在并发标记期间分配的新对象,一出生就是黑色。为什么?因为它是”新生的”,程序只能通过刚刚写入的指针到达它,而那个写入会被写屏障捕获(屏障会把它涂灰)。既然屏障已经兜底了,直接标黑反而更安全——可以避免”新对象在标记结束时还没被扫到、却因为分配路径的时序诡异而漏掉”的边角情况。代价是这些对象会无条件活过本轮 GC,产生一点浮动垃圾。这是一次典型的”宁可保守保活,不可冒险误回收”的取舍:GC 的容错方向永远是不对称的。

细节二:标记是”精确”的,靠编译期生成的元数据。 GC 扫一个对象,必须知道”这个对象的哪些字节是指针、哪些是整数”。要是不知道,把一个 int64 当成指针去解引用,轻则假保活(内存泄漏,永远回收不掉),重则非法访问直接崩。所以 Go 编译器为每个类型生成指针位图(pointer bitmap / GC data),运行时扫对象时按位图来扫。这个元数据是编译期成本,换来了运行期的精确 GC。又一次”把成本往编译期推”。


子主题七:两种写屏障的对决——Dijkstra 的”进门登记” vs Yuasa 的”搬走留底”

现在进入这一章最硬的部分。

我们前面说过,并发标记的漏标,根因是这两步同时发生:

  1. 一个已扫完(黑色)的对象 A,把指向 B 的指针改掉;
  2. 另一个已扫完(黑色)的对象 D,新指向了 B。

结果 B 明明活着,却永远是白色。那么:屏障到底该拦住”新值的写入”,还是拦住”旧值的删除”?

这两条路,就是 GC 史上最著名的两种屏障。

Dijkstra 插入屏障:管住”新来的”

思路极其朴素:黑色对象不能指向白色对象(强三色不变量),那我就在写指针的瞬间,把新值涂灰,白变灰了,不变量就守住了。

writePointer(slot, ptr):
    shade(ptr)    // 把新值涂灰:别管白色还是什么色,涂灰就对了
    *slot = ptr

对应 Go 源码里的 greyobject,效果是把对象标灰并塞进工作队列。

类比:小区门禁,进门先登记。 不管你是谁、从哪来,只要你要进这个地址,先登记成”待查”,保安(GC)迟早会去查你。

它的好处: 逻辑简单、绝对安全,满足的是最强的那个不变量,推理起来几乎不会出错。Go 早期的并发 GC 用的就是它。

它的代价有两个,第二个尤其致命:

代价一:浮动垃圾。 假设程序先让 A 指向 B(屏障把 B 涂灰保住了),下一行就把它改成 nil。B 其实已经死了,但它这一轮被救过一次,就得活到下一轮。插入屏障对”标记期间临时存活”的对象非常宽容,堆越大、写越频繁,浮动垃圾越多。

代价二(真正的痛点):栈怎么办?

栈是根,栈上的写是最频繁的写——函数调用传参、局部变量赋值,全在栈上。如果给栈上的每一次写都装屏障,mutator 的开销会炸。可要是不装,那”黑色栈上新写入一个白色对象”这个漏标路径就堵不上。

Go 早期的答案简单粗暴:栈的写不装屏障,标记结束时 STW,把所有 goroutine 的栈重新扫一遍。 反正 STW 了,随便扫,扫完所有栈,不变量自然就恢复了。

问题就在这:这个 STW 的时长,正比于”所有 goroutine 的栈总大小 + 栈里指针的总数”。 你的服务要是有几万个 goroutine,每个栈几十 KB,那么”把所有栈重扫一遍”这件事,就是毫秒级的停顿。堆再大也不怕,goroutine 一多就完蛋。这个瓶颈,才是倒逼 Go 换屏障的真正原因。

Yuasa 删除屏障:管住”走掉的”

换个思路。我不拦新值,我拦旧值:你要覆盖一个指针槽位之前,先把槽位里原来的那个对象涂灰。

writePointer(slot, ptr):
    shade(*slot)  // 把旧值涂灰:它曾经被引用过,我先保住它
    *slot = ptr

类比:搬家前先给旧房子拍照留底。 不管你搬去哪,旧房子我先记录在案,不会被当成无主房产拆掉。

这个屏障守的不变量要弱一档,叫弱三色不变量:

允许黑色对象指向白色对象,但这个白色对象必须能从某个灰色对象出发,经由一条全白路径到达。

听起来绕,其实意思就是:你可以暂时被一个黑对象指着,但必须还有另一条”正在推进的灰色线索”能找到你,不能被彻底遗忘。

它的杀手锏特性叫 SATB(Snapshot At The Beginning,起始快照):

删除屏障保证的是——在标记开始那一刻,从根可达的每一个对象,本轮一定会被标记到,无论你之后怎么改指针。

为什么?想象任意一个在标记开始时活着的对象 X。它活着,意味着存在一条从某个根出发、经过若干对象、到达 X 的指针路径 root → O1 → O2 → ... → X。现在假设程序把 O2 → O3 这条边改掉了。删除屏障会在改的瞬间把 O3 涂灰。于是 X 的线索虽然断了,但 O3 被救住了,从 O3 出发还能继续走到 X。路径上的每一条边被删除时,被删的那个后继都会被保住——所以这条链永远断不了。

这就是 SATB。它的名字很贴切:它保护的是”开始那一刻的快照”,而不是”结束那一刻的真相”。 所以它的缺点是明摆着的:标记期间死掉的对象,一个都回收不掉,全是浮动垃圾。

但它的优点更致命——栈不需要重扫了。

这才是关键。你想:栈是根,栈在标记开始时被扫一遍,栈上所有引用指向的对象及其后代,全都被 SATB 钉住了。之后程序在栈上怎么改指针都无所谓:

  • 改掉旧引用?旧目标被 shade(*slot) 保住了。
  • 写入新引用?新值能是哪来的?只能是三种:常量(nil 或静态数据,无关)、已经存在的某个变量或字段的值(那个对象一定已经在被标记的范围内,因为持有它的地方要么已扫过——扫过就意味着它指向的东西已涂灰;要么还没扫)——或者刚分配的新对象(新对象一出生就是黑色)。

没有第四种可能。 一个值不能凭空出现在程序里,它必须从某个地方”流动”过来,而流动路径上的每一环都已经被标记的进度覆盖或保住了。所以:栈扫一次,永久有效。

混合写屏障:两个都要,但第二个大多数时候免费

Go 的做法是把两者焊在一起。源码注释里给出的伪代码是这样的(等价表述):

writePointer(slot, ptr):
    shade(*slot)            // Yuasa 删除屏障:无条件,保住旧值
    if current stack is grey:
        shade(ptr)          // Dijkstra 插入屏障:只在这个 goroutine 的栈还没扫完时才做
    *slot = ptr

拆开看:

第一半(无条件 shade(*slot)) 是主力。它提供了 SATB 性质,从而消灭了”标记结束时 STW 重扫所有栈”这个大头。这一半是有成本的——每次写指针都要先看一眼旧值、必要时涂灰。

第二半(条件 shade(ptr)) 是补丁,而且绝大多数时候不执行。条件是”当前 goroutine 的栈是灰色的”,也就是这个栈还没扫完。为什么要这个条件?因为一旦栈扫完了(变黑),新写入的值就一定来自已被标记的范围(上面那段论证),再涂一次灰纯属浪费。而栈还没扫完的时候,栈上的新值可能来自尚未覆盖到的路径,这时才需要兜底。

于是 Go 1.8 之后的栈重扫被削减成什么了? 剩下的是”扫到一半被抢占、没扫完的栈”,以及”在标记期间被恢复调度、需要重新标记其栈的 goroutine”。从”重扫全部栈”变成了”处理极少数未完成的栈”。 停顿自然就从毫秒级掉下来了。

这一节的三句总结

  1. 插入屏障保”未来”,删除屏障保”过去”。 一个拦新值、满足强不变量、但要重扫栈;一个拦旧值、满足弱不变量、浮动垃圾多但栈免重扫。
  2. 混合屏障的本质是:用”删除屏障的 SATB”换掉栈重扫,用”带条件的插入屏障”堵住残余漏洞。 有条件这一笔,是让它不至于比纯删除屏障贵太多的关键。
  3. 代价从哪搬到哪了? Go 1.8 之前:栈写免费,代价集中在 STW;Go 1.8 之后:栈写也要过屏障,代价摊进了 mutator 的每一次写。这已经是本章第三次看到同一个套路了——Go 的一切优化,都是把”停顿”换成”吞吐”。把这句话记住,你就能预测 Go GC 未来还会往哪走。

子主题八:一个 GC 周期的完整阶段——哪里 STW,哪里并发

现在把镜头拉远,看整个周期。Go 的一个 GC 周期分四个阶段,我把 STW 的位置标出来:

阶段是否 STW具体做什么
Sweep Termination(清扫终止)STW停世界,等所有 P 到达 GC 安全点;清扫上一轮遗留的、还没扫完的 span(只有本轮被提前触发时才有);为标记做准备
Mark(标记)开头极短 STW + 主体并发短暂的 STW 里把 gcphase 切到 _GCmark、打开写屏障、打开辅助标记、把根标记任务入队。然后恢复世界,标记并发进行
Mark Termination(标记终止)STW停世界;关闭 mark worker 和辅助标记;把每个 P 的 mcache 刷回 mcentral;处理未完成的栈扫描;计算下一轮的触发阈值
Sweep(清扫)并发恢复世界,关闭写屏障;新分配的对象重新变成白色;清扫是”按需”的——分配时用到哪个 span 就先扫哪个,后台也有 sweeper 慢慢扫

几个值得停下来想的点:

为什么”打开写屏障”必须 STW?

因为屏障是 per-P 的状态。只要还有一个 P 没打开屏障,它上面跑的 goroutine 就在”无保护地写指针”,此时任何已扫描的对象都可能被漏标。所以必须等所有 P 都进入安全点、都打开屏障,才能开始扫第一个对象。 这个 STW 极短(它只是切个状态),但它必须存在。这也是为什么每个周期至少要有两次 STW——你没法把”切状态”这件事并发化。

扫描栈不是全局 STW,是”停单个 goroutine”。

根标记的一半工作量是扫栈。做法是:暂停这个 goroutine → 扫它的栈 → 立刻恢复它。 一次只停一个 goroutine,不是停全世界。这是个很容易被误解的点,值得你记住:栈扫描的成本,是”每个 goroutine 被单独暂停一小会儿”,而不是”所有人一起停一大会儿”。 所以它的延迟影响是分散的,但它确实会引入抖动——你的 goroutine 可能莫名其妙被暂停一下去扫栈。

STW 的时长和什么相关?

这是面试和实际排障里最有价值的一句话:Go 的 STW 时长与堆大小基本解耦,但与 goroutine 数量、栈的总大小、以及运行时内部数据结构的规模强相关。

为什么?并发标记把”扫对象图”这个正比于堆大小的活儿搬出了 STW,剩下的 STW 里干的是:到达安全点、切状态、flush mcache、处理未完成的栈。这些都不正比于堆大小,但正比于”有多少 P、多少 goroutine、多少 mcache”。

所以一个典型的误判是:“我堆小,我的停顿一定短。” 错。你堆 10MB 但开了 50 万个 goroutine,停顿可能比你堆 10GB 只开 50 个 goroutine 还长。 这也直接推出了一个调优结论:控制 goroutine 数量,本身就是一个 GC 延迟优化手段,它不只是内存问题。

STW 会被”死循环”拖住吗?

这是个真实存在过的坑。STW 要求所有 goroutine 跑到安全点(safe point)——而早期的安全点只在函数调用等少数位置。如果某个 goroutine 在跑一个不含任何函数调用的紧密循环,它就不会经过安全点,STW 就会卡在那儿等它,停顿被无限拉长。

Go 后来引入了基于信号的异步抢占:运行时可以给不合作的 goroutine 发信号,强行打断它,让它进入安全点。这个机制的直接受益者就是 GC——STW 不再可能被一个 for { i++ } 卡住。

这说明什么?说明”低停顿”不是一个算法问题,而是一个系统问题。你光有并发标记不够,你还得有抢占、有 per-P 状态、有安全点设计、有调度器配合。一个 GC 的停顿指标,是整个运行时协作的结果,不是一个模块的功劳。


子主题九:GOGC 的精确语义——它到底在跟谁比,什么时候扣扳机

GOGC 被讲烂了,但绝大多数人只会背”默认 100,堆翻倍就 GC”。这句话不算错,但不够精确,而且精确到能指导你调参的程度,很多人说不出来。我们来拆。

语义:目标堆 = 存活堆 × (1 + GOGC/100)

设上一轮标记结束时的存活堆大小为 live(这是”扫完之后真正活着的对象总量”)。那么本轮的目标堆:

goal = live × (1 + GOGC/100)

GOGC=100 → goal = 2 × live。这就是”翻倍”的由来。但请注意三个常常被说错的细节:

细节一:基数是”存活堆”,不是”上次的堆大小”。 如果你的程序堆里有 80% 是垃圾,那么”堆翻倍”和”live 翻倍”完全不是一回事。GC 频率是由存活堆决定的,不是由堆的瞬时大小决定的——这直接推出一个反直觉但极其重要的结论,我们下面讲。

细节二:触发点略早于 goal。 运行时维护一个 heap_live(已分配且未回收的字节数,近似的、随分配单调递增的量),当它达到触发阈值就开工。而这个阈值不是 goal 本身,而是比 goal 小一点的值——因为从触发到标记结束,程序还在疯狂分配,堆还会继续涨。运行时会预留一段”标记期间的增长量”,让堆在标记完成时正好落在 goal 附近。这是并发 GC 特有的设计:你得提前扣扳机,才能打中目标。

细节三:GOGC=off 直接关掉 GC。 堆只涨不扫,直到你手动调用或者内存爆掉。调试时有用,生产上是自杀。

GOGC 也可以在进程运行中动态改:debug.SetGCPercent(n)。这让你能做”高峰期调大、低峰期调小”这种自适应策略。

调大 GOGC,你到底省下了什么、又付出了什么

这是本节最有价值的一段。很多人的直觉是”调大 GOGC → GC 次数少 → 省 CPU”。只对了一半。

先把一次 GC 的成本拆开:

一次 GC 的成本 = 固定成本 + 变动成本
  固定成本 = 根扫描(栈 + 全局)+ 状态切换 + 两次 STW + 调度 mark worker + 写屏障的开关
  变动成本 = 标记存活对象图(正比于 live)+ 清扫 span(正比于本轮被回收/被扫的 span 数)+ 辅助标记

现在把 GOGC 从 100 调到 400,live 不变。会发生什么?

省下的: GC 次数大约减少一半以上(每轮之间允许堆涨得更多,所以间隔更长)。于是固定成本被摊薄了——根扫描、STW、写屏障开关这些”每轮都要付一次”的钱,付的次数少了。同时 GC 占用的总 CPU 百分比下降。

没省下的: 标记工作量几乎不变。 因为它正比于 live,而 live 没变。你每次 GC 还是得扫那 500 万个存活对象,一个都不少。

多付的: 峰值内存变大(goal 变成 5 × live);每轮要清扫的 span 更多(攒了更多垃圾一次性扫);内存局部性可能变差(堆更大、更碎片化);容器场景下更容易撞 OOM。

于是那个反直觉的结论浮出水面:

调大 GOGC 的收益,本质上是”摊薄每轮固定成本”,而不是”减少标记工作量”。

这直接告诉我们:什么负载下调大 GOGC 几乎没用?

答案是:存活堆很大、但分配速率很低的程序。

典型场景:启动时加载了一个 2GB 的字典到内存,之后每秒只分配几百 KB。这种程序里,成本几乎全是”扫描 2GB 存活对象”,固定成本占比极小。你把 GOGC 从 100 调到 800,GC 次数少了,但每次还是得扫那 2GB,总 CPU 几乎一模一样,只是内存峰值翻了几倍——纯亏。

反过来,什么负载下调大 GOGC 收益最大? 存活堆小、分配速率极高、固定成本占比大的程序。比如一个高频转发服务:每次请求分配几十 KB 临时对象、live 只有几十 MB。这种程序里每轮 GC 的钱主要花在”根扫描 + STW + 状态切换”上,把它们摊薄,收益立竿见影。

再问一层:那怎么才能真正减少”扫描 2GB 存活对象”的成本? 答案是降低存活堆本身,或者降低扫描的”指针密度”——这个我们留到调优那一节讲。这里你只需要记住:GOGC 动不了 live,它动的是”多久扫一次”。

GOMEMLIMIT 与 GOGC 的关系

两个旋钮同时存在时,运行时会取更保守(更小)的那个目标。也就是说,GOMEMLIMIT 不是替换 GOGC,而是给 GOGC 加了个天花板。

三个必须知道的性质:

  1. 它是软限制。 Go 不承诺堆绝不越过 GOMEMLIMIT。GC 是并发的、有延迟的,瞬时越过是设计上允许的。它保证的是”越过之后运行时会拼命往回收”,不是”绝不越过”。
  2. 逼近上限时,GC 会变得极其激进——甚至可能连续触发,把 CPU 大量消耗在 GC 上,表现为”程序还在跑但几乎不干活”。这在监控上看起来像”卡死”,其实是 GC 在救你的命。如果你看到 GC CPU 占比飙到很高且堆贴着上限,说明你的上限设得太紧,程序正在窒息。
  3. 设上限时要给非堆内存留余量。 进程占用的内存不只有 Go 堆,还有 goroutine 栈、二进制映像、运行时元数据、以及操作系统层面的开销。照着容器限制的原值设,等于没留缓冲。

一个补充:在 GOMEMLIMIT 出现之前,社区流行过一个技巧叫 ballast(压舱物)——启动时分配一个很大的、长期存活的 []byte(比如几百 MB),人为把 live 抬高,从而把 goal 抬高,减少 GC 次数。它的原理完全符合我们上面的公式,确实是有效的。但它占的是真金白银的内存,而且本质上是在”骗”运行时。有了 GOMEMLIMIT 之后,这个技巧基本可以退出历史舞台了——你有了更直接、更诚实的旋钮。


子主题十:GC 调优实战——gctrace 怎么读、停顿怎么测、手段有哪些

机制讲完了,来点能立刻上手的。

一、读懂 gctrace

GODEBUG=gctrace=1 ./yourserver

会输出类似这样一行(字段含义按位置讲解,不同 Go 版本字段略有增减,较新的版本还会多出栈和全局变量相关的字段):

gc 3 @0.156s 4%: 0.032+2.1+0.045 ms clock, 0.13+0.42/1.5/1.1+0.18 ms cpu, 12->13->9 MB, 20 MB goal, 8 P

逐段拆:

  • gc 3:第 3 次 GC。

  • @0.156s:进程启动后 0.156 秒。用它算 GC 频率(两次的差值 = 间隔)。

  • 4%:从进程启动至今,GC 占用的 CPU 时间百分比。这是最重要的健康度指标之一。个位数算健康,持续偏高说明 GC 在吃掉你的算力。

  • 0.032+2.1+0.045 ms clock:三段墙钟时间,格式固定为 A+B+C:

    • A = Sweep Termination 的 STW
    • B = 并发标记的总时长(这一段是墙钟,跨越了几毫秒甚至更久,但程序一直在跑)
    • C = Mark Termination 的 STW

    你要盯的停顿就是 A 和 C。 B 再大也不是停顿,只是标记干得久。

  • 0.13+0.42/1.5/1.1+0.18 ms cpu:同样的三段,但换成 CPU 时间,中间那段被拆成三项:

    • 0.42 = assist 时间(用户 goroutine 被拉去做标记的 CPU 时间)
    • 1.5 = 后台 mark worker 的 CPU 时间(dedicated worker)
    • 1.1 = idle 时间的标记(P 闲着没事干时去标记)

    这个拆分是诊断的金矿。 如果 assist 那一项常年居高不下,说明你的分配速率已经超过了后台标记的消化能力,你的业务代码正在被迫替 GC 打工——这时候你看到的”慢”,不是停顿,是分配变慢了。

  • 12->13->9 MB:三个堆大小。第一个是 GC 开始时的堆,第二个是 GC 结束时的堆,第三个是标记结束后认定的存活堆(live)。第三个数字是你真正的”内存健康基线”——它降不下来,别的都没用。

  • 20 MB goal:下一轮的目标堆。拿它和第三个数字对一下,能验证你的 GOGC 是不是按预期在起作用。

  • 8 P:P 的数量。

一条实用的读法清单:

  1. 看 A + C(两次 STW)→ 停顿达标没有。
  2. 看 4% 那个百分比 → GC 的总 CPU 开销。
  3. 看 assist / 后台 / idle 三项的比例 → 谁在干活。assist 高 = 分配速率过高。
  4. 看 ->9 MB 这个存活堆的趋势 → 是不是有内存泄漏(存活堆单调上涨,就是泄漏的强信号)。
  5. 看 GC 的间隔(@ 时间的差值)→ 频率合不合理。

注意一个陷阱:gctrace 里的 MB 是 Go 堆的近似值,不是进程的 RSS。 你看到”堆 50MB”但容器里进程占 200MB,未必矛盾——还有栈、运行时元数据、以及已经被清扫、但还没归还给操作系统的内存(Go 会保留一部分空闲 span 以便复用,避免频繁跟 OS 打交道)。想强行归还可以用 debug.FreeOSMemory(),但它会立刻触发一次完整 GC,生产环境慎用。

二、测停顿的正确姿势

gctrace 够用,但有几个短板:它是 stderr 上的文本,不好聚合;它给的是单次值,不给分布。生产环境更推荐:

方案一:runtime/trace。 在程序里 trace.Start(w) 抓一段时间,然后:

go tool trace trace.out

打开的可视化页面里,你能看到每个 P 的时间线上明确标出的 STW 区间,以及 GC 各阶段的边界、goroutine 被阻塞在哪里。这是理解”我的停顿到底花在哪”最直观的工具——你能亲眼看到”所有 P 同时被掐断”的那一条竖线。

方案二:runtime/metrics。 现代 Go 版本提供了一个稳定的指标接口,其中 /gc/pauses:seconds 是停顿时间的直方图。你可以定期把它拉出来,上报到 Prometheus,做 p99 停顿告警。这比解析 gctrace 文本更适合生产——它结构化、可编程、能算分位数。

记住一个方法论:GC 停顿必须看分位数,不能看平均值。 平均值会把”99 次亚毫秒 + 1 次几十毫秒”抹平成”看起来还行”,而你的用户投诉的恰恰是那 1 次。

三、调优手段,按有效性排序

手段一(收益最大):降低分配速率。

用 pprof 找分配热点:

go test -bench=. -memprofile mem.out
go tool pprof -sample_index=alloc_space ./bin mem.out   # 按分配总量排序
go tool pprof -sample_index=alloc_objects ./bin mem.out # 按分配次数排序

常见的”分配大户”:fmt 系列(尤其是 Sprintf,它内部有反射和 boxing)、[]byte 与 string 的反复互转、每次请求新建的临时结构体、把值塞进 interface{} 导致的装箱、+ 拼接字符串。

对应的手法:给 slice 和 map 预设容量(避免扩容时的重新分配和拷贝)、复用 buffer(sync.Pool)、用 strconv 替代 fmt、用字节拼接替代字符串拼接、把热路径上的 interface{} 换成泛型或具体类型。

手段二(收益第二大):降低存活堆。

live 是标记工作量的唯一决定因素。常见的”存活堆膨胀”来源:永不失效的缓存、只增不删的 map、全局的 slice 只 append 不缩、以及无意中持有的引用(把一个大对象的某个小字段存进长生命周期的结构里,整个大对象就都活了——这是最隐蔽的一种泄漏)。

一个必须知道的细节:slice 的截断操作不会释放底层数组。 你从一个 100 万元素的 slice 里 s = s[:10],那 100 万个元素背后的数组仍然整块活着。想真正释放,得让引用彻底消失(比如 s = append([]T(nil), s[:10]...) 复制一份小的,让大的那个没人引用)。

手段三(经常被忽略,但对扫描成本影响极大):降低指针密度。

这是本节最”内行”的一条。GC 扫描一个对象,成本正比于它含指针的字段数量,而不是它的字节大小。Go 的 span 有一个 noscan 标记:完全不含指针的对象(比如 []byte、[]int64、纯数值结构体)被放在 noscan span 里,GC 回收它们时根本不扫描它们内部。

推论非常实用:

  • 一个 100MB 的 []byte,对标记成本几乎毫无贡献(只贡献堆占用和清扫成本)。
  • 一个存了 100 万个 *User 的 map,标记时要走 100 万次指针追踪,还可能引发大量 cache miss。

所以有个经典的优化套路:把”指针密集的索引”和”数据本体”拆开。 比如把 map[string]*BigRecord 改成 map[string]int32(值是 id)+ []BigRecord(按 id 索引),或者更进一步,把大块二进制数据从结构体里剥离出去单独放。这样 GC 要走的指针路径从”百万级”降到”几十级”。

代价是什么? 代码复杂度上升、可能多一次间接寻址、以及失去了指针的部分语义便利。这正是 Go 的一贯交易:用工程复杂度换运行时开销。

手段四:旋钮。 GOGC、GOMEMLIMIT、debug.SetGCPercent、debug.SetMemoryLimit。按上一节的语义来调,别瞎试。

手段五:控制 goroutine 数量。 还记得吗?根扫描和 STW 都与 goroutine 数量强相关。一个失控的 goroutine 泄漏,在 GC 视角里是双重伤害:既占内存,又拉长停顿。

四、两个反模式

反模式一:把 runtime.GC() 当优化。 它只是强制跑一次完整的、带两次 STW 的 GC。它不会让后续更快,只会让调用它的那一刻更慢。除非你在做基准测试(为了排除上一轮 GC 的干扰),否则没有理由用它。

反模式二:看到 GC 频繁就调大 GOGC。 按上一节的分析,先问一句:你的 live 大不大、分配速率高不高? 如果是”分配速率爆炸”导致的频繁 GC,调大 GOGC 只是把垃圾攒得更多、让每次清扫更重,治标不治本。正确的做法永远是先看数据(gctrace / pprof),再动旋钮。


子主题十一:把链路闭合——栈、逃逸分析、GC 是一台机器的三个齿轮

到这里,我们把这一章和上一章彻底焊死。

栈是根,而且是最大的那个根

GC 的根集合有三大块:全局变量(数据段)、goroutine 栈、运行时内部数据结构(比如 mspan 里某些元数据、finalizer 相关的结构)。其中栈是变动最频繁、扫描成本最高的一块。

为什么栈难扫?因为栈上的”哪些字节是指针”不是一个固定答案。同一个栈槽位,在函数执行的某个时刻存的是指针,在另一个时刻可能存的是整数(寄存器/栈槽被复用)。所以 GC 不能拿着一张静态地图去扫栈,它必须知道”扫描发生的那一刻,程序执行到哪条指令”。

Go 的答案是栈映射(stack map):编译器为每个函数的每个安全点生成一张位图,标明此刻栈帧里哪些槽位存放着活跃的指针。GC 扫栈时,顺着栈帧链逐帧回溯,用当前 PC 查对应的栈映射,就知道该扫哪儿、不该扫哪儿。

这里有个非常重要的设计后果:Go 的 GC 是”精确 GC”,不是”保守 GC”。 保守 GC(比如某些 C 的 GC 库)看到像指针的值就当指针,宁可错保不可漏保;Go 因为有编译器生成的元数据,能精确区分一个 uint64 和一个 *T。精确 GC 的代价是编译期要生成并保存这些元数据(二进制会变大一点),收益是不会有”因为把一个整数误认成指针而导致的对象永不释放”。

逃逸分析:不给 GC 添负担,就是最好的 GC 优化

现在把上一章的知识接上来。编译器判定一个变量”不逃逸”,它就会被分配到栈上。栈上意味着:

栈上(不逃逸)堆上(逃逸)
分配成本SP 移动一条指令走 mcache → mcentral → mheap,可能触发 GC
回收成本函数返回,SP 一退,零成本必须等 GC 标记 + 清扫
对写屏障无关(栈上的写虽然也走屏障,但这块内存本身不进 GC 账本)每次写指针都要过屏障
对标记不参与标记,不进存活集增大 live,直接抬高标记工作量
对清扫无 span,无需清扫占 span,清扫要处理
内存局部性极好(栈是连续的、反复复用的热内存,几乎总在 L1 里)一般(堆上分散,可能 cache miss)
碎片无有

这一张表,就是”为什么 Go 程序优化的第一件事永远是看逃逸分析”的全部理由。每让一个对象留在栈上,你就同时省掉了分配、写屏障、标记、清扫、碎片、cache miss 六笔钱。

用 go build -gcflags="-m" 看逃逸分析结论,是 Go 程序员最该养成的习惯之一。

反过来想:为什么 Go 敢这么激进地把对象往栈上塞?

这是个很少有人问、但问出来很有意思的问题。答案是:因为 Go 的栈是可增长的。

回忆一下:Go 的 goroutine 栈是连续栈,初始很小(几 KB),不够了就分配一块更大的、把旧栈拷过去。因为栈可以长大,所以编译器不需要为”万一不够”预留巨大空间,也就敢于把更多东西放栈上。

反过来做个思想实验:如果 Go 的栈是固定大小的(比如固定 256KB 或像传统线程那样 1MB),那么:

  1. 你不能开几十万个 goroutine,因为栈就吃掉了几十 GB;
  2. 编译器在做逃逸分析时必须更保守——遇到大对象、或者递归里的对象,它会想”栈就这么大,万一爆了怎么办”,于是倾向于扔到堆上。

结论:可变长栈 → 小初始栈 → 海量 goroutine + 激进的逃逸分析 → 更少对象进堆 → GC 压力小 → 停顿更容易做低。 这是一条完整的因果链,任何一个环节拆掉,后面的都立不住。

再补一个工程细节:栈增长要拷贝整个栈,而拷贝意味着”所有指向旧栈的指针都要修正”。这件事必须在一个确定的、没有其他人在看这块栈的时刻做——所以栈拷贝会暂停那个 goroutine,并且和 GC 的栈扫描互斥(GC 扫栈前也会暂停那个 goroutine)。运行时用 goroutine 的状态机来保证这两件事不打架。栈、调度器、GC 三个模块在这里是真正咬合的。

一张闭环图

   逃逸分析(编译期)
        │
        ├── 不逃逸 ──→ 留在栈 ──→ 函数返回即清零 ──→ 完全不进 GC 账本
        │                 ↑
        │            (可变长栈让它敢这么激进)
        └── 逃逸 ────→ 进堆 ──→ 分配器(mcache/mcentral/mheap)
                                    │
                                    ├── 堆增长到 goal ──→ 触发 GC
                                    │
                                    └── GC ──→ 从栈/全局出发(栈是根)
                                              ──→ 三色并发标记(写屏障保不变量)
                                              ──→ 并发清扫 ──→ 内存回池,等待复用

看懂这张图,你就理解了:Go 的内存体系里没有”独立的 GC 优化”,只有”整条链路的成本分配”。 你不能说”我的 GC 调得好”,你只能说”我把成本分配得合理”。


子主题十二:GC 是寄生在调度器上的——辅助标记与背压

这一节讲一个 90% 的 Go 程序员没想过的问题:GC 的活儿,到底是谁在干?

答案有点意外:GC 没有自己的线程。它就是一群特殊的 goroutine,靠 Go 自己的调度器调度。

后台标记:占用多少 CPU 算是”合理”?

并发标记总得有人干。Go 的设定是:后台标记期望占用大约 25% 的 CPU 时间(源码里就是这么个常量)。它的实现方式是调度器层面的:

  • Dedicated worker:标记阶段,调度器会把一部分 P “征用”,让它们专门跑 mark worker goroutine,别的什么也不干,直到本轮标记结束。
  • Fractional worker:另一部分 P 按一个时间比例,间歇性地去做标记。
  • Idle worker:P 上实在没活儿干(没有可运行的 goroutine)时,也去帮忙标记。

所以并发标记不是”免费”的——它吃掉的是本来可以跑你业务代码的 CPU。 25% 这个数就是那个”用吞吐换停顿”的具体汇率。

辅助标记(mark assist):GC 的背压机制

现在来想一个尖锐的问题:如果分配的速度,远远超过后台标记的速度,会怎样?

后台标记只拿 25% 的 CPU,而你的程序在以每秒几百 MB 的速度分配。标记还没走完,堆就已经涨到 goal 的两倍、五倍、十倍……最终内存爆掉。并发 GC 必须解决这个”标记跑不过分配”的问题。

Go 的答案非常漂亮,叫辅助标记(mark assist),本质是一个债务机制:

  1. 分配即欠债。 每个 goroutine 分配内存时,不是白拿的,它同时背上了一笔”债”——债务量大致正比于它的分配量,乘以一个由”标记还剩多少、堆还能涨多少”决定的系数。
  2. 还债有两种方式。 要么自己去干标记的活儿(扫描一批对象,用工作量抵债),要么从后台 worker 的”信用”里买(后台 worker 干得多,会积累正的信用额度,可以转让给分配者)。
  3. 还不清,就走不动。 债务没还清,这次分配就一直卡在那儿——你的 goroutine 不是在等锁,它是在替 GC 打工。

这就是背压(backpressure): 分配速率被自动钳制到标记速率附近。你分配得越猛,你自己被拉去干活的时间就越长,你的分配就越慢。系统因此不会失控——内存不会被打爆,代价是分配变慢。

一个狠类比:把 GC 想成信用卡账单。 后台 mark worker 是”每月自动还的最低还款额”,你的分配是”刷卡消费”。刷得太猛,最低还款不够了,你就得当场去打零工还债(assist)——而你打零工的时候,是没法继续刷卡的。系统不会让你破产(OOM),但会让你花钱变慢(延迟上升)。

而且这个债务系数是非线性的:越接近 goal(堆快涨满),欠债的系数越高,分配者被拖得越狠。所以你的服务在”快触发 GC 的那段时间”里,分配会明显变慢——这是一个在 pprof 上能直接看到的现象。

如何观测到 assist

三个地方会露馅:

  1. gctrace:CPU 时间那一段的第一项(assist / 后台 / idle 里的 assist)。它长期偏高,就是背压在起作用。
  2. pprof 的 CPU profile:runtime.gcAssistAlloc 这个函数如果出现在热点里,铁证如山——你的业务代码正在被拉去帮忙标记。
  3. goroutine 的阻塞分析:goroutine 卡在 assist 上,在 block profile 里也会被记一笔。

这里有个极其重要的诊断洞察:assist 造成的问题,表现为”慢”,而不是”停顿”。 你去查 STW,会发现停顿很短、很漂亮,可服务的 p99 就是烂。因为问题根本不在 STW,而在你的分配被背压拖慢了。 只看停顿指标来诊断 Go 的 GC 问题,会漏掉这一类。

怎么缓解 assist

按有效性:

  1. 降低分配速率(治本)。assist 的债务正比于分配量,分配少了,债自然少。
  2. 降低存活堆。标记要扫的东西少了,标记就快了,债还得快。
  3. 适当调大 GOGC。给标记留出更多”堆的余量”,债务系数会下降(因为离 goal 更远)。
  4. GOMEMLIMIT 会在逼近上限时让 assist 更激进——这是设计使然:内存快爆了,只能逼所有人一起还债。所以内存上限设得太紧,会直接导致 assist 暴涨。

本节的一句话

这已经是本章第四次看到同一个套路了。 排队等 STW?改成并发标记,代价是写屏障。栈要重扫?改成混合屏障,代价是栈上的写也要过屏障。标记跑不过分配?加背压,代价是分配变慢。

Go 的每一次”停顿优化”,都是一次成本搬家:把停顿搬到吞吐里。 理解这一点,你就能回答那个终极问题——“为什么 Go 的 GC 停顿这么低,但我的服务在高压下还是慢?” 因为你只是把账单从”延迟”那一栏,挪到了”吞吐”那一栏。账从来没被免掉,只是换了科目。


子主题十三:业界对照补强——ZGC 的染色指针与 V8 的分代

最后一节,把视野拉到整个业界。不是为了吹谁,而是为了让你看清楚:GC 的设计空间有多大,以及”为什么不同的语言会走到不同的角落”。

Java ZGC:把元数据塞进指针里

ZGC 最出名的设计叫染色指针(colored pointers)。

常规 GC 要记录”这个对象标记过了没有”,得有一张位图(bitmap),放在另外的内存里。于是每判断一次”这个对象标了没”,就要多一次内存访问——而这次访问很可能是 cache miss,在遍历百万级对象图时,这个开销很可观。

ZGC 的思路是:64 位地址空间里的位,我们用不完。那就在指针本身的高位里,塞进几个”颜色位”。 标记状态(Marked0 / Marked1 / Remapped 等)直接编码在指针里。判断一个对象的状态,看指针的高位就行,不需要访问任何额外的数据结构。

这带来两个直接后果:

后果一:需要读屏障(load barrier),而不是写屏障。 因为颜色在指针里,而指针是”到处流动的”——你从字段里读出一个指针,这个指针可能是”旧颜色”(比如指向一个已经被搬走的对象)。所以 ZGC 在每次从堆加载引用时插入一小段代码,检查指针的颜色,如果不对就当场修正并写回(这个特性叫 self-healing,指针一旦被修正,下次再读就不用修了)。

后果二:读屏障必须极快,因为读比写频繁得多。 ZGC 的解法很绝:在支持的操作系统上用多重映射(multi-mapping)——把同一块物理内存同时映射到几个不同的虚拟地址区间,每个区间对应一种颜色。这样”判断颜色”就退化成了”判断指针落在哪个地址区间”,在硬件上就是几条指令的事。

现在问一个真正有价值的问题:为什么 Go 不抄这个?

不是因为 Go 团队不知道,而是因为代价不同:

  1. 染色指针依赖 64 位地址空间和 OS 的虚拟地址多重映射能力。 Go 要支持更广泛的平台(包括 32 位、包括一些嵌入式和冷门 OS),把一个强依赖特定 MMU 行为的机制塞进核心,会严重限制可移植性。
  2. Go 的指针模型更复杂。 Go 允许内部指针(指向结构体中间的字段、指向 slice 的某个元素),还有大量的 unsafe.Pointer 运算,以及和 C 的直接互操作(cgo)。给指针”上色”,意味着所有这条链路上的代码——用户自己写的 unsafe 代码、C 代码里保存的指针、指针与整数的来回转换——全都要考虑颜色位的语义。 这是一个巨大的边界情况爆炸。
  3. Go 的取舍一贯是”宁可多一次内存访问,也不要把运行时复杂化”。 位图访问慢一点,但它简单、可移植、可推理。

所以这不是”Go 不如 ZGC”,而是”Go 的约束集不同,最优解就不同”。

最后补一个公允的事实:近年的 JDK 为 ZGC 引入了分代模式(Generational ZGC)。 这很有意思——它说明两件事:第一,“分代”和”低延迟”并不是互斥的,它们只是”很难同时做对”;第二,反过来印证了 Go 当年的选择:放弃分代,不是因为分代无用,而是因为在当时的工程预算下,它不是性价比最高的那一笔投入。 工程决策从来不是真理判断,是资源判断。

V8:为什么分代对 JS 是天作之合

V8 的 GC(代号 Orinoco)是标准的两分代结构:

新生代(Young Generation) 用 Scavenger:把空间切成两半(From / To,半空间),分配只在 From 里做;回收时,用 Cheney 算法从根遍历,把存活的对象复制到 To 空间,然后把整个 From 一次性清空、角色互换。

这个算法的成本模型极其特殊:它的成本只正比于”存活对象的大小”,与”垃圾有多少”完全无关。 垃圾再多,你也不用一个个去碰它们——直接把整个空间扔掉就行。所以当”朝生暮死”的假设成立时(存活率极低),Scavenger 的效率高得离谱。

代价是什么?你永远只能用一半的新生代空间。 这就是复制算法的经典账单:用空间换”回收时不碰垃圾”。

老生代(Old Generation) 用并发标记 + 清扫,必要时才做整理(Mark-Compact,因为搬动大量长期存活的对象很贵)。标记过程既有增量(切成小片,塞进 JS 执行的间隙,避免一次长停顿)也有并发(在后台线程上跑,靠写屏障记录期间的修改)。

现在问:为什么这条路对 JS 是对的,而对 Go 是浪费的?

关键在于一个我们前面铺垫过的事实:V8 没法和 Go 一样,靠逃逸分析把短命对象挡在堆外面。

原因有三层,一层比一层深:

第一层:JS 的闭包语义,天然要求”活过返回”。 Go 的逃逸分析能证明”这个变量不会活过函数返回”,从而把它放栈上。但 JS 里,回调、Promise、事件处理器遍地都是,闭包捕获的变量必须活过创建它的函数——这是语义要求,不是优化选择。于是每次创建闭包,就可能有一次必然的堆分配。Go 里同样的场景,很可能整个被编译器优化没了。

第二层:JS 的动态语义让静态判断极难。 对象可以随时增删属性、隐藏类会中途变化、eval 和 with 能改变作用域、Proxy 能拦截一切。当编译器连”这个对象长什么样”都说不准的时候,它怎么敢断定”这个对象不会活过这个函数”? 于是保守起见,只能放堆上。

第三层:V8 的逃逸分析存在,但目标不同。 V8 也有逃逸分析,但它主要用于标量替换这类局部优化(把一个短命的小对象拆成几个局部变量,从而彻底消掉分配)。这个技术在能起作用的地方很有效,但它的适用面远比 Go 的栈分配窄——因为它同样受制于上面两层。

结论就清楚了: JS 的短命对象必须进堆,进堆就必须在堆里解决,而堆里解决短命对象最有效的答案就是分代 + 复制式 Scavenger。Go 的短命对象大多不进堆,既然不进堆,分代要解决的那个问题就不存在了,那套机制(更重的写屏障、多套算法、对象的晋升与老化)自然就成了纯负担。

同一个问题(海量短命对象),因为一门语言能不能在编译期证明对象生命周期,走向了两个完全不同的答案。这不叫技术优劣,这叫约束决定设计。

最后一层对照:约束不同,答案就该不同

再补一个容易被忽略的维度:它们服务的”延迟约束”根本不是一回事。

  • 浏览器主线程的预算是帧:一帧十几毫秒,GC 停顿一超,用户就看到掉帧、点不动。所以 V8 的主线是”切碎”——增量标记、空闲时间调度,把大活儿拆成无数小片塞进执行间隙。它不敢要”一次 5 毫秒的停顿”,哪怕只有一次。
  • Go 服务端的预算是请求的尾延迟:p99 要好。它对”一次短停顿”的容忍度反而比浏览器高,但它需要吞吐和延迟的平衡,而且它有几万个并发 goroutine 在跑。所以 Go 的主线是”并发 + 极短的两段 STW”。

同样一个”低停顿”的目标函数,因为约束曲线的形状不同,最优解的形状就不同。

收尾的一句话

问”Go 的 GC 和 Java 的 GC 谁更强”,是个坏问题——就像问”手术刀和菜刀哪个更好”。 正确的问法是三个:

  1. 我的负载长什么样?(分配速率、存活堆大小、存活对象的年龄分布、指针密度、goroutine 数量)
  2. 我的目标函数是什么?(p99 延迟?吞吐?内存占用上限?还是三者加权?)
  3. 在这个负载和这个目标下,哪个运行时的取舍跟我最一致?

能问出这三个问题,你就不是”会调 GOGC 的人”,而是”懂 GC 设计的人”了。而这一章从头到尾想给你的,恰恰是后者。


业界对照

Java ZGC / G1: 分代 + 并发标记 + 各种读屏障/染色指针,追求低停顿的同时保留高吞吐。ZGC 用”染色指针”(在指针上做文章)和读屏障,实现了亚毫秒级停顿;代价是实现极其复杂。Java 的路线是”用极其复杂的工程换低停顿 + 分代高吞吐兼得”。

.NET: 分代 + 并发,也提供服务器 GC 等工作模式。

V8(JS 引擎): 分代(新生代 Scavenger + 老生代 Mark-Compact),因为 JS 的对象分配模式和 Go 不同,且没有 Go 那么强的逃逸分析,短命对象必须靠新生代快速回收。

Erlang: 每个进程独立小堆,GC 只回收单进程,天然无全局 STW,停顿被”进程级”隔离。这是”用进程隔离换 GC 简单”的另一条路。

Rust: 没有 GC,靠所有权和借用检查在编译期保证内存安全,运行期零 GC 停顿。代价是开发者要承担借用检查的认知负担,且无法方便地做”图结构 + 共享所有权”。


版本演进小结

版本关键变化
~1.0STW 标记-清扫,停顿随堆线性增长
1.5并发三色标记 + Dijkstra 写屏障,GC 史上最大飞跃
1.8混合写屏障,削减栈重扫成本
1.9+持续打磨清扫、大对象、STW 边界
1.19GOMEMLIMIT 内存软上限

贯穿的主线就一条:Go 的 GC 一切演进,都围绕”把停顿压下去”这一个目标,代价是写屏障拖慢写、并发标记吃掉 CPU、放弃分代。 没有免费的午餐,Go 只是选择为”低延迟”这顿午餐,付”吞吐”和”写屏障”的账。


本章思考题

【思考题】

  1. Go 为什么放弃分代回收?它靠什么替代分代,拿到了”快速回收短命对象”的大部分收益?

  2. 写屏障的代价是什么?它换来的是什么?请用”强三色不变量”解释为什么写屏障能防止漏标。

  3. GOGC 和 GOMEMLIMIT 各自的语义是什么?在容器内存受限的场景,为什么后者更贴合实际?

  4. 一个对象逃逸分析判它”逃逸”了,它和 GC 的交互会怎样?如果同一对象改成”不逃逸”,GC 那边会有什么变化?

  5. 请亲手推演一次三色标记:有对象 a、b、c、d,根变量 root 指向 a,a.next=b,b.next=c,d 无人引用。请逐步写出白色/灰色/黑色的变化和灰色队列的内容,并回答:为什么”灰色”这个状态是必需的,不能只靠黑和白两色完成并发标记?

  6. Dijkstra 插入屏障和 Yuasa 删除屏障,各自守住了什么不变量?为什么删除屏障能让”栈免于重扫”?请用一个完备的论证说明:在删除屏障下,“标记期间往一个已扫完(黑色)的栈里写入新指针”为什么不会漏标?

  7. 有人说”调大 GOGC 能减少 GC 的标记工作量,所以能提升吞吐”。这句话错在哪?请分析一次 GC 的成本构成,说明调大 GOGC 到底省了什么、抬高了什么,并指出在什么负载特征下调大 GOGC 几乎没有收益、纯亏内存。

  8. 一个 Go 服务:GOGC=100,存活堆约 50MB,每秒分配约 500MB,goroutine 数量 3000。gctrace 显示两段 STW 都很短(亚毫秒到毫秒级),但中间那段 CPU 时间里的 assist 项远高于后台标记项,服务的 p99 延迟很差。请解释:发生了什么?为什么”停顿很短”和”延迟很差”可以同时成立?给出至少三种应对手段,并说明各自代价。

【参考答案】

  1. 因为逃逸分析已经在编译期把海量短命对象留在了栈上——栈上对象函数返回即清零,根本不进堆、不参与 GC,等于”在堆之外解决了新生代的问题”。既然短命对象大多不进堆,分代的价值就大打折扣;而分代需要更重的写屏障和更复杂的多套算法,与 Go 的简单哲学冲突。所以 Go 用”逃逸分析 + 单一并发标记”替代了”分代”。

  2. 代价是拖慢所有指针写操作(每次写指针都要过一道屏障检查),并占用 CPU 做并发标记。换来的是低 STW。强三色不变量是”黑色对象不能指向白色对象”;并发标记中,程序可能让黑色对象新指向一个白色对象,一旦发生就会漏标(活对象被误回收)。写屏障在这些写入的瞬间,把被指向的白色对象标回灰色、重新入队扫描,从而守住不变量、防止漏标。混合写屏障(插入+删除)用更小代价做到了这一点。

  3. GOGC=100 意为”堆比上次 GC 后增长 100%(翻倍)时触发 GC”,是”用内存换 GC 频率”的旋钮。GOMEMLIMIT 是内存软上限,让 Go 在逼近该值时更积极地 GC,避免把容器内存打爆被 OOM kill。容器里你关心的不是”堆翻没翻倍”,而是”别超过 cgroup 的 2GB 配额”,所以 GOMEMLIMIT 直接对应用户的真实约束,比 GOGC 更贴合。

  4. 逃逸了,对象进堆,分配走 mcache/mcentral/mheap,且要参与 GC 的标记-清扫:GC 要扫它、维护它的位图,它增加了 GC 的存活集和扫描工作量。改成不逃逸,它留在栈上,分配是 SP 指针移动、函数返回即回收,完全不进 GC 账本——堆不涨、GC 不扫。这就是”减少分配”和”让对象留栈”对 GC 压力的直接缓解机制,也是性能优化里最有效的一类手段。

  5. 推演:初始 a/b/c/d 全白,灰色队列空。① 扫根:root 指向 a,a 白→灰,入队(队列=[a])。② 出队 a:扫 a 的字段,a.next 指向 b,b 白→灰入队(队列=[b]);注意 a.name 是 string,内部的字节数组也是堆对象,同样白→灰入队;a 字段扫完,a 灰→黑。③ 出队 b:b.next 指向 c,c 白→灰入队;b 灰→黑。④ 出队 c(及字符串字节数组):c.next 为 nil,c 灰→黑。⑤ 队列空,终止:黑={a,b,c,字符串数组},白={d},回收 d。 为什么灰是必需的:因为标记不是一个原子动作,“发现一个对象”和”扫完它的引用”之间必然存在一个中间态——这个中间态就是灰。如果只有黑白两色,那么一个对象要么未被发现(会与”垃圾”混淆)、要么已扫完,GC 就没有任何方式表达”我知道它活着,但还没来得及看它指向谁”。并发标记恰恰需要这个中间态:它是工作队列的载体,也是唯一可以安全暂停和恢复的位置。换句话说,灰色的存在,让”标记到一半”这个状态变得可描述、可推理——而”标记到一半”正是并发标记的全部难点。

  6. Dijkstra 插入屏障守的是强三色不变量:黑色对象不能指向白色对象。它在写入新值时把新值涂灰(shade(ptr)),于是黑对象要指向的对象不会是白的。Yuasa 删除屏障守的是弱三色不变量:允许黑对象指向白对象,但这个白对象必须能从某个灰对象经由一条全白路径到达。它在覆盖槽位前把旧值涂灰(shade(*slot)),从而保证 SATB 性质:标记开始那一刻从根可达的对象,本轮一定会被标记到——因为可达路径上的每一条边被删除时,被删的那个后继都会被涂灰保住,链条永远断不掉。 栈免重扫的理由:栈在标记开始时被扫一次后即变黑。此后栈上的任何写入都不会漏标,分两种情况分析:① 覆盖旧引用——旧目标被 shade(*slot) 无条件保住,即使它已经死了,也只是变成浮动垃圾,不会漏标;② 写入新引用——新值只能来自三个地方:常量/静态数据(无所谓)、某个已存在的变量或字段(持有它的位置要么已扫过——扫过就意味着它引用的对象都已涂灰、已在标记范围内;要么还没扫——那它自己还在灰色集合里,很快会被扫到)、或者标记期间新分配的对象(新对象分配即标黑,本轮无条件存活)。没有第四种来源——一个值不能凭空产生,它流动路径上的每一环都已被标记进度覆盖或保住。所以栈扫一次永久有效,不需要在 mark termination 时 STW 重扫所有栈。

  7. 错在把”GC 次数”和”标记工作量”混为一谈。一次 GC 的成本 = 固定成本(根扫描、状态切换、两次 STW、调度 mark worker、写屏障开关)+ 变动成本(标记存活对象图,正比于 live;清扫 span,正比于本轮回收量;辅助标记)。调大 GOGC:live 不变,所以标记工作量几乎不变;GC 次数变少,所以固定成本被摊薄,GC 占用的总 CPU 百分比下降;代价是峰值内存变大(goal = live × (1+GOGC/100) 抬高)、每轮要清扫的 span 更多、内存局部性变差、容器里更容易 OOM。所以调大 GOGC 的收益本质是”摊薄每轮固定成本”,不是”减少标记工作量”。 几乎没收益的负载:存活堆很大、分配速率很低。比如启动时加载 2GB 字典、之后每秒只分配几百 KB。此时成本几乎全部是”扫描 2GB 存活对象”这一项,固定成本占比极小;调大 GOGC 之后每次还是得扫那 2GB,总 CPU 几乎不变,只是内存峰值翻了几倍——纯亏。反过来,收益最大的负载是”存活堆小、分配速率极高”(每次请求分配几十 KB 临时对象、live 只有几十 MB),此时每轮的钱主要花在固定成本上,摊薄它收益立竿见影。真正想减少标记工作量,只有两条路:降低 live,或降低指针密度(用 noscan 的纯标量结构替代指针密集结构)。

  8. 发生的是辅助标记(mark assist)造成的背压。后台 mark worker 只占约 25% 的 CPU,每秒 500MB 的分配速率远超它的消化能力,于是运行时让每个分配的 goroutine 按比例”还债”——要么自己去扫对象,要么从后台 worker 的信用里买;债没还清,这次分配就卡住。分配速率被自动钳制到标记速率附近,内存因此不会爆,代价是分配变慢。 “停顿很短”和”延迟很差”可以同时成立,因为 assist 造成的问题是吞吐损失,不是 STW。你去查停顿指标会看到两段 STW 都是亚毫秒/毫秒级、很漂亮,但业务 goroutine 有大把时间花在 runtime.gcAssistAlloc 上替 GC 打工——这在 pprof 的 CPU profile 和 gctrace 的 assist 项里都会直接露馅。只看停顿诊断 Go 的 GC 问题,会系统性漏掉这一类。 应对手段与代价:① 降低分配速率(治本)——用 pprof 的 alloc_space/alloc_objects 找热点,预分配 slice/map 容量、sync.Pool 复用 buffer、strconv 替代 fmt、避免 []byte/string 反复互转、避免热路径上的装箱。代价:代码要改,可读性略降。② 降低存活堆——清掉永不失效的缓存、只增不删的 map;注意 s = s[:10] 不会释放底层数组,要真正断开引用。代价:可能改变缓存命中率等业务行为。③ 降低指针密度——把 map[string]*BigRecord 拆成 map[string]int32 + []BigRecord,把大 blob 从指针密集结构里剥离,让大块数据落在 noscan span 上,标记时根本不扫它们。代价:多一次间接寻址、代码复杂度上升。④ 旋钮——适当调大 GOGC 给标记更多余量(债务系数随逼近 goal 非线性上升,离 goal 越远越轻松);若有 GOMEMLIMIT,检查是否设得太紧(逼近上限会让 assist 更激进,程序会”看起来还活着但几乎不干活”)。代价:内存占用上升。⑤ 减少 goroutine 数量——3000 个 goroutine 的根扫描本身是实打实的开销,若存在 goroutine 泄漏则是双重伤害(既占内存又拉长根扫描)。代价:可能需要重新设计并发模型。