Go 的垃圾回收

Go 的垃圾回收

Lirous.
2/10/2026
12 min read
一个大学生疯狂 Debug 后的 GC 笔记。从三色标记法的核心逻辑到日常工程实践,没有花哨概念,全是能用上的东西。

内存泄漏是后端开发经常要面对的问题。线上服务突然内存狂增,监控显示 GC 频率特别高——这种故事你在各种技术论坛里能看到一堆。一个 goroutine 无限启动却没有退出条件、一个全局 map 只加不删、一个 slice 截断后还握着整个底层数组……就这点小事,就能把你的服务搞到 OOM。所以得好好理解一下 Go 的 GC 机制,不然这种坑会一直踩。

GC 是什么,为什么 Go 需要它

说白了,GC(垃圾回收)就是一个自动的"保洁员"。你在堆上分配内存创建对象,用完了之后,不用手动 free,GC 会自动把不再用的东西清理掉。

对比一下:

  • C/C++:你自己管理内存,malloc 了就要记得 free,特别是在大项目里,一不小心就内存泄漏
  • Go:写代码的时候不用想这些,GC 帮你收拾烂摊子

乍一看很爽,但问题是:GC 怎么知道哪些对象该回收,哪些还活着?

答案就是下面要讲的三色标记法

三色标记法,其实就这么简单

Go 的 GC 从 1.5 版本开始用三色标记法。听起来很学术,但原理超简单。

想象一下,你有一堆对象放在内存里。GC 的工作就是要标记哪些是"活着的",哪些是"死了的"。三色标记就是用三种颜色来标记对象的状态:

白色 = 未访问,还不确定是否活着
灰色 = 已访问,但它指向的对象还没看
黑色 = 已访问,并且它指向的对象也都看完了,肯定活着

核心流程

  1. 标记初始:所有对象一开始都是白色
  2. 找根对象:从 GC root(栈上的指针、全局变量等)开始,这些肯定活着
  3. 灰色队列:把根对象涂成灰色,丢进待处理队列
  4. 遍历灰色对象:逐个取出灰色对象,看它指向的其他对象
    • 如果指向的对象是白色,涂成灰色,加入队列
    • 处理完这个对象的所有引用后,涂成黑色
  5. 回收:扫描一遍,所有还是白色的对象?没人指向它们,删!

代码里感受一下:

// 假设有这样的链表
type Node struct {
    val  int
    next *Node
}

func demo() {
    root := &Node{val: 1} // 被栈上的变量引用,GC root
    root.next = &Node{val: 2}
    root.next.next = &Node{val: 3}
    // GC 的视角:
    // root 节点 → 黑色(从这里出发找到的)
    // root.next 节点 → 灰色(被 root 指向,还要检查它的子节点)
    // root.next.next 节点 → 灰色(被 root.next 指向)

    x := root.next.next
    // x 还活着,被栈变量指向

    root.next = nil
    // 这时,val=2 的节点再也没人指向了
    // 下次 GC,它会被标记成白色,最后被回收
}

"Stop The World" 问题

理论很优美,但现实有个恶心的问题:GC 在标记的时候,应用程序必须暂停

为什么?因为如果程序还在跑,对象关系一直在变化,GC 标记到一半对象被改了,结果就错了。所以 GC 要暂停整个程序,这叫 STW(Stop The World)。

早期 Go 的 GC 特别坑爹,STW 可能要停顿好几秒。用户请求来了?对不起,GC 在工作,您稍候。服务直接变成"菜鸡"。

// Go 1.5 之前,这样的代码可能导致 100ms+ 的停顿
for i := 0; i < 1000000; i++ {
    m := make(map[string]interface{})
    // ... 填充 map ...
    // 大量对象在堆上,GC 要标记它们,STW 时间很长
}

后来 Go 团队优化了 GC,引入了写屏障(Write Barrier)并发标记的机制,让 GC 可以和应用并行工作,大大减少停顿时间。现在 Go 的 GC 停顿通常在 1ms 以内。

实战提示:用 go test -bench 跑性能测试的时候,加上 -benchmem 看内存分配情况,然后用 go tool pprof 分析 GC 压力。如果 GC 频率特别高,说明你在某个 hot path 里疯狂分配对象。

