This repository has been archived on 2026-05-19. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
obsidian/DEV/GO/GC垃圾回收.md
T

7.2 KiB
Raw Blame History

tags, create time
tags create time
go
golang
runtime
GC
garbage-collection
三色标记
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):
  如果黑色对象引用了白色对象,
  那么那个白色对象一定是通过其他灰色对象可达的

核心原理:如果一个白色对象被黑色对象引用,但它没有通过灰色对象可达 → 说明这个白色对象真正不可达,可以安全回收。

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+)

在指针赋值之前检查:

// 伪代码:插入写屏障逻辑
func writePointer(field, newVal) {
    if newVal 是白色 {
        newVal 重新标记为灰色  // 确保白色对象不被误回收
    }
}

// 实际场景
*p = newObj  // 写屏障在这里介入

插入写屏障的局限:无法处理删除引用(将指针设为 nil 或指向其他对象)的场景。

删除写屏障(Go 1.8+)

// 伪代码:删除写屏障逻辑
func writePointer(field, newVal) {
    oldVal = *field  // 旧值
    *field = newVal   // 新值

    // 如果旧值是黑色,且它引用的白色对象可能只通过它可达
    if oldVal 是黑色 and 旧引用对象是白色 {
        旧引用对象重新标记为灰色  // 确保白色对象不被漏掉
    }
}

混合写屏障(Go 1.8+,Go 默认使用)

插入写屏障 + 删除写屏障的组合:

赋值前检查:新值如果是白色 → 标灰(插入屏障)
赋值后检查:旧值如果是黑色 → 旧引用的白色对象标灰(删除屏障)

效果:无论指针如何变化,白色对象都不会被漏标
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 是如何处理的?这与 内存分配与逃逸分析 有什么关系?

Go 的 GC 触发方式

Go 使用**比例触发(Ratelimiting)**而非固定阈值:

触发条件:距上次 GC 结束后,分配的数据量达到上次 GC 时存活数据量的 P%
P = GCPercent(可通过 GCPercent() 查询)

Go 1.8+ 目标:单次 GC 的 STW 时间 < 100μs
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 会有更好的表现?

关联笔记