三色标记法与混合写屏障:Go GC 垃圾回收全流程解析

"The best GC is the one you don't notice." ------ Go GC 的演进史,就是一部与 STW (Stop The World) 的抗争史。

在 C/C++ 中,程序员拥有对内存的绝对控制权,但也背负了手动管理的沉重枷锁。Go 选择了一条不同的路:自动化垃圾回收(Garbage Collection)

提到 GC,很多人的第一反应是:"会卡顿"、"STW(世界暂停)"。 确实,在 Go 1.0 时代,GC 极其粗糙,STW 时间甚至高达几百毫秒,简直是线上服务的噩梦。但到了 Go 1.8 之后,STW 通常被控制在 0.5ms 以下

它是怎么做到的? 从简单的标记-清除 ,到复杂的三色标记 ,再到精妙的混合写屏障

1. 为什么需要"三色"?

最原始的垃圾回收算法是 标记-清除 (Mark and Sweep)

  1. 暂停整个程序 (STW):如果不暂停,程序一边运行一边改引用,GC 根本算不准谁是垃圾。

  2. 标记:从根对象(全局变量、栈变量)出发,找出所有引用的对象,打上标记。

  3. 清除:遍历堆,回收没标记的对象。

  4. 恢复程序

这个逻辑很简单,但致命弱点是 STW 时间太长。随着内存变大,暂停时间线性增长。

为了缩短 STW,Go 引入了 并发标记 。即:程序在跑,GC 也在跑 。 为了实现并发,必须引入一种逻辑严密的标记机制,这就是 三色标记法

2. 三色标记法 (Tricolor Marking)

三色标记法将对象逻辑上分为三种颜色:

  • ⬜️ 白色 (White):潜在的垃圾。GC 开始时,所有对象都是白色。

  • 🔘 灰色 (Grey):活跃对象,但它的子对象(引用)还没扫描完。

  • ⚫️ 黑色 (Black):活跃对象,且它的所有子对象都已经扫描完毕。

2.1 正常流程

  1. 初始状态:所有对象都是白色。

  2. 扫描根节点:将从 Root Set(栈、全局变量)直接引用的对象标记为灰色,放入队列。

  3. 遍历灰色队列

    • 取出一个灰色对象,将其引用的白色子对象标记为灰色。

    • 将该对象自身标记为黑色。

  4. 重复步骤 3,直到灰色队列为空。

  5. 清除:此时剩下的白色对象就是不可达的垃圾,进行回收。

3. 并发标记的 Bug:对象丢失

如果 GC 在标记的同时,用户代码(Mutator)也在修改引用关系,会发生什么?

灾难场景:

假设 A(黑) -> B(灰) -> C(白)。

  1. GC 扫描了 A,A 变黑。B 还是灰。

  2. 用户代码执行A.ref = C (黑色 A 指向了白色 C)。

  3. 用户代码执行B.ref = nil (灰色 B 删除了对 C 的引用)。

  4. GC 继续扫描 B:B 没有子对象了,B 变黑。

  5. 扫描结束。

结果 :C 依然是白色(垃圾),会被清除。但实际上 A 引用了 C! 后果悬垂指针,程序崩溃。

4. 屏障技术

为了防止上述 Bug,Go 引入了 写屏障 。 Go 1.8 之前使用的是 Dijkstra 插入屏障Yuasa 删除屏障 ,但都有缺点(需要 STW 重新扫描栈)。 Go 1.8 引入了 混合写屏障,结合了两者的优点。

核心规则

  1. GC 开始时,将栈上的所有可达对象全部标记为黑色(不需要 STW 再次扫描栈)。

  2. GC 期间,任何在栈上创建的新对象,均为黑色。

  3. 堆上被删除的对象标记为灰色。

  4. 堆上新添加的对象标记为灰色。

屏障逻辑伪代码

复制代码
// 伪代码:在执行 *slot = ptr 时触发
func writePointer(slot *unsafe.Pointer, ptr unsafe.Pointer) {
    shade(*slot) // 删除屏障:保护旧值,将其标灰
    shade(ptr)   // 插入屏障:保护新值,将其标灰
    *slot = ptr  // 真正的赋值操作
}

效果

  • 精度高:结合了插入和删除屏障。

  • STW 短 :因为栈是黑色的,且栈操作无屏障,GC 结束时不需要重新扫描栈

5. 观察 GC (GODEBUG)

5.1 模拟高频分配代码

复制代码
package main

import (
    "fmt"
    "time"
)

// 制造垃圾的函数
func allocate() {
    _ = make([]byte, 1<<20) // 分配 1MB 的内存
}

