# Go GC 机制


Go 的 GC 经历了三个阶段：标记清除（全程 STW）→ 三色标记 + 插入/删除写屏障（并发标记）→ 三色标记 + 混合写屏障。下面按演进顺序讲，最后是现在的触发条件、完整流程和调参。

## 一段话背诵版

Go 的 GC 核心是并发标记清除。标记清除全程停世界，停顿随堆大小增长，所以改成三色标记做并发：白、灰、黑分别表示没扫到、在队列里等扫描、扫完，从根出发做 BFS，灰队列空了，仍是白色的就是垃圾。并发标记的问题在于程序边跑边改引用，黑色对象可能被写入指向白色对象的指针，白色就永远扫不到，所以堆指针写入要过写屏障：插入屏障把新值标灰，删除屏障把被覆盖的旧值标灰，宁可错救也不漏标，代价是被误救的浮动垃圾下一轮才收。混合写屏障把两者合并：覆盖堆引用时无条件标灰旧值，写入者的栈还没扫描时再标灰新值，栈就再也不用重扫。一轮 GC 只有标记准备和标记终止两次短 STW，主体是并发标记，清除惰性分摊。触发条件有四种：堆涨到 GOGC（默认 100，即堆翻倍）的阈值、sysmon 每 2 分钟兜底、runtime.GC() 手动触发、GOMEMLIMIT 软限制。调参先看 GODEBUG=gctrace=1，内存充足调大 GOGC，容器里设 GOMEMLIMIT。

## 阶段 1：标记清除（Go 1.3 及以前）

标记和清除都停世界（stop the world，暂停所有线程）。标记时程序必须停：程序一边跑一边改引用，标记结果不可信，可能把活对象当垃圾收掉。

停顿和堆大小、对象数量挂钩，堆越大停得越久。延迟敏感的程序扛不住，这是后面所有演进的根本原因。

## 阶段 2：三色标记 + 写屏障（Go 1.5 起）

### 三色标记

标记阶段把对象分成三种颜色：

- 白色：还没被扫描到，可能是垃圾。
- 灰色：被发现了，但指向它的引用还没扫描完，队列里等着扫。
- 黑色：扫描完了，它的所有引用都已处理，确定存活。

流程：从根对象（全局变量、栈、寄存器）出发，标记为灰色；然后不停从灰色队列取对象，把它的引用对象标灰，自己变黑；灰色队列空了，所有仍是白色的对象就是垃圾，回收掉。这个过程有点像图的广度优先遍历（BFS）：灰色队列就是 BFS 的队列，黑色是已出队的节点。

```mermaid
flowchart LR
    subgraph 根[根对象]
        R[全局变量 / 栈 / 寄存器]
    end
    subgraph 堆[堆]
        A["A（黑色）"]
        B["B（灰色）"]
        C["C（白色）"]
        D["D（白色）"]
    end
    R --> A
    A --> B
    A -. 未扫描 .-> C
    D -. 无引用，垃圾 .-> X[回收]
```

三色标记不是标记清除的替代品，是它的并发化版本：把「这个对象处理到哪一步」用颜色记下来，标记就能增量推进，扫完的变黑，待扫的保持灰；程序照常跑。清除阶段不变。

### 并发标记的问题：黑对象引用白对象

标记是并发的，程序一边跑一边标。某个黑色对象（已扫描完，确定存活）可能被程序写入一个新指针，指向还没扫到的白色对象。如果不处理，这个白色对象永远标不到，会被当成垃圾回收，程序之后再访问它就崩了。

解决办法是写屏障（write barrier）：GC 期间，每次堆指针写入都拦截一下，把相关对象标灰。

### 强三色不变式和弱三色不变式

并发标记要防两种破坏：

1. 黑色对象直接引用白色对象。白色挂在黑色下面，没有灰色对象可达它，永远不会被扫到。
2. 灰色对象到白色对象的可达关系被破坏。灰色对象对白色的引用被覆盖掉，白色失去被发现的路径。

对应两个不变式：

- 强三色不变式：禁止黑色对象引用白色对象。破坏条件 1。
- 弱三色不变式：允许黑色对象引用白色对象，但前提是那个白色对象仍然被某个灰色对象可达。破坏条件 2。

