Files
autumn-recruitment/00.Go/runtime/三色标记GC原理.md
T

242 lines
8.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags: [go/lang, gc, tri-color-marking, write-barrier, pacer]
create time: 2026-08-08 19:00
update time: 2026-08-08 19:00
---
# 三色标记 GC 原理
## 概述
Go 的垃圾回收器(GC)是 Go 语言高性能的核心支柱之一。从 v1.5 引入的三色标记清除算法到 v1.9 完善的混合写屏障,每一次演进都在压缩 STW(Stop-The-World)时间、提升并发效率。理解 GC 的工作原理,不仅能在面试中从容应对底层细节问题,更能指导写出低 GC 压力的代码。
> [!NOTE] 为什么需要三色标记?
> 传统标记阶段必须暂停所有 goroutine(STW),如果标记期间对象引用关系发生变化(如 A 指向 B,然后 A 不再指向 B 但其他白色对象还指向 B),可能导致被错误回收。三色标记用颜色抽象解决了这个问题。
## 核心原理
### 三种颜色的含义
```mermaid
graph LR
W["白色 White<br/>未扫描"] -->|"被扫描后"| G["灰色 Gray<br/>已发现但未扫描子节点"]
G -->|"扫描完所有子节点后"| B["黑色 Black<br/>完全扫描过"]
style W fill:#f5f5f5,stroke:#9e9e9e,color:#000
style G fill:#bbdefb,stroke:#1565c0,color:#000
style B fill:#a5d6a7,stroke:#2e7d32,color:#000
```
| 颜色 | 含义 | GC 行为 |
|------|------|--------|
| **白色** | 未被标记访问过的对象 | 可能被回收 |
| **灰色** | 已被发现,但其引用的对象尚未扫描 | 加入扫描队列 |
| **黑色** | 已完成扫描且其引用的对象也都是黑色或灰色 | 不可回收,不会再次出现白色引用 |
关键不变性:**黑色对象永远不会直接引用白色对象**。这保证了任何可达对象都被正确标记。
### 扫描流程
```mermaid
flowchart TD
A["STW: 标记根集<br/>所有全局变量/栈上的指针"] --> B["根对象标为灰色<br/>放入扫描队列"]
B --> C{"扫描队列空?"}
C -->|否| D["取出一个灰色对象<br/>扫描它的所有字段"]
D --> E["发现的白色子节点<br/>→ 标为灰色并入队"]
E --> F["当前对象标为黑色"]
F --> C
C -->|是| G["触发下一轮 STW<br/>准备进入清除阶段"]
style A fill:#ffcdd2
style D fill:#fff3e0
style G fill:#e8f5e9
```
每个被取出的灰色对象会被完整扫描:遍历其内存中的所有指针字段,对每个指针对象执行:
```go
if obj.isWhite() {
obj.color = gray // 从未发现变为已发现
addScanQueue(obj) // 加入待扫描队列
} else if obj.isGray() {
whiteObjectDelta++ // 记录灰色对象的数量变化
}
// 自身标记为 black
obj.color = black
```
> [!TIP] 面试常考点
> Go GC 的扫描不是传统的标记阶段。标记和清除是交错进行的——当用户代码运行的同时,后台也有 goroutine 在执行 GC 扫描工作。这被称为"并发标记"。
### 混合写屏障(Hybrid Write Barrier)
这是 Go GC 最核心的创新之一。写屏障确保在并发标记期间,即使对象的引用关系被修改,也不会导致可达对象被误回收。
Go 在 v1.8 引入白色前置写屏障,v1.9 完善为混合写屏障(白色后置 + 白色前置):
```go
// 混合写屏障伪代码
func storePointer(p *unsafe.Pointer, val unsafe.Pointer) {
old := *p
new := val
// 白色前置屏障
if isWhite(old) {
markWhiteObject(old) // 将旧值重新标灰
}
// 真正的赋值
*p = new
// 白色后置屏障
if isWhite(new) && isGray(gcWorker) {
markGrayObject(new) // 将新值标灰
}
}
```
两种屏障的组合确保了即使在并发修改的情况下:
- **白色前置**:保证被取消引用的旧白色对象不会被漏扫
- **白色后置**:保证新引用的白色对象会被纳入扫描
```mermaid
sequenceDiagram
participant M as "用户代码 (Mutator)"
participant WB as "写屏障"
participant GC as "GC 扫描线程"
M->>WB: ptr = newObject(white)
WB->>GC: 白色后置: new → gray
GC->>GC: 从队列取出 new 扫描
M->>WB: ptr = anotherObject(white)
Note over WB: 旧值被移除
WB->>GC: 白色前置: old → gray
GC->>GC: 从队列取出 old 扫描
GC->>GC: 继续扫描...
```
> [!WARNING] 为什么叫"混合"写屏障?
> 纯前置屏障只需要在赋值前检查旧值,简单但无法处理新值的情况;纯后置屏障则相反。混合方案结合了两者的优点,同时保证不遗漏任何应该被扫描的对象。代价是每个指针存储操作多了两次颜色检查。
### STW 阶段
整个 GC 周期包含若干 STW 阶段,Go 的目标是让它们尽可能短:
| 阶段 | Go 版本 | 作用 | STW 时长目标 |
|------|---------|------|-------------|
| 初始 STW | v1.5+ | 标记根集为灰色,启动后台扫描器 | < 1ms |
| 终止 STW (v1.8 之前) | v1.5-v1.8 | 配合单纯写屏障 | < 1ms |
| 终止 STW (混合屏障后) | v1.9+ | 完成最后一批对象的标记 | ~微秒级 |
| 清除 STW | v1.5+ | 重置 freed 对象的白色状态 | ~几毫秒 |
Go 1.8 之后,除了初始化时的根集扫描和结束时的少量清理外,大部分 GC 工作与用户代码并行运行。
### Pacer 算法与触发阈值
Go 使用一个称为 Pacer 的反馈控制系统来决定何时触发 GC:
```
Pacer(t) = GC_time / Wall_time - target_fraction
触发条件:
heap_live >= last_heap_live * 1.44^(Pacer调整系数)
```
核心参数:
- **GCCyCleTargetRatio**: 默认 0.1(即 10%)。控制每轮 GC 占 CPU 时间的比例上限
- **HeapGoal**: `heap_live` 增长 44% 时触发一轮 GC(e^0.37 ≈ 1.44)
这意味着 GC 触发频率自适应:堆越大、分配越快,GC 越频繁。Go 通过指数增长的阈值来平滑 GC 触发的节奏。
```mermaid
flowchart LR
A["堆内存增长"] -->|"达到 threshold × 1.44"| B["触发 GC"]
B --> C["STW: 标记根集"]
C --> D["并发标记: 后台 goroutine 扫描"]
D --> E["并发清除: 释放白色对象"]
E --> F["更新 heap_live"]
F --> A
style B fill:#ffebee
style D fill:#e8f5e9
```
### Go GC 演进历史
| 版本 | 关键改进 | 突破点 |
|------|---------|--------|
| v1.5 | 引入三色标记 + 并发标记 | 首次实现用户代码与 GC 并行 |
| v1.8 | 引入白色前置写屏障 | 支持更灵活的并发策略 |
| v1.9 | 混合写屏障 + 改进 Pacer | STW 时间大幅缩减至可忽略级别 |
> [!TIP] 面试加分项
> 提到 Go 采用增量式 GC(incremental GC)而非全量 STW 标记,并且通过写屏障处理了并发场景下的引用一致性问题是证明你对 GC 有深入理解的标志。
## 代码示例
### 减少 GC 压力的预分配技巧
```go
// ❌ GC 压力大:每次循环都创建新的临时切片
func processItems(items []Item) []Result {
var results []Result
for _, item := range items {
results = append(results, transform(item)) // 多次扩容 + GC
}
return results
}
// ✅ GC 友好:一次性预分配
func processItemsOptimized(items []Item) []Result {
results := make([]Result, len(items)) // 零额外分配
for i, item := range items {
results[i] = transform(item)
}
return results
}
```
预分配切片的价值在于:不仅避免了多次扩容的拷贝开销,更重要的是减少了 GC 需要跟踪的中间对象数量。在高吞吐场景中,这点优化可能带来显著的性能差异。
### 主动触发 GC(通常不需要)
```go
runtime.GC() // 立即触发一次完整的 GC
stats := runtime.MemStats{}
runtime.ReadMemStats(&stats)
fmt.Printf("heap alloc: %d bytes\n", stats.HeapAlloc)
```
大多数应用不应该主动调用 `runtime.GC()`——Go 的 Pacer 已经做得足够好。手动触发只适合 benchmark 或调试场景。
## 实践场景
### 面试高频问题
**Q: 什么时候会触发 GC?**
当 `heap_live` 相较于上一轮 GC 结束时增长了约 44% 时触发。这个比例由 Pacer 动态调整。
**Q: 如何降低 GC 压力?**
- 预分配已知大小的容器(map/slice),避免扩容
- 复用对象(sync.Pool)
- 减少短生命周期对象的创建(逃逸到堆上会增加 GC 负担)
- 使用指针数组而非接口类型(消除间接引用)
**Q: STW 和 CTW 的区别?**
STW (Stop-The-World) 是暂停所有用户 goroutine。CTW (Concurrent The-World) 是 Go 的特色——大部分 GC 工作与用户代码并发运行。Go 1.8 之后几乎没有真正意义上的 CTW。
### 实战建议
- **关注 Pprof heap profile**:定期审查哪些对象占用了最多的堆空间并存活时间过长
- **使用 `GOGC=100` 调低 GC 频率**:高延迟敏感场景下可以让堆增长更多再触发 GC
- **使用 `GOGC=10` 提高 GC 频率**:内存受限环境下减少峰值占用
## 扩展阅读
- [[Pprof 性能分析指南]] — pprof 中的 heap profile 可直接观察 GC 产生的对象分布
- [[Goroutine 调度模型]] — GC 扫描工作由独立的 GC worker goroutine 执行