切片几乎在每个 Go 程序里都被用到,但它是最常被误解的类型。典型误解有三条:以为切片是"引用传递"、以为 s[:n] 会复制数据、以为 append 之后两个切片互不相干。实际情况是切片持有一个指向底层数组的指针,append 在容量够时原地写、容量不够时新建数组,两条路径的观察结果完全不同。这篇从数组的值语义讲到三字段结构,再到扩容、三索引切片与内存泄漏,给出可直接验证的判据。
1. 数组是值类型,长度是类型的一部分
a := [3]int{1, 2, 3}
b := a // 完全复制
b[0] = 100
fmt.Println(a) // [1 2 3],未受影响
// var d [4]int = a // 编译错误:长度不同即不同类型
| 特性 | 数组 [N]T |
切片 []T |
|---|---|---|
| 类型是否含长度 | 含,[3]int 与 [4]int 不同 |
不含 |
| 赋值语义 | 复制全部元素 | 复制描述符(3 个字) |
| 可否比较 | 长度相同且元素可比较时可用 == |
只能与 nil 比较 |
| 传参开销 | 元素个数 × 元素大小 | 固定 24 字节(64 位) |
| 长度可变 | 不能 | 能 |
因为长度是类型的一部分,[3]int 和 [4]int 无法互相赋值,也不能把不同长度的数组放进同一个变量。这就是实际代码里数组用得少、切片用得多的根本原因。
2. 切片的三个字段
切片在运行时是一个结构体:
type slice struct {
array unsafe.Pointer // 指向底层数组
len int // 当前长度
cap int // 容量(从 array 起点到底层数组末尾)
}
base := [6]int{1, 2, 3, 4, 5, 6}
s := base[1:4] // 长度 3,容量 5
fmt.Println(len(s), cap(s)) // 3 5
cap 是从切片起始位置到底层数组末尾的距离,不是切片长度的上限。这就是 base[1:4] 的容量是 5(base[1] 到 base[5])的原因。
| 表达式 | len | cap | 说明 |
|---|---|---|---|
base[:] |
6 | 6 | 全量 |
base[1:4] |
3 | 5 | 起点前移,cap 减少 |
base[:0] |
0 | 6 | 长度清空,容量不变 |
base[4:] |
2 | 2 | 尾部对齐 |
make([]int, 3, 10) |
3 | 10 | 显式指定 |
len 决定能索引到哪,cap 决定 append 是否要重新分配。
3. 共享底层数组的经典陷阱
func main() {
base := [5]int{1, 2, 3, 4, 5}
s1 := base[0:2] // [1 2]
s2 := base[0:3] // [1 2 3]
s1[0] = 99
fmt.Println(s2) // [99 2 3],被 s1 改了
s1 = append(s1, 7) // cap 够,原地写 base[2]
fmt.Println(s2) // [99 2 7],又被改了
}
s1 和 s2 共享同一个底层数组,任何一方的元素写入、以及容量内的 append,都会反映到另一方。这不是 bug,是切片的定义。
判据只有一条:看两个切片的 &s[0] 是否相等。
fmt.Println(&s1[0] == &s2[0]) // true 表示共享
base[1:4] 这类操作不复制任何数据,只是把指针后移、把 len/cap 改小。所以对一个大切片反复切子切片,内存占用不会下降——底层数组仍被持有。
3.1 什么时候会分离
append 触发扩容时会分配新数组,此后两个切片断开:
s1 := make([]int, 2, 2) // len 2, cap 2
s2 := s1
s1 = append(s1, 3) // cap 不足,新建数组
s1[0] = 99
fmt.Println(s2[0]) // 原值,未受影响
所以"append 之后还共享吗"取决于当时 cap 够不够,这是切片最难推理的地方。
4. append 扩容规则
append 的两条路径:
| 条件 | 行为 | 对原切片的影响 |
|---|---|---|
len + n <= cap |
原地写入底层数组,返回新描述符 | 底层数组被改,共享者可见 |
len + n > cap |
分配新底层数组,复制旧元素,返回新描述符 | 原底层数组不动,切片分离 |
扩容倍率是运行时实现细节,各版本有调整,需按当前版本核对(Go 1.18 前后对小切片与大切片的增长策略做过改动)。不要依赖具体倍率写逻辑,只依赖"容量不足必然重新分配"这一条保证。
s := make([]int, 0, 1)
for i := 0; i < 8; i++ {
s = append(s, i)
fmt.Println(i, len(s), cap(s)) // 典型输出:cap 按版本策略阶梯增长
}
想观察实际扩容点,跑上面这段并打印 cap,结果就是当前版本的策略,比记倍率可靠。
4.1 预分配容量
已知最终长度就预分配,避免反复复制:
out := make([]int, 0, len(src)) // 一次分配到位
for _, v := range src {
out = append(out, v*2)
}
make([]int, 0, n) 与 make([]int, n) 不同:前者 len 为 0、靠 append;后者已有 n 个零值元素、按下标赋值。
5. copy 与全量切片
src := []int{1, 2, 3}
dst := make([]int, len(src))
n := copy(dst, src) // 返回实际复制的元素数,3
copy(dst, src[:2]) // 只覆盖前两个,第三个保持原值
copy 复制的元素数是 min(len(dst), len(src)),不会扩容,也不会 panic。
| 写法 | 是否复制数据 | 用途 |
|---|---|---|
s[:] |
否 | 全量重切,仍共享底层数组 |
s[0:len(s)] |
否 | 同上,纯写法差异 |
append([]T(nil), s...) |
是 | 一行复制,简洁 |
make + copy |
是 | 显式,可控制容量 |
slices.Clone(s) |
是 | Go 1.21 标准库,推荐 |
s[:] 是最常见的误解点:只换描述符,没有任何复制。要断开共享必须走复制路径。
// Go 1.21+
import "slices"
safe := slices.Clone(s)
6. 三索引切片:限制容量防误改
s[low:high:max] 的第三个索引限制新切片的容量为 max - low,从而切断"容量内 append 原地写"的路径。
base := [5]int{1, 2, 3, 4, 5}
s := base[0:2:2] // len 2, cap 2
s = append(s, 9) // cap 不足,分配新数组
fmt.Println(base) // [1 2 3 4 5],未被污染
| 写法 | len | cap | append 会否污染原数组 |
|---|---|---|---|
base[0:2] |
2 | 5 | 会 |
base[0:2:2] |
2 | 2 | 不会 |
base[0:2:3] |
2 | 3 | 第三个元素会被写 |
典型用途是函数返回一个"只读视图":调用方无论如何 append 都改不到你的内部数据。这是库代码防止调用方误改内部状态的低成本手段。
7. 切片作为参数的真正语义
func changeElem(s []int) { s[0] = 100 } // 生效,共享底层数组
func changeLen(s []int) { s = append(s, 1) } // 不生效,只改副本描述符
func main() {
s := []int{1, 2, 3}
changeElem(s) // s[0] 变成 100
changeLen(s) // len(s) 仍是 3
}
| 操作 | 是否影响调用方 | 原因 |
|---|---|---|
改元素 s[i] = x |
是 | 共享底层数组 |
容量内 append 并写元素 |
是 | 原地写底层数组 |
容量不足 append |
否 | 新数组,仅副本描述符更新 |
s = s[:2] |
否 | 仅改副本的 len |
排序 slices.Sort(s) |
是 | 原地交换元素 |
要真正把长度变化传出去,只能返回新切片,或者传 *[]T:
func grow(s *[]int, v int) { *s = append(*s, v) }
后者不推荐滥用:签名变难懂,而且容易和并发写冲突。返回新切片是 Go 的惯用法。
8. nil 切片与空切片
var a []int // nil
b := []int{} // 非 nil,空
c := make([]int, 0) // 非 nil,空
fmt.Println(len(a), len(b), len(c)) // 0 0 0
fmt.Println(a == nil, b == nil) // true false
| 维度 | nil 切片 |
空切片 []int{} |
|---|---|---|
len / cap |
0 / 0 | 0 / 0 |
== nil |
true | false |
range |
0 次,安全 | 0 次,安全 |
append |
安全 | 安全 |
json.Marshal |
null |
[] |
reflect.DeepEqual 与空切片比较 |
false | true |
最后两行是实际会出问题的地方:JSON API 返回 null 与 [] 对客户端语义不同;测试里 DeepEqual 判等会因 nil 与空切片差异失败。
约定:函数返回切片时,无数据就返回 nil(不是 []int{});需要 JSON 输出 [] 时,在序列化前显式 if s == nil { s = []T{} }。
9. 内存泄漏:小子切片持有大数组
func firstN(big []byte, n int) []byte {
return big[:n] // 泄漏:整个 big 无法回收
}
func firstN2(big []byte, n int) []byte {
out := make([]byte, n)
copy(out, big[:n])
return out // 安全:只有 n 字节存活
}
第一个版本返回的切片仍指向 big 的底层数组,只要返回值活着,整个 big 都不能被 GC 回收。读 1 MB 文件取前 10 字节并长期持有,就是泄漏 1 MB。
| 模式 | 泄漏风险 | 修复 |
|---|---|---|
big[:n] 长期持有 |
高 | copy 到新切片 |
big[i:j] 传给缓存 |
高 | 复制或 slices.Clip |
append 后丢弃原切片 |
低 | 原数组随引用消失回收 |
全局 slice 不断 append |
中 | 定期重建或限制上限 |
Go 1.21 起 slices.Clip(s) 可把 cap 收紧到 len,对"防止误 append 污染"有用,但它不减少底层数组的内存占用——底层数组本身不会缩小,这一点别混。
10. 排错:五个高频问题
| 现象 | 原因 | 处理 |
|---|---|---|
| 改了子切片,父切片跟着变 | 共享底层数组 | 复制,或三索引限制 cap |
append 后数据"串"到别的切片 |
容量内原地写 | s[low:high:max] 或 slices.Clone |
函数内 append 后调用方长度不变 |
描述符是值复制 | 返回新切片 |
| 内存涨了却找不到大对象 | 小子切片持有大底层数组 | copy 出独立切片 |
JSON 里出现 null 而非 [] |
返回了 nil 切片 |
序列化前转成 []T{} |
11. 边界:这些情况本就该换结构
| 场景 | 用切片 | 更合适的选择 |
|---|---|---|
| 频繁头部插入删除 | append(s[:0], ...) 挪动 |
container/list 或双端队列 |
| 需要按 key 查找 | 线性扫切片 | map[K]V |
| 元素频繁增删、要稳定地址 | 切片扩容会搬动 | map[int]*T 或链表 |
| 固定长度的小集合 | 切片(堆分配) | 数组 [N]T,可留在栈上 |
| 只读大数据共享 | 各自复制 | 传只读视图 + 三索引切片 |
一句话:切片适合"追加 + 顺序遍历",不适合"任意位置插入删除"。选错结构后再调扩容参数,收益远小于换结构。
12. 自测
base[1:4]的cap是多少?由什么决定?s[:]会复制底层数据吗?想真正断开共享该怎么做?append之后两个切片还共享底层数组吗?取决于什么条件?base[0:2:2]的第三个索引改变了什么?解决什么具体问题?- 函数里改切片元素生效、
append后长度不生效,原因分别是什么? - 一个返回
big[:10]的函数为什么会造成内存泄漏?怎么修?