Sirius
Sirius

目录

Golang 垃圾回收

本文对 Go 语言垃圾回收(GC)的整体流程进行一次清晰的总结。

Go 的 GC 设计核心目标是实现极低的延迟,尤其是要最大限度地缩短“Stop-The-World”(STW,即程序完全暂停)的时间。为了达到这个目标,它采用的是并发三色标记清除(Concurrent Tri-color Mark-and-Sweep)算法,和混合写屏障(Hybrid Write Barrier)

一个完整的 GC 周期可以分为以下几个主要阶段:

在进入 GC 周期前,首先需要被触发。主要有三种触发方式:

  1. 堆内存分配阈值:这是最主要的触发方式。当程序新分配的内存达到一个阈值时,GC 会被触发。这个阈值由环境变量 GOGC 控制(默认为100),计算公式为:下次触发GC的堆大小 = 上次GC后的存活堆大小 * (1 + GOGC/100)。例如,如果 GOGC=100,则当堆大小翻倍时触发 GC。

  2. 定时触发:Go 运行时会保证即使没有达到内存分配阈值,最长也会在约 2 分钟内强制触发一次 GC,以防止程序长时间不回收内存。

  3. 手动触发:通过调用 runtime.GC() 可以手动强制执行一次 GC 周期,这通常只在测试或特殊场景下使用。


一旦被触发,GC 周期将按以下四个阶段进行。其中,最耗时的标记和清理阶段都是与用户代码并发执行的。

这是 GC 周期的第一个、也是一个非常短暂的 Stop-The-World (STW) 暂停

  • 目的:为即将到来的并发标记阶段做准备。

  • 核心操作

    1. 开启混合写屏障 (Enable Write Barrier):这是此阶段最重要的任务。写屏障是一种机制,它会“监视”在并发标记期间用户 Goroutine 对堆上指针的修改操作。一旦开启,任何“黑”对象指向“白”对象的操作都会被写屏障拦截并妥善处理,从而保证不错过任何存活的对象。(屏障机制类似于一个回调保护机制,指的是在完成某个特定动作前,会先完成屏障成设置的内容.)

    2. 暂停所有正在运行的用户 Goroutine。

    3. 准备根对象的扫描(如全局变量、每个 Goroutine 栈上的指针等)。

这个 STW 暂停通常非常快,在微秒级别。完成后,用户 Goroutine 会被恢复。

这是 GC 中最耗时的阶段,但它是与用户 Goroutine 并发执行的,这也是 Go GC 低延迟的关键。

  • 目的:找出所有存活的对象。

  • 核心操作

    1. GC 的后台工作 Goroutine 开始从根对象(全局变量、栈)出发,遍历整个对象图。

    2. 它使用三色标记法

      • 白色 (White):对象是潜在的垃圾。初始时所有对象都是白色。

      • 灰色 (Gray):对象是存活的,但其引用的其他对象尚未被扫描。

      • 黑色 (Black):对象是存活的,并且它引用的所有对象都已被完全扫描。

    3. GC worker 不断从灰色对象队列中取出对象,扫描它引用的指针,将被引用的白色对象标记为灰色,然后将自身标记为黑色。

    4. 写屏障在此阶段持续工作:如果用户代码在此期间修改了对象引用(例如 blackObj.field = whiteObj),写屏障会确保这个 whiteObj 被标记为灰色,防止它被漏标。需要注意的是,基于性能考量,混合写屏障仅仅适用于堆上

    5. GC 辅助 (GC Assist):为了确保标记工作能及时完成,当用户 Goroutine 分配新内存时,可能会被要求“帮助” GC 完成一部分标记工作。

    6. 其他:并发标记阶段新增的栈上对象会直接标黑

这是第二个,也是最后一个非常短暂的 Stop-The-World (STW) 暂停

  • 目的:完成所有标记工作,确保没有遗漏,并为清理阶段做准备。

  • 核心操作

    1. 再次暂停所有用户 Goroutine。

    2. 关闭写屏障

    3. 对所有 Goroutine 的栈进行快速的重新扫描:这是处理“栈上指针写入无写屏障”问题的关键安全网。此过程会检查在并发标记期间,栈上是否出现了新的指向白色对象的指针,如果有,则将对应的白色对象标记为灰色。

    4. 排空所有剩余的灰色对象队列,直到没有灰色对象为止。此时,所有可达对象都是黑色的。

得益于混合写屏障,这个 STW 阶段也极快,通常在亚毫秒级。完成后,用户 Goroutine 会被恢复。

此阶段也是与用户 Goroutine 并发执行的

  • 目的:回收所有未被标记(即仍然是白色)的对象所占用的内存。

  • 核心操作

    1. 标记工作完成后,内存中的对象要么是黑色(存活),要么是白色(垃圾)。

    2. GC worker 开始遍历整个堆,将所有白色对象的内存空间回收,并归还给内存分配器,以便后续的内存分配请求使用。

    3. 用户 Goroutine 在分配新内存时,也可能会触发一小部分的清理工作。

  • 红色框代表 Stop-The-World (STW) 阶段,程序会短暂暂停。
  • 绿色框代表并发阶段,GC 工作与用户代码同时运行。
