ARTICLE / Go · 内存泄漏 · 垃圾回收 · 性能优化

Go Slice 导致的内存泄漏:小切片如何拖垮大内存?

为什么一个只剩几个元素的 slice 能让几百 MB 内存迟迟不降?从 GC 的可达性原理出发,拆解「大底层数组被小切片绑架」与「残留指针未清零」两类内存泄漏,并给出 slices.Clone 深拷贝、clear 置零与 slices.Delete 的解法。

发布
阅读
约 2 分钟
作者
Nicolas Leigh
READS0次阅读

Go 有垃圾回收(GC),但很多开发者常遇到一个困惑:明明一个 slice 最终只留了几个元素,为什么几百 MB 内存死活不降?

这通常不是 runtime 的 bug,而是 GC 严格按照可达性(Reachability)工作的必然结果。最典型的坑有两个:大底层数组被小切片绑架,以及残留指针未清零

1. 巨型数组被“偷窥孔”锁死

从大内存切一小段,看起来只有十几字节,但只要这个小切片还在用,整个大数组就无法释放:

func getHeader(data []byte) []byte {
    return data[:10] // 虽然 len=10,但它依然挂靠在原底层数组上
}

data := make([]byte, 100*1024*1024) // 100 MB
header := getHeader(data)
data = nil // 没用,GC 依然无法回收那 100 MB,因为 header 还在引用它

注:用 data[:10:10] 也没用。限制 cap 只能防止后续 append 踩踏原数组,并不能切断对大数组的引用。

解法:主动深拷贝,切断血缘关系

result := make([]byte, 10)
copy(result, data[:10]) // 或 append([]byte(nil), data[:10]...)

// 或者采用 Go 1.21 引入的 slices.Clone
result := slices.Clone(data[:10])

拿几十字节的额外分配代价,换来 100 MB 内存被 GC 及时回收,这笔账绝对划算。

2. 截断/删除切片时的“幽灵指针”

如果切片里存的是指针带指针的结构体(如包含 []bytemapstring),缩短切片长度并不能释放被裁掉的对象:

type User struct {
    Data []byte // 假设每个占 1 MB
}

users := make([]User, 1000)
// ... 填充数据 ...

// 业务上只要前两个:
users = users[:2]

此时 len(users) == 2,但后面 998 个元素依然安稳躺在底层数组里,它们的 Data 指针依然有效,近 1 GB 内存全被悬空锁死。

解法:显式置空或使用 clear

复用原数组截断:用 Go 1.21+ 引入的内置函数 clear,把不在视界内的槽位打回零值:

clear(users[2:]) // 将尾部元素置为零值,Data 字段因此变为 nil
users = users[:2]

单元素删除:经典元素位移后,末尾务必置空:

copy(s[i:], s[i+1:])
s[len(s)-1] = nil // 抹掉尾部残留指针,让 GC 回收
s = s[:len(s)-1]

// Go 1.21 引入了官方标准库 slices.Delete,更简单的方法如下
s = slices.Delete(s, i, i+1)

什么时候该当心?

不用草木皆兵——普通的 []int 或短生命周期的小切片,随函数退出自然会被扫掉。

真正需要警惕的是两个交集:

  1. 长生命周期(全局缓存、长期持有的 struct 字段、连接池);
  2. 大尺寸或含指针(网络解析大 Buffer、大文件切块、[]*T)。

看到 s = s[:n] 时多问一句:这个底层数组到底有多大?它背后的指针,是不是该亲手切断了?