白色对象怎么知道上游有没有黑色对象？不用它知道。不变式不是靠对象自查维持的，是写屏障在每次指针写入时保证的：黑色对象要拿到白色引用，屏障当场把白色标灰，「黑→白」还没成立就被拆掉了。

### 插入屏障（Dijkstra）

写指针时，把新指向的对象标灰。防「黑指向白」漏标。比如程序把新指针写进黑色对象，新目标还是白色的话，当场标灰，进灰色队列：

```mermaid
flowchart LR
    B["B（黑色对象）"] -->|"写入新指针"| W["W（白色对象）"]
    W -->|"插入屏障：标灰"| GQ["灰色队列"]
```

Go 1.5-1.7 实际用的就是插入屏障，对堆指针写入无条件生效（`shade(ptr)` 后写槽位）。

为什么叫 Dijkstra？写屏障最早出现在 Dijkstra 1978 年的论文《On-the-fly garbage collection: an exercise in cooperation》——三色标记本身也是这篇论文提出的。屏障的规则以作者命名，就叫 Dijkstra 屏障。

### 删除屏障（Yuasa）

另一种经典屏障。写指针时，把被覆盖的旧引用指向的对象标灰。防「灰对象丢掉对白的引用后，白不再被扫描」：

```mermaid
flowchart LR
    G["G（灰色对象）"] -->|"覆盖对 W 的引用"| NEW["新对象"]
    G -.->|"旧引用指向"| W["W"]
    W -->|"删除屏障：标灰"| GQ["灰色队列"]
```

代价：屏障可能把已经没人引用的对象标灰救活，这一轮收不掉，下一轮才回收——浮动垃圾（floating garbage）。宁可错救，不可漏标。注意没有「对象被标记为要删除、下一轮再删」这回事：白色对象标记结束直接回收，会拖到下一轮的是被屏障误救的浮动垃圾。

为什么叫 Yuasa？这个屏障出自 Taiichi Yuasa 1990 年的论文《Real-time garbage collection on general-purpose machines》，同样以作者命名。Go 1.8 之前没用它，1.8 的混合屏障才把它的思想引进来。

### 栈为什么扫两遍

写屏障只插桩堆指针写入，栈上的指针写入不经过屏障，纯粹为了性能——栈写入太频繁，每次都拦扛不住。

但标记是并发的，程序照常跑。这就引出插入屏障时代的问题：栈不设防，怎么保证它不藏白色对象？

Go 的答案是扫两遍：

- 第一遍（标记启动时）：把所有 goroutine 的栈扫一遍，栈引用全部标灰。
- 第二遍（标记终止 STW）：重扫所有栈，把标记期间新出现的引用补标。

为什么扫过一遍的栈还要重扫？栈扫完会变黑，但那是「冻结状态」的黑——goroutine 一旦继续执行，往栈上写新的指针，栈的黑色就不可信了。没有栈写屏障记录这些写入，只能保守处理：扫描过的栈，只要它的 goroutine 又跑过，就变回灰色，等终止时重扫。重扫要遍历所有 goroutine 的栈，STW 时长和 goroutine 数量挂钩，几十到几百毫秒都可能——这是 Go 1.5-1.7 停顿的主要来源，也是阶段 3 要解决的问题。

```mermaid
flowchart LR
    subgraph t1[标记启动]
        A1[扫一遍所有栈<br>栈引用标灰] --> A2[栈暂时变黑]
    end
    subgraph t2[标记期间]
        B1[goroutine 继续执行<br>栈上写入新引用] --> B2[栈变回灰色<br>无人记录新引用]
    end
    subgraph t3[标记终止 STW]
        C1[重扫所有灰栈<br>补标新引用]
    end
    t1 --> t2 --> t3
```

那能不能只在终止时扫一遍，省掉第一遍？不能。第一遍扫完，从栈出发的整片对象图进入灰色队列，在并发期被消化掉；省掉它，标记期间栈根缺席，所有栈可达对象都等到终止 STW 扫栈后才被发现，标记工作全部堆进暂停里，停顿不可控。终止的重扫是正确性兜底（栈只要跑过就变灰，终止必扫），但它兜不住性能——所以两遍都要。

注意标记进度在两次 STW 之间是延续的：灰队列、已标黑的对象都保留着，STW 只是暂停程序，世界恢复后从灰队列继续。第二遍扫栈补出的新引用并入已有的标记流程，不是从头重新标记。

