Go 高手(07):slice 底层——slice header、扩容策略、内存泄漏
更新时间:2026-09-01。本文是
languages/go/intermediate/高手层第 7 篇,接 闭包与逃逸分析。slice 是 Go 最常用的数据结构,但很多人只用了表面的append和索引。理解 slice 的底层数据结构,才能避免扩容开销、内存泄漏和共享底层数组的坑。
本文要回答的问题
- slice 的底层结构是什么?三要素(ptr/len/cap)怎么工作?
append在什么情况下扩容?扩容策略是什么?- 子切片
s[low:high]共享底层数组吗?什么情况下会泄漏? - 怎么强制"深拷贝"子切片,不共享底层数组?
- 切片操作和 C 语言的数组指针有什么异同?
一、slice header:三要素
每个 slice 在内存里是一个 24 字节的 slice header:
type SliceHeader struct {
Data uintptr // 指向底层数组的指针(8 字节)
Len int // 当前元素个数(8 字节)
Cap int // 最大容量(8 字节)
}slice header(24 字节)
┌──────────────────────┐
│ Data (ptr) │ → 指向底层数组
├──────────────────────┤
│ Len (int) │ → 当前元素个数
├──────────────────────┤
│ Cap (int) │ → 最多能容纳的元素
└──────────────────────┘s := make([]int, 3, 5) // len=3, cap=5
// s = [0, 0, 0],底层数组有 5 个位置二、append 的扩容策略
s := make([]int, 0, 3)
fmt.Println(cap(s)) // 3
s = append(s, 1, 2, 3)
fmt.Println(cap(s)) // 3,没有扩容
s = append(s, 4) // 触发扩容
fmt.Println(cap(s)) // 6(Go 1.18+ 扩容策略)Go 1.18+ 的扩容策略:
- 容量 < 256 时:翻倍(如 3→6→12→24)
- 容量 ≥ 256 时:增长 1.25 倍(逐步放缓,避免大切片浪费内存)
- 确切公式:
newcap = oldcap + oldcap/4(当 oldcap ≥ 256 时)
扩容发生了什么?
- 分配新的底层数组(更大的容量)
- 拷贝旧数据到新数组
- 修改 slice header 的 Data 指针指向新数组
- 旧数组被 GC 回收
性能影响:频繁扩容导致多次分配和拷贝。如果预先知道最终大小,提前 make([]T, 0, n) 指定容量。
三、子切片:共享底层数组
arr := []int{0, 1, 2, 3, 4, 5}
s1 := arr[1:4] // [1, 2, 3], len=3, cap=5
s2 := arr[2:5] // [2, 3, 4], len=3, cap=4底层数组: [0, 1, 2, 3, 4, 5]
↑ ↑ ↑
s1: 1 2 3
↑ ↑ ↑
s2: 2 3 4s1 和 s2 共享同一个底层数组。修改 s1[0] 会同时影响 arr[1] 和 s2(如果 s2 能看到)。
四、子切片的内存泄漏
// 从一个大文件读取所有行,取第一行
data := readAllLines() // 10000 行的 slice
firstLine := data[:1] // 只取第一行firstLine 只有 1 个元素,但底层数组(10000 行)被 firstLine 引用着,GC 不会回收。这就是内存泄漏。
// ✅ 修复:深拷贝子切片
firstLine := make([]string, 1)
copy(firstLine, data[:1])
// 现在原来的大数组可以被 GC 回收了什么时候要注意?
- 大 slice 切出小 slice 后,大 slice 不再使用
- 取到子 slice 后,继续使用子 slice 很久
- 函数返回子 slice,内部保留了大 slice 的引用
五、copy 深拷贝
src := []int{1, 2, 3}
dst := make([]int, len(src))
n := copy(dst, src) // 返回拷贝的元素个数,3copy 拷贝的是 min(len(dst), len(src)) 个元素。用 copy 创建独立的 slice,不共享底层数组。
六、go 1.22 的 slice 新特性
Go 1.22 引入了更安全的切片操作,但核心的 slice header 和扩容策略不变。主要变化是 for range 每次迭代创建新变量,影响的是闭包捕获,不是 slice 本身。
七、slice 和 C 数组的对比
| 对比 | Go slice | C 数组 |
|---|---|---|
| 长度 | 运行时,动态 | 编译期,固定 |
| 传递 | 值传递(header 拷贝),但共享底层数组 | 退化为指针 |
| 边界检查 | 运行时检查,越界 panic | 无检查,越界 segfault |
| 扩容 | append 自动扩容 | 手动 realloc |
| 子切片 | 共享底层数组,O(1) | 指针运算,O(1) |
Go 的 slice 比 C 数组安全(边界检查),但性能稍低(检查开销,纳秒级)。C 的数组更接近硬件,没有额外开销。
八、常见坑对照
| 坑 | 现象 | 对策 |
|---|---|---|
| append 结果不赋值 | slice 没变 | s = append(s, x) 必须赋值 |
| 子切片修改影响原数组 | 数据被意外修改 | 用 copy 深拷贝 |
| 大 slice 切小 slice 不 copy | 大数组没法 GC | 小 slice 用 copy 深拷贝 |
| 频繁 append 不预分配 | 多次扩容,性能差 | 提前 make 指定 cap |
| 把 slice 存入结构体 | 共享底层数组,修改互相影响 | 用 copy 创建独立副本 |
相关与延伸
下一篇:map 底层——hashmap、负载因子、扩容;slice 的底层实现暴露了 C 的指针思维,这和 C 语言的数组指针操作一脉相承,见 C 数组指针关系。
一句话总结
Go slice 底层:slice header 三要素(Data + Len + Cap),24 字节;append 扩容:容量 < 256 翻倍,≥ 256 增长 25%;子切片共享底层数组,修改互相影响;大 slice 切小 slice 要 copy 深拷贝,否则泄漏整个底层数组;预分配容量避免多次扩容。