内存泄漏是后端开发经常要面对的问题。线上服务突然内存狂增,监控显示 GC 频率特别高——这种故事你在各种技术论坛里能看到一堆。一个 goroutine 无限启动却没有退出条件、一个全局 map 只加不删、一个 slice 截断后还握着整个底层数组……就这点小事,就能把你的服务搞到 OOM。所以得好好理解一下 Go 的 GC 机制,不然这种坑会一直踩。
GC 是什么,为什么 Go 需要它
说白了,GC(垃圾回收)就是一个自动的"保洁员"。你在堆上分配内存创建对象,用完了之后,不用手动
free,GC 会自动把不再用的东西清理掉。
对比一下:
- C/C++:你自己管理内存,
malloc了就要记得free,特别是在大项目里,一不小心就内存泄漏 - Go:写代码的时候不用想这些,GC 帮你收拾烂摊子
乍一看很爽,但问题是:GC 怎么知道哪些对象该回收,哪些还活着?
答案就是下面要讲的三色标记法。
三色标记法,其实就这么简单
Go 的 GC 从 1.5 版本开始用三色标记法。听起来很学术,但原理超简单。
想象一下,你有一堆对象放在内存里。GC 的工作就是要标记哪些是"活着的",哪些是"死了的"。三色标记就是用三种颜色来标记对象的状态:
白色 = 未访问,还不确定是否活着
灰色 = 已访问,但它指向的对象还没看
黑色 = 已访问,并且它指向的对象也都看完了,肯定活着
核心流程:
- 标记初始:所有对象一开始都是白色
- 找根对象:从 GC root(栈上的指针、全局变量等)开始,这些肯定活着
- 灰色队列:把根对象涂成灰色,丢进待处理队列
- 遍历灰色对象:逐个取出灰色对象,看它指向的其他对象
- 如果指向的对象是白色,涂成灰色,加入队列
- 处理完这个对象的所有引用后,涂成黑色
- 回收:扫描一遍,所有还是白色的对象?没人指向它们,删!
代码里感受一下:
// 假设有这样的链表
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分析内存和 CPUruntime.ReadMemStats()查看实时内存状态
折腾了这么久才搞明白,希望这篇笔记对你也有帮助。