## 阶段 3：三色标记 + 混合写屏障（Go 1.8+）

### 动机：去掉栈重扫

阶段 2 的栈重扫让 STW 随 goroutine 数量增长。Go 1.8 把插入屏障和删除屏障合并成混合写屏障（runtime/mbarrier.go 的伪代码）：

```
writePointer(slot, ptr):
    shade(*slot)          # 旧值标灰，无条件
    if 写入者的栈还没被扫描:
        shade(ptr)        # 新值标灰
    *slot = ptr
```

- 删除部分无条件：任何覆盖堆引用的写入都把旧对象标灰，防止程序把堆里唯一指针挪到栈上藏起来——想藏，先断开，断开的瞬间就被标灰。
- 插入部分有条件：写入者的栈还是灰色（没扫过）才需要标灰新值。

为什么栈还没扫时必须标灰新值？写入把新值装进了一个黑色对象——黑对象已经扫描完，之后不会再处理它的引用，这条边一旦建立就只能靠写屏障把关。栈虽然是灰色的、迟早要扫，但扫栈扫的是「扫描那一刻」栈上的引用：程序写完这个指针，转头就把栈上的副本覆盖掉（变量离开作用域、复用），扫栈时已经找不到它了。等扫栈再标灰，新值可能已经变成「黑对象里的白对象，没有任何灰色保护」——漏标。所以必须在写入的瞬间把新值标灰，送进灰色队列，不能等扫栈：

```go
// 标记进行中，本 goroutine 的栈还没被扫描
var root *Obj // 黑色对象，已扫描完

func f(w *Obj) { // w 可能指向白色对象：栈还没扫过，引用未处理
    root.field = w // 写入瞬间：把 w 标灰，进灰色队列
    // 之后 w 离开作用域，栈上不再有它的引用
    // 如果写入时没标灰，w 就只剩 root.field 一条引用，而 root 不会再被扫描 → 漏标
}
```

那栈变黑后为什么可以省？关键看新值从哪来。

程序往堆里写指针，这个指针值不可能凭空生成，只能从已存在的地方读出来，最常见的是读堆对象的字段：

```go
w := obj.field // w 的值从 obj.field 读出来
```

obj 只有两种状态：

- **obj 已经扫描（黑色）**：扫描 obj 时，它所有字段指向的对象都被标灰了。w 指向的对象必然已经标灰，写入无需处理。
- **obj 还没扫描（灰色）**：obj 还在灰色队列里排队，扫描它会处理它的字段。w 指向的对象可能暂时还是白色，但它被队列里的 obj 引用着——等 obj 被扫时自然标灰，不会漏。

```mermaid
flowchart LR
    subgraph caseA[情况 A：obj 已扫描]
        A1["obj（黑）"] -->|"字段指向"| A2["目标（已标灰）"]
        A3["w = obj.field"] -.->|"写进堆"| A2
    end
    subgraph caseB[情况 B：obj 未扫描]
        B1["obj（灰，队列中）"] -->|"字段指向"| B2["目标（白）"]
        B3["w = obj.field"] -.->|"写进堆"| B2
        B2 -.->|"等 obj 被扫时标灰"| B4["不会漏"]
    end
```

所以从黑栈写进堆的新值，要么已经标灰，要么还挂在某个灰对象上等扫描——不可能是「彻底没人管的白色」。插入部分可以省掉。

### 效果：栈只扫一遍

标记开始不用再预扫栈，重扫也没了。栈在并发标记阶段逐个挂起 goroutine 扫描（scanstack），扫完变黑；标记终止的短 STW 只补扫还没扫完的栈。

并发标记期间，扫栈和扫堆是交错进行的，没有「先扫完栈再扫堆」的顺序：灰色队列里既有「扫描堆对象」的工作项，也有「扫描 goroutine 栈」的工作项，GC worker 取出哪个做哪个：

```mermaid
flowchart LR
    S1["扫栈 A"] --> S2["扫堆对象 X"] --> S3["扫栈 B"] --> S4["扫堆对象 Y"] --> S5["扫栈 C"]
```

扫过的栈和对象都变黑，不需要重扫——栈扫完变黑后，goroutine 继续执行产生的写入由混合屏障兜底（见上一节），这就是 Go 1.8 能把栈扫描彻底摊进并发期的原因。

