Go 里 slice 为什么会导致内存不释放、数据被改掉?

slice 是值类型,值里装的是一个指向底层数组的指针,所以传参、赋值、切片都只是复制这个 24 字节的头,底层数组始终只有一份。只要还有一个子切片引用着原数组,整个底层数组就无法被 GC 回收;只要两个切片共享底层数组,对其中一个 append 就可能改掉另一个的数据。

slice 到底是什么

Go 运行时里 slice 的表示就三个字段(runtime/slice.go):

type slice struct {
    array unsafe.Pointer // 底层数组首地址
    len   int
    cap   int
}

传参、赋值、返回,拷贝的都是这 24 字节,array 指针原样复制。所以准确说法是:slice 是值类型,值的内容是一个指针,不是「引用类型」。

三个真实事故

事故一:图片服务内存不降

缩略图接口把 4000×3000 的原图(约 48MB)裁出一小块返回:

func CropFast(img *Image, r Rect) []byte {
    return img.Raw[r.Offset : r.Offset+r.Size] // 直接切片返回
}

切出来的 1.2MB 小块和 48MB 原图共享同一个底层数组。GC 的回收粒度是「对象」,一个字节被引用,整个数组就活着。上线一周堆里驻留 40 万张解码后的大图,内存水位 19GB。

事故二:订单导出「掉包案」

300 条订单(底层数组容量 512)切成两段:orders[:100] 给前端预览,orders[100:] 交给归档 goroutine 异步落库。预览接口最后一步 preview = append(preview, summary),因为 cap 继承整段 512,没触发扩容,就地写进了下标 100——那正是 archive[0] 的位置。归档文件里第一条订单变成了一条「预览汇总行」。没有 panic、没有 data race 报警,因为这是两个合法的 append 写进了同一块内存。

事故三:函数里 append「没生效」

func Dedup(s []int) // 内部 s = append(s[:0], unique...)

函数内改的是数组内容(前几个元素确实被覆盖了),函数外调用方持有的头 len 还是旧值。改数组是全局的,改头是局部的。

怎么确认两个切片共享底层数组

一行代码:

fmt.Printf("same backing array: %v\n", &full[0] == &cropped[0]) // true

两个切片的第一个元素地址相同,就是同一块内存。所有 slice 的灵异现象,先用它验明正身。

append 的扩容机制

Go 1.18+ 的 growslice 逻辑(伪代码):

if oldCap < 256 {
    newcap = oldCap * 2          // 小切片翻倍
} else {
    for newcap < needed {        // 大切片约 1.25 倍爬坡
        newcap += (newcap + 3*256) / 4
    }
}
// 之后按元素大小对齐内存规格(roundupsize),实际 cap 常比公式值更大

两个工程含义:

  • 扩容会换底层数组,所以 append 的返回值必须接回原变量
  • 扩容边界不可依赖,256 这个阈值、对齐规则都可能随版本变

三条铁律

铁律一:要把子切片带离原数组,就显式断开。

// 手动版
out := make([]byte, r.Size)
copy(out, img.Raw[r.Offset:r.Offset+r.Size])

// Go 1.21+ 标准库版
out := slices.Clone(img.Raw[r.Offset : r.Offset+r.Size])

铁律二:append 的返回值必须接回原变量。 append(s, x) 单独一行是 bug。要在函数里影响调用方的长度,就返回新 slice:func Dedup(s []int) []int,调用方 s = Dedup(s)。

铁律三:两个切片共享底层数组时,禁止同时写。 只读共享合法且高效;写必须先隔离。判断口诀:cap(s[a:]) == cap(s)-a。

工具函数:

func sameBacking[T any](a, b []T) bool {
    return len(a) > 0 && len(b) > 0 && &a[0] == &b[0]
}

数据对比

事故一的驻留内存(接口峰值每秒 200 张 48MB 原图裁剪,每张裁出 1.2MB):

| 实现方式 | 单请求净驻留 | 稳态堆水位(GC 后) | | --- | --- | --- | | 直接切片返回 | 48 MB | ~19 GB | | clone 后返回 | 1.2 MB | ~240 MB + 短暂解码峰值 |

clone 的代价:每请求多一次 1.2MB 拷贝,约 80 微秒,占接口总耗时(约 300ms)的 0.03%。

append 预分配与否(追加 100 万个 int64):

| 写法 | 耗时 | 扩容次数 | 总拷贝量 | | --- | --- | --- | --- | | var s []int64 裸 append | ~28 ms | 41 次 | ~8.4 MB 均摊 | | make([]int64, 0, 1e6) | ~4 ms | 0 次 | 0 |

预分配快 7 倍。slices.Clone 克隆 1MB 字节切片约 60µs,成本可忽略。

常见面试问题

slice 和数组的区别? 数组是值类型,赋值/传参整体拷贝,长度是类型的一部分;slice 是 24 字节 header,header 值拷贝,底层数组共享。追问「slice 是引用类型吗」时,答「值类型,但值含指针」。

append 的扩容机制? Go 1.18 之前 cap<1024 翻倍、之后 1.25 倍;1.18 起阈值改为 256,小切片翻倍、大切片 1.25 倍渐进爬坡,最后 roundupsize 对齐,实际 cap 可能大于公式值。重点是:扩容换底层数组,返回值必须接回。

函数内对参数 slice append,调用方为什么看不到? cap 足够时写入落在共享数组上,内容变化可见但调用方 len 不变;cap 不足时扩容换新数组,调用方完全无感。正确签名是返回新 slice。

深拷贝怎么做? 一维用 slices.Clone(nil 输入返回 nil)。二维/嵌套结构必须逐层拷贝。坑:clone 一个「共享了大量子切片」的结构体数组,浅层 clone 完这些子切片仍互指。

截取子切片有什么坑? cap 继承(cap(s[a:]) = cap(s)-a,append 可能写进原数组的后续位置);共享底层数组导致内存无法释放;跨 goroutine 写会互相改数据。

一句话总结:slice 是值类型但值里含指针,传的是头、共享的是数组,要带走子切片就 clone,要 append 就接回返回值,要并发写就先隔离。


本文是速览版,完整教程看这篇:Go 语言实战(四):slice 的「背叛」,原文含完整代码示例与推导过程。