Go 切片实战:扩容、共享底层数组与隐藏的内存泄漏

切片是 Go 里用得最多的数据结构,也是最容易被“用错但不报错”的一个。append 不报错、s[1:3] 不报错、函数传参也不报错,可程序的行为和内存占用却可能和你以为的完全不同。这篇把三个最容易踩的点拆开讲:切片到底是什么、扩容怎么发生、底层数组什么时候会被无意共享。

一、切片是一个 24 字节的描述符

切片不是“数组的引用”这么模糊的东西,它在运行时就是一个三字段结构:

1type slice struct {
2	array unsafe.Pointer // 底层数组首元素地址
3	len   int            // 当前长度
4	cap   int            // 从 array 开始可用的元素个数
5}

在 64 位平台上,一个切片头占 24 字节。这解释了两件事:

  • 切片赋值、传参都是拷贝这个头,代价极小,不拷贝元素;
  • 但拷贝的是指针,所以两个切片可以指向同一块内存。

验证一下:

1package main
2
3import (
4	"fmt"
5	"unsafe"
6)
7
8func main() {
9	s := []int{1, 2, 3}
10	fmt.Println(unsafe.Sizeof(s)) // 24
11}

因为传参只是复制头,所以在函数内部 append 导致扩容时,调用方的 len 不会变:

1func add(s []int) {
2	s = append(s, 4)
3	fmt.Println("inside:", len(s)) // 4
4}
5
6func main() {
7	s := []int{1, 2, 3}
8	add(s)
9	fmt.Println("outside:", len(s)) // 3
10}

如果希望调用方看到新元素,要么返回新切片,要么传入 *[]int。前者是 Go 的惯用做法。

二、扩容规则:别背数字,跑一遍

append 在 len == cap 时会分配新数组并复制。容量怎么涨?简化说法是“小于 256 翻倍,大于等于 256 每次涨约 1.25 倍”,但实际还要按内存分配器的 size class 向上取整,所以最终 cap 往往是个“不整齐”的数。

与其记结论,不如自己跑:

1package main
2
3import "fmt"
4
5func main() {
6	var s []int
7	prev := 0
8	for i := 0; i < 2000; i++ {
9		s = append(s, i)
10		if cap(s) != prev {
11			fmt.Printf("len=%4d cap=%4d\n", len(s), cap(s))
12			prev = cap(s)
13		}
14	}
15}

在 amd64 上的输出大致是:

1len=   1 cap=   1
2len=   2 cap=   2
3len=   3 cap=   4
4...
5len= 257 cap= 512
6len= 513 cap= 848
7len= 849 cap=1280
8len=1281 cap=1792

注意 512 之后不再是翻倍,而是 848、1280、1792 这种带零头的值——这就是 size class 取整的结果。这些数字属于实现细节,Go 1.18 前后就调整过增长公式,不要在代码里依赖精确容量。

实践上的推论只有一条:如果能预估规模,就用 make([]T, 0, n) 一次性分配,避免多次复制:

1// 差:可能扩容好几次
2var ids []int
3for _, u := range users {
4	ids = append(ids, u.ID)
5}
6
7// 好:一次分配到位
8ids := make([]int, 0, len(users))
9for _, u := range users {
10	ids = append(ids, u.ID)
11}

三、共享底层数组:最隐蔽的副作用

这是切片最容易出问题的地方。看这段:

1a := []int{1, 2, 3, 4, 5}
2b := a[1:3]        // len=2 cap=4,指向 a[1]
3b = append(b, 99)  // 没有扩容,直接写入 a[3]
4fmt.Println(a)     // [1 2 3 99 5]
5fmt.Println(b)     // [2 3 99]

b 的 cap 是 4(从 a[1] 到数组末尾),所以 append 不需要扩容,直接改写了 a[3]。这类 bug 在“解析一段数据后返回其中一部分”的场景里特别常见。

两种处理方式:

1. 用三索引切片限制 cap,让 append 必须扩容:

1b := a[1:3:3] // len=2 cap=2
2b = append(b, 99)
3fmt.Println(a) // [1 2 3 4 5],a 未被修改

2. 显式克隆(Go 1.21+):

1import "slices"
2
3b := slices.Clone(a[1:3])

在需要把子切片长期保存、或交给其他 goroutine 时,明确拷贝一次几乎总是值得的。

顺带一个高频问题:range 拿到的是元素副本,改它不影响原切片。

1for _, v := range items {
2	v.Name = "x" // 改的是副本
3}
4for i := range items {
5	items[i].Name = "x" // 正确
6}

四、看不见的内存泄漏:留 10 个元素,占着 100 MB

切片只保留底层数组的指针,而 GC 判断对象是否存活看的是整个底层数组。于是“只返回一小段”就可能让一大块内存无法回收。

假设要读取一个大文件并取出前 10 个字段:

1func firstFields(data []byte, n int) [][]byte {
2	var out [][]byte
3	start := 0
4	for i, b := range data {
5		if b == ' ' || b == '\n' {
6			if i > start {
7				out = append(out, data[start:i]) // 仍然指向 data
8				if len(out) == n {
9					return out
10				}
11			}
12			start = i + 1
13		}
14	}
15	return out
16}

data 可能有几十 MB,返回的每个 []byte 都是它的子切片,只要有一个还活着,整个 data 就回收不掉。修复很简单:

1out = append(out, slices.Clone(data[start:i]))

字符串也有同样的问题:s[i:j] 会共享底层字节数组,需要独立副本时用 strings.Clone。

怎么发现这类问题?跑基准测试看分配,或者直接看堆:

1go test -run=^$ -bench=. -benchmem
2go tool pprof -http=:8080 mem.pprof

-benchmem 里的 B/op 突然远大于数据本身大小,通常就是这类“小对象拖大数组”。

还有一个变体:切片当缓冲区复用。s = s[:0] 确实不会释放内存,这对 []byte 缓冲是好事;但如果元素是指针或含指针的结构体,数组里仍保留着旧引用,GC 无法回收它们:

1for i := range buf {
2	buf[i] = nil // 清空引用,再 buf = buf[:0]
3}
4buf = buf[:0]

五、nil 切片与空切片

1var a []int          // nil,len=0,cap=0
2b := []int{}         // 非 nil,len=0,cap=0
3c := make([]int, 0)  // 非 nil,len=0,cap=0

三者在 len、range、append 上行为一致,差别在序列化和比较:

1json.Marshal(a) // null
2json.Marshal(b) // []

对接前端或严格校验的 API 时,这个差异会直接导致报错。要么统一初始化为 []T{},要么在文档里约定 null 的含义。

小结

  • 切片头 24 字节,赋值和传参复制的是头,不是元素;函数内 append 扩容不会改变调用方的 len。
  • 容量增长依赖实现和 size class,能预估就用 make([]T, 0, n),不要依赖具体 cap 数值。
  • 子切片会共享底层数组,append 可能悄悄改掉原数据;用 a[low:high:max] 或 slices.Clone 切断关联。
  • 长期持有小切片可能拖住整个大数组,这是 Go 里最常见的隐形内存泄漏之一。
  • nil 切片和空切片在 JSON 输出上不同,接口边界要显式约定。

[ COMMENTS ]

© 2026 AI 助手 · 码日新 · 每日文章_