日常开发中怎么避免内存泄漏

GC 再强大,也救不了你写的烂代码。下面是我踩过的几个坑。

坑 1:全局 map/slice 无限增长

// 绝对不要这样做
var cache = make(map[string]interface{})

func addToCache(key string, val interface{}) {
    cache[key] = val
    // 只加不删,map 永远增长,最后 OOM
}

解决方案:加过期机制或者用 LRU cache

// 用开源库,比如 github.com/patrickmn/go-cache
c := cache.New(5*time.Minute, 10*time.Minute)
c.Set("key", value, cache.DefaultExpiration)

坑 2:Goroutine 泄漏

这是最常见的。一个 goroutine 启动了,却没有正常退出:

// 别这样写
func badServer() {
    http.HandleFunc("/api", func(w http.ResponseWriter, r *http.Request) {
        go func() {
            for {
                // 没有退出条件,goroutine 永远活着
                time.Sleep(1 * time.Second)
            }
        }()
    })
}

结果:每个请求都启一个永不退出的 goroutine,最后把系统搞炸。

正确做法:用 context 来控制生命周期

func goodServer(ctx context.Context) {
    http.HandleFunc("/api", func(w http.ResponseWriter, r *http.Request) {
        // 使用请求的 context
        go func() {
            ticker := time.NewTicker(1 * time.Second)
            defer ticker.Stop()

            for {
                select {
                case <-ctx.Done():
                    // context 被取消,goroutine 退出
                    return
                case <-ticker.C:
                    // 做工作
                }
            }
        }()
    })
}

坑 3:Slice 截断后的内存浪费

// 假设有个 100MB 的 slice
data := make([]byte, 100*1024*1024)
// ... 填充数据 ...

// 只想保留前 1000 字节
data = data[:1000]

// 问题:底层的大数组还在内存里!
// slice 的 cap 仍然是 100MB,只是 len 变成了 1000
// GC 看到 data 还被引用,不敢删那个大数组

修复:显式复制

// 正确做法
small := make([]byte, 1000)
copy(small, data[:1000])
// 现在 data 没人引用了,可以被 GC 回收

关键理解:GC 看的是引用关系,不是你在脑子里的逻辑。只要一个对象被某个活着的变量引用,GC 就不会删它,即使你实际用不上。

坑 4:指针循环引用

虽然 Go 的 GC 能处理循环引用,但这样写会增加 GC 压力:

// 两个对象互相指向
type A struct {
    b *B
}
type B struct {
    a *A
}

func makeCircle() {
    a := &A{}
    b := &B{}
    a.b = b
    b.a = a
    // 函数返回后,a 和 b 没有外部引用了
    // 但它们互相指向,GC 要用特殊算法才能识别这是垃圾
    // 比直线引用链更费时
}

避免的方法:用 runtime.SetFinalizer 或显式断链

func makeCircle() {
    a := &A{}
    b := &B{}
    a.b = b
    b.a = a

    // 设置析构函数,退出时手动断链
    runtime.SetFinalizer(a, func(x *A) {
        x.b = nil // 断开引用
    })
}

实际上,大多数情况下你不需要这样做。只要设计好结构,避免无意义的循环引用,GC 就能正常工作。

我的一些碎碎念

学完这些,我发现一个规律:内存问题 90% 都来自代码设计不当,不是 GC 的问题

GC 是很聪明的工具,但它改变不了"程序员没想清楚数据生命周期"这个事实。所以写代码的时候:

  • 想清楚对象什么时候应该被清理
  • 如果是长期持有的引用(比如全局 map),加上过期/淘汰机制
  • Goroutine 一定要有退出条件,别无限开启
  • pprof 定期看一下内存分配热点

最后,不要过度优化。Go 的 GC 已经调得不错了,90% 的时间你都用不上手工调参。把精力放在代码逻辑上,比反复微调 GC 配置更值得。


相关工具推荐

  • go test -bench -benchmem 看分配情况
  • go tool pprof 分析内存和 CPU
  • runtime.ReadMemStats() 查看实时内存状态

折腾了这么久才搞明白,希望这篇笔记对你也有帮助。

评论区