--- tags: [go, golang, runtime, GC, garbage-collection, 三色标记] create time: 2026-04-24 10:00 --- # GC 垃圾回收 ## 概述 Go 的垃圾回收(GC)是**自动、并发、不可移动**的标记-清除算法。它的核心目标是:**在保证正确性的前提下,尽可能减少 STW(Stop-The-World)时间**。 > **思考**:为什么 Go 选择"不可移动"的 GC 策略?对象不可移动对 GC 的实现和性能分别有什么影响? Go GC 的演进: | Go 版本 | 特性 | |---------|------| | 1.1~1.3 | 半并发 GC(并发标记,STW 终结) | | 1.5+ | 全并发 GC(并发标记 + 并发清理,STW 仅用于启动和切换) | | 1.8+ | 混合写屏障(Hybrid Write Barrier),消除扫描阶段的 STW | | 1.9+ | 更精细的并发控制,STW 时间显著缩短 | ## 三色标记法 三色标记是 Go GC 的核心算法。所有对象被标记为三种颜色之一: | 颜色 | 含义 | |------|------| | **白色** | 未被标记(可能可达,也可能不可达) | | **灰色** | 已被标记,但其引用的对象尚未扫描 | | **黑色** | 已被标记,且其引用的对象已全部扫描完毕 | ### 标记规则 ``` 初始:所有对象 = 白色 根扫描开始: - 根对象(全局变量、栈上的引用)标记为灰色 - 放入灰色队列 标记循环(并发执行): 1. 从灰色队列取出一个灰色对象 → 标记为黑色 2. 扫描黑色对象引用的所有对象: - 如果引用对象是白色 → 标记为灰色,加入灰色队列 - 如果引用对象是灰色或黑色 → 不做操作 终止条件:灰色队列为空 → 标记阶段结束 ``` ### 三色不变式 Go GC 依赖两个不变式来保证正确性: ``` 强不变式(Strong Invariant): 黑色对象不能直接引用白色对象 弱不变式(Weak Invariant): 如果黑色对象引用了白色对象, 那么那个白色对象一定是通过其他灰色对象可达的 ``` > **核心原理**:如果一个白色对象被黑色对象引用,但它没有通过灰色对象可达 → 说明这个白色对象**真正不可达**,可以安全回收。 ```mermaid flowchart LR subgraph Roots["根对象 (灰色)"] R1[灰色对象 R1] R2[灰色对象 R2] end subgraph Objects["所有对象"] B1[黑色 B1] B2[黑色 B2] W1[白色 W1] W2[白色 W2] end R1 -->|"引用"| B1 R1 -->|"引用"| W1 R2 -->|"引用"| B2 B1 -->|"引用"| W2 B2 -->|"引用"| W2 classDef gray fill:#ffa726,color:#fff classDef black fill:#616161,color:#fff classDef white fill:#eeeeee,stroke:#333 class R1,R2 gray class B1,B2 black class W1,W2 white ``` > **思考**:上图中 B2 直接引用了白色 W2,这违反了"黑色对象不能直接引用白色对象"的强不变式。如果此时 GC 终止,W2 就会被误回收——这就是为什么需要写屏障。 ## 写屏障(Write Barrier) 写屏障是并发 GC 的核心机制。在并发标记期间,如果用户代码修改了指针,GC 可能来不及追踪这些变化。写屏障确保: > **任何指向白色对象的指针写入,都会被记录为指向灰色对象** ### 插入写屏障(Go 1.5+) 在指针赋值**之前**检查: ```go // 伪代码:插入写屏障逻辑 func writePointer(field, newVal) { if newVal 是白色 { newVal 重新标记为灰色 // 确保白色对象不被误回收 } } // 实际场景 *p = newObj // 写屏障在这里介入 ``` **插入写屏障的局限**:无法处理删除引用(将指针设为 nil 或指向其他对象)的场景。 ### 删除写屏障(Go 1.8+) ```go // 伪代码:删除写屏障逻辑 func writePointer(field, newVal) { oldVal = *field // 旧值 *field = newVal // 新值 // 如果旧值是黑色,且它引用的白色对象可能只通过它可达 if oldVal 是黑色 and 旧引用对象是白色 { 旧引用对象重新标记为灰色 // 确保白色对象不被漏掉 } } ``` ### 混合写屏障(Go 1.8+,Go 默认使用) **插入写屏障 + 删除写屏障**的组合: ``` 赋值前检查:新值如果是白色 → 标灰(插入屏障) 赋值后检查:旧值如果是黑色 → 旧引用的白色对象标灰(删除屏障) 效果:无论指针如何变化,白色对象都不会被漏标 ``` ```mermaid flowchart TD A["🏁 GC 启动 (STW 极短)"] --> B["📝 并发标记阶段"] B -->|"标记循环\n(灰色队列→黑色)"| C{"灰色队列\n是否为空?"} C -->|"否"| B C -->|"是"| D["🛡️ 写屏障介入\n混合写屏障: 插入+删除"] D --> E["🏁 标记结束 (STW 极短)"] E --> F["🗑️ 并发清理阶段"] F --> G["🏁 GC 完成"] classDef start fill:#e53935,color:#fff classDef process fill:#1e88e5,color:#fff classDef barrier fill:#43a047,color:#fff classDef done fill:#8e24aa,color:#fff class A,E start class B,F process class D barrier class G done ``` ## 标记-清除 vs 标记-复制 Go 采用**标记-清除(Mark-Sweep)**而非标记-复制的原因: | 维度 | 标记-清除 | 标记-复制 | |------|-----------|-----------| | 内存碎片 | 会产生碎片 | 无碎片 | | 内存利用率 | 50%(需要空半区) | 100%(碎片由整理消除) | | GC 速度 | 与存活对象数相关 | 与总对象数相关 | | 对象移动 | 不移动(简化指针) | 需要移动和更新指针 | | Go 的选择 | ✅ | ❌ | > **思考**:既然标记-清除会产生内存碎片,Go 是如何处理的?这与 [内存分配与逃逸分析](./内存分配与逃逸分析.md) 有什么关系? ## Go 的 GC 触发方式 Go 使用**比例触发(Ratelimiting)**而非固定阈值: ``` 触发条件:距上次 GC 结束后,分配的数据量达到上次 GC 时存活数据量的 P% P = GCPercent(可通过 GCPercent() 查询) Go 1.8+ 目标:单次 GC 的 STW 时间 < 100μs ``` ```go import ( "runtime" "runtime/debug" ) func main() { // 查询当前 GC 比例 p := runtime.GCPercent() fmt.Println("GCPercent:", p) // 调整 GC 比例(值越小,GC 越频繁) debug.SetGCPercent(20) // 默认 100 // 强制触发 GC(仅用于测试/调试) runtime.GC() // 查看 GC 统计 var m runtime.MemStats runtime.ReadMemStats(&m) fmt.Printf("GC 次数: %d\n", m.NumGC) fmt.Printf("分配总量: %d bytes\n", m.Alloc) fmt.Printf("系统分配: %d bytes\n", m.Sys) } ``` > **关键理解**:`GCPercent` 不是"存活 X% 时触发 GC",而是增量比例——每次 GC 后,系统会根据当前存活量计算触发阈值。 ## 总结 Go GC 的设计哲学: - **并发优先**:标记和清理几乎全部并发执行,STW 时间极短 - **写屏障保障正确性**:混合写屏障确保并发标记的准确性 - **不可移动**:简化运行时,避免指针更新开销 - **增量回收**:GC 开销分摊到每次对象分配上 > **思考**:为什么 Go 不采用分代 GC(Generational GC)?在什么场景下分代 GC 会有更好的表现? ## 关联笔记 - [[DEV/GO/GMP调度模型]] - [[DEV/GO/内存分配与逃逸分析]] - [[DEV/GO/Goroutine泄漏排查]]