graph TD
    A[GC 触发] --> B[阶段一: 标记准备 STW]
    B --> |开启写屏障, 准备根| C[阶段二: 并发标记]
    C --> |扫描对象图, 写屏障活跃| D[阶段三: 标记终止 STW]
    D --> |关闭写屏障, 重扫栈| E[阶段四: 并发清理]
    E --> |回收白色对象| F[GC 周期结束]

    classDef stw fill:#ff9999,stroke:#333,stroke-width:2px
    classDef concurrent fill:#99ff99,stroke:#333,stroke-width:2px
    
    class B,D stw
    class C,E concurrent
graph TD
    A[栈上引用修改] --> B{是否在标记阶段?}
    B --> |否| C[正常赋值操作]
    B --> |是| D{引用类型?}
    D --> |栈到栈| E[无写屏障保护]
    D --> |栈到堆| F[可能需要记录]
    D --> |堆到堆| G[写屏障保护]
    
    E --> H[标记终止时重扫栈]
    F --> H
    G --> I[立即标记灰色]
    
    H --> J[发现新引用]
    I --> K[对象安全]
    J --> K

    classDef dangerous fill:#ffcccc,stroke:#ff0000,stroke-width:2px
    classDef safe fill:#ccffcc,stroke:#00ff00,stroke-width:2px
    
    class E,F dangerous
    class G,I,K safe
stateDiagram-v2
    [*] --> 白色: 对象创建
    白色 --> 灰色: 被根对象引用/写屏障触发
    灰色 --> 黑色: 扫描完成
    黑色 --> [*]: GC清理(如果白色)/保留(如果黑色)
    
    白色 --> [*]: 被回收
    
    note right of 灰色: 待扫描队列中
    note right of 黑色: 确认存活
    note right of 白色: 可能被回收

考虑这样一个问题

背景: 存在栈上对象A,白色(未扫描,这是因为对应的栈还未开始扫描); 存在栈上对象B,黑色(已完成扫描,说明对应的栈均已完成扫描); 存在堆上对象C,被栈上对象A引用,白色(未被扫描) 并且A.ref=C

• moment1:B建立对C的引用; • moment2:A删除对C的引用.

分析Golang GC, 次情景下如何保证不漏标,需要注意的是混合写屏障不适用栈对象

sequenceDiagram
    participant A as 栈对象A(白)
    participant B as 栈对象B(黑)
    participant C as 堆对象C(白)
    participant GC as GC扫描器
    
    Note over A,C: 初始状态: A -> C
    
    B->>C: moment1: 建立引用 B -> C
    Note right of B: 栈操作,无写屏障
    
    A->>A: moment2: 删除引用 A -> nil
    Note right of A: 栈操作,无写屏障
    
    Note over B,C: 现在只有 B(黑) -> C(白)
    Note over B,C: B已扫描完,C仍为白色
    
    GC->>GC: 标记终止阶段
    GC->>B: 重新扫描B所在栈
    GC->>C: 发现 B -> C 引用
    C->>C: 标记为灰色/黑色
    
    Note over C: C被保护,不会被回收

GOGC 是控制 GC 频率最重要的环境变量。它的默认值是 100。 它的含义是:当新分配的内存达到上次 GC 结束后存活内存的 GOGC% 时,触发下一次 GC

  • GOGC = 100 (默认): 意味着当堆内存增长到上一次的两倍时,触发 GC。这是一个在 CPU 开销和内存占用之间的平衡选择。
  • GOGC < 100: 会让 GC 更频繁地触发。这会增加 CPU 的消耗,但能更有效地控制内存占用,适合内存敏感的应用。
  • GOGC > 100: 会让 GC 不那么频繁。这会减少 CPU 的消耗,但会允许程序占用更多内存。
  • GOGC = off: 关闭 GC。极不推荐,除非是短时运行、内存占用明确的特殊程序。

你也可以在代码中通过 debug.SetGCPercent() 来动态设置。

从 Go 1.19 开始,引入了 GOMEMLIMIT 环境变量。它提供了一个软内存限制。当程序的总内存使用接近这个限制时,GC 会被更积极地触发,以尝试将内存维持在这个限制之下。这对于在有严格内存限制的容器环境中运行 Go 程序非常有用。

  1. 内存分配优化:最高效的 GC 就是不发生 GC。通过优化代码,减少不必要的临时对象分配,是性能调优的第一步。
    • 可以使用 sync.Pool 来复用对象。
    • 尽量预分配内存,减少动态扩容
  2. 避免指针:减少指针数量可降低GC扫描成本,GC 只关心指针指向的对象。如果你的数据结构中不包含指针(或 slice, map, chan 等),GC 的扫描工作量就会大大减少。
  3. 合理设置 GOGC:通过性能分析(pprof),观察应用的 GC 行为和内存增长情况,调整 GOGC 以在 CPU 和内存之间找到最佳平衡点。