func main() {
    fmt.Println("Start Allocating...")
    for i := 0; i < 10; i++ {
        allocate()
        time.Sleep(time.Millisecond * 100)
    }
    fmt.Println("Done.")
}

5.2 运行与分析

使用 GODEBUG=gctrace=1 运行程序:

复制代码
$ GODEBUG=gctrace=1 go run main.go

输出解读(示例)

复制代码
gc 1 @0.005s 3%: 0.019+0.38+0.016 ms clock, 0.15+0.12/0.066/0.14+0.13 ms cpu, 4->4->0 MB, 5 MB goal, 8 P
gc 2 @0.112s 0%: 0.046+0.16+0.024 ms clock, 0.37+0.076/0.047/0.015+0.19 ms cpu, 4->4->0 MB, 5 MB goal, 8 P
...

核心字段解析

  • gc 1:第 1 次 GC。

  • @0.005s:程序启动后 0.005s 时发生。

  • 3%:自上次 GC 以来,GC 占用了 3% 的 CPU 时间。

  • 4->4->0 MB

    • GC 开始时的堆大小 (4MB)。

    • GC 结束时的堆大小 (4MB,因为是并发的,可能还会涨)。

    • 存活的堆大小 (0MB) -> 这里因为 allocate 函数结束后切片就没用了,所以都被回收了。

  • 5 MB goal:下一次触发 GC 的目标堆大小。

6. GC 调优 (Ballast & GOGC)

6.1 GOGC 原理

Go 默认的 GC 触发策略是:当堆内存达到上次 GC 后存活内存的 200% 时触发 。 这个 200% 就是由 GOGC 环境变量控制的,默认值 100。

公式:NextGC_Goal = LiveHeap + (LiveHeap + GC_Roots) * GOGC / 100

6.2 内存压舱石 (Memory Ballast)

在 Go 1.19 之前(Soft Memory Limit 引入前),像 Twitch 这样的大厂广泛使用 Ballast 技术来降低 GC 频率。

场景 :你的服务需要处理大量短连接,产生海量小对象。GC 频繁触发,CPU 飙升。 方案:初始化一个巨大的、永远不被释放的数组。

复制代码
package main

import (
    "fmt"
    "runtime"
    "time"
)

func main() {
    // 1. 初始化一个 100MB 的压舱石
    // 注意:这只是虚拟内存,只要不读写它,就不会占用物理内存(RSS)
    ballast := make([]byte, 100*1024*1024) 

    // 保持引用,防止被 GC
    runtime.KeepAlive(ballast)

    fmt.Println("Ballast initialized. GC will be less frequent.")

    // 模拟业务逻辑
    for {
        _ = make([]byte, 1<<20) // 分配 1MB
        time.Sleep(time.Millisecond * 10)
    }
}

原理

  1. 程序启动,LiveHeap 瞬间变成 100MB。

  2. 根据 GOGC=100,下一次 GC 的目标是 100MB + 100MB = 200MB

  3. 你的业务逻辑产生的小对象(垃圾)必须填满这额外的 100MB 空间,才会触发 GC。

  4. 结果:GC 频率大幅降低,虽然峰值虚拟内存变大了,但 CPU 得到了解放。

注意 :Go 1.19 引入了 debug.SetMemoryLimit,这是更官方的解决方案,但在理解 GC 原理上,Ballast 依然是一个绝佳的案例。

相关推荐
漫随流水2 小时前
leetcode回溯算法(216.组合总和Ⅲ)
数据结构·算法·leetcode·回溯算法
froginwe112 小时前
`.addClass()` 方法详解
开发语言
Leweslyh2 小时前
【实战】设计一颗“永远向阳”且“姿态稳定”的卫星 (例题 4.8)
算法·航天·轨道力学·星际航行·太阳同步轨道
机器视觉知识推荐、就业指导2 小时前
Qt 6 所有 C++ 类(官方完整清单 · 原始索引版)
开发语言·c++·qt
木木木一2 小时前
Rust学习记录--C12 实例:写一个命令行程序
学习·算法·rust
一口面条一口蒜2 小时前
R 包构建 + GitHub 部署全流程
开发语言·r语言·github
大柏怎么被偷了2 小时前
【C++】哈希桶
数据结构·算法·哈希算法
IT19952 小时前
C++ 实战笔记:OpenSSL3.5.2 实现 SM2 数据加密(附完整源码 + 注释)
开发语言·c++·笔记
leaves falling2 小时前
c语言自定义类型深度解析:联合(Union)与枚举(Enum)
c语言·开发语言·算法