GC 期间新分配的对象直接标黑（allocate black）。它们就算马上变成垃圾，本轮也不回收，下一轮才清掉——也算浮动垃圾。

两个场景：

- 程序往黑色对象里写一个刚拿到的指针，指向白色对象：写入者栈还没扫，插入部分把白色对象标灰，它进灰色队列，不会漏。

```mermaid
flowchart LR
    S["栈（灰色，未扫描）"] -.->|"持有指针"| W["W（白色对象）"]
    B["B（黑色对象）"] -->|"写入 B.slot = W"| W
    W -.->|"插入部分触发：标灰"| GQ["灰色队列"]
```

- 程序覆盖一个堆引用，旧对象被无条件标灰：如果它还被别处引用，安全；如果已经没人引用，下一轮才被回收。这个动作和写入者栈的颜色无关，删除部分总是触发。

```mermaid
flowchart LR
    B["B（黑色对象）"] -->|"覆盖 B.slot，指向新对象"| NEW["新对象"]
    B -.->|"旧引用被断开"| W["W（白色对象）"]
    W -->|"删除部分：无条件标灰"| GQ["灰色队列"]
```

## 触发条件

GC 由四种条件触发（`gcTrigger` 的四种方式）：

- **gcTriggerHeap（堆内存阈值）**：最主要的方式。堆上活跃对象增长到一定量就触发。阈值由 GOGC 控制，默认 100，意思是「上一次 GC 后堆涨了 100% 再触发」。比如上次 GC 后存活 4MB，那堆涨到 8MB 时触发下一次。GOGC 是环境变量，也可以在代码里用 `debug.SetGCPercent` 改。
- **gcTriggerTime（定时）**：`forcegcperiod` 是 2 分钟，由 sysmon 线程检查，超过 2 分钟没 GC 过就强制触发一次。纯空闲程序也会周期 GC。
- **gcTriggerCycle（手动）**：代码里调 `runtime.GC()` 触发。
- **GOMEMLIMIT（内存软限制）**：Go 1.19+，设置后堆接近这个上限就提高 GC 频率，防止超限。软限制：可能短暂超过，但会尽力压住。默认不限制。

## 完整流程

一次 GC 分四个阶段（`gcStart` → `gcMarkDone` → `gcSweep`）：

1. **Sweep（清理上一轮的残留）**：上一轮 GC 标记完的垃圾还没清完，这一轮开始前先清掉。清除是惰性的，`sweepone` 一次清一个 span（内存块），分摊到平时，不集中卡顿。
2. **Mark Setup（标记准备，短 STW）**：`stopTheWorld`，所有 M 停到安全点（safe point，代码中可安全检查 GC 状态的位置），P 转 Pgcstop，gcphase 从 \_GCoff 切到 \_GCmark，开启写屏障。这个 STW 很短（微秒到亚毫秒级）。
3. **Marking（并发标记，主体）**：世界恢复运行，标记工作交给后台 GC worker（`gcBgMarkWorker`，占用 P 的资源跑），程序继续执行。这期间写屏障拦截所有堆指针写入，把相关对象标灰。标记完（灰色队列空）进入下一步。
4. **Mark Termination（标记收尾，短 STW）**：再停一次世界，把还没扫完的栈扫掉，gcphase 切到 \_GCmarktermination，最后关写屏障。然后世界恢复，进入下一轮的 Sweep 阶段。

整体上每轮 GC 只有两次很短的 STW，大头是并发标记，这就是 Go 1.5+ 以来 GC 停顿能压到毫秒级的原因。

## 调参

- **GOGC**：默认 100。调小：GC 更频繁、内存占用更低，代价是 CPU 开销更大；调大则反过来。内存充足、追求吞吐的场景适合调大。
- **GOMEMLIMIT**：Go 1.19+，`GOMEMLIMIT=500MiB` 或 `debug.SetMemoryLimit`。给容器设置软内存上限，超过时提高 GC 频率压内存。
- **GODEBUG=gctrace=1**：打印每次 GC 的耗时、STW 时长、内存占用，观察 GC 行为用这个。

排查时先看 gctrace 输出，再决定调 GOGC 还是 GOMEMLIMIT，不要盲调。

## 面试题

### Go GC 的实现经历了哪些主要阶段？

Go 1.3 及以前主要使用全程停世界的标记清除。程序暂停后完成标记和清除，停顿会随堆和对象数量增长。

Go 1.5 引入三色标记和并发标记，堆指针写入由插入屏障保护。Go 1.8 改用混合写屏障，把栈扫描放进并发阶段，每个栈只需扫描一次，标记终止时不再重扫全部栈。

### 三色标记的原理是什么？

- 白色对象还没被扫描，标记结束后仍为白色就会被回收。
- 灰色对象已经被发现，但它指向的对象还没处理完。
- 黑色对象已经扫描完成，它指向的对象都已进入标记流程。

GC 从全局变量、栈等根对象出发，把可达对象放入灰色队列。灰色队列清空后，剩余白色对象就是本轮要清理的垃圾。

### 写屏障有什么作用？混合写屏障做了什么？

写屏障（write barrier，指针写入时执行的额外逻辑）用于保护并发标记。没有它，程序可能让已扫描的黑色对象指向未标记的白色对象，GC 会漏标仍然存活的对象。

Go 1.8 之后的混合写屏障包含两部分：覆盖堆指针时，无条件标灰旧值；写入者的栈还没扫描时，再标灰新值。它保证对象不会被藏在未受保护的引用后面，也去掉了标记终止时的全栈重扫。

### 一轮 GC 中哪些阶段会发生 STW？

一轮 GC 有两次 STW（stop the world，暂停所有线程）：

1. 标记准备：切换到标记状态，开启写屏障，准备根扫描和后台标记工作。
2. 标记终止：完成剩余标记工作，切换 GC 阶段，随后关闭写屏障。

并发标记是耗时主体，程序可以继续运行。清除通常惰性执行，不需要把整个清除阶段集中在一次 STW 中。

### Go GC 会在什么情况下触发？

- 堆达到 GC 控制器计算的触发阈值时，触发 `gcTriggerHeap`。
- 距离上次 GC 超过 2 分钟时，后台监控触发 `gcTriggerTime`。`GOGC=off` 时这个定时触发关闭。
- 调用 `runtime.GC()` 时，通过 `gcTriggerCycle` 强制启动新一轮 GC。
- 设置 GOMEMLIMIT 后，运行时可能降低基于 GOGC 的堆目标，让 GC 更早运行。

### GOGC=100 的准确含义是什么？

GOGC 控制下一轮 GC 的堆增长目标，默认值是 100。它表示新分配数据与上轮 GC 后存活数据的比例达到约 100% 时，应完成下一轮 GC；上轮存活堆为 4 MiB 时，目标大致是 8 MiB。

源码计算堆目标时还计入上轮的栈扫描量和全局变量扫描量：

```text
heap goal = live heap + (live heap + stack scan + globals scan) × GOGC / 100
```

GC 控制器会在堆目标之前计算实际启动阈值，给并发标记留出运行空间。因此 8 MiB 是近似完成目标，不是固定的启动点。负值或 `GOGC=off` 会关闭基于 GOGC 的触发，但内存软限制仍可触发 GC。

### Go GC 应该怎样调优？

先用 `GODEBUG=gctrace=1` 或运行时指标观察 GC 频率、堆大小和暂停时间，再定位分配热点。频繁创建短命对象时，优先减少分配和复用缓冲区；不要先改 GOGC 掩盖问题。

内存充足且更看重吞吐时，可以调大 GOGC，减少 GC 次数。容器或内存受限服务应设置 GOMEMLIMIT，并给非 Go 堆内存和系统开销留余量。调小 GOGC 会降低堆占用，但会增加 GC 的 CPU 开销。

### 逃逸分析和 GC 有什么关系？

逃逸分析（escape analysis，编译器判断变量能否安全留在栈上的过程）决定部分对象分配在栈还是堆。栈上对象随函数返回自动失效，不需要 GC 单独回收；逃逸到堆上的对象会增加分配量和扫描工作。

减少不必要的逃逸通常能降低 GC 压力，但不能为了避免逃逸牺牲代码正确性。可以用 `go build -gcflags='-m=2'` 查看编译器的逃逸判断，再结合性能数据决定是否调整代码。


---

> 作者: Nite  
> URL: https://www.nite07.com/zh-cn/posts/go-gc/  

