切片不是动态数组:底层数组共享、append 与内存滞留
切片最容易制造一种错觉:它长得像数组,用起来又像会自动扩容的容器,于是很多代码把它当成 Java 的 ArrayList。这在简单程序里通常没事,一旦出现子切片、复用缓冲区或者并发读写,问题就会从“偶尔多一个元素”升级成数据污染和内存长期不释放。
切片本身不是数据。可以把它理解为一个很小的描述符:指向某个底层数组的一段连续区域,并记录长度与容量。
flowchart LR
S["slice: ptr / len=3 / cap=5"] --> A1
subgraph Array[底层数组]
A0[0] --- A1[1] --- A2[2] --- A3[3] --- A4[4]
end
这里的模型用于理解语义,不要在业务代码里依赖 reflect.SliceHeader 或 unsafe 观察内部字段。语言保证的是切片行为,不是某个运行时结构的布局。
子切片共享的不是值,而是存储
numbers := []int{10, 20, 30, 40}
left := numbers[:2]
left[1] = 99
fmt.Println(numbers) // [10 99 30 40]left 和 numbers 指向同一个底层数组。把切片传给函数同样不会复制元素:函数拿到的是切片描述符的副本,描述符仍指向原来的存储。
如果函数不应该修改调用方的数据,边界必须通过复制表达出来:
func Normalize(input []string) []string {
output := append([]string(nil), input...)
for i := range output {
output[i] = strings.TrimSpace(output[i])
}
return output
}Go 1.21 以后也可以使用 slices.Clone。两种写法都在告诉读者:从这里开始,结果拥有独立存储。
append 是否修改原数组,取决于容量
append 的关键不是“追加”,而是“返回一个可能指向新数组的切片”。容量足够时,它通常复用原数组;容量不足时,运行时会分配新数组并复制已有元素。
base := make([]int, 2, 4)
base[0], base[1] = 1, 2
a := append(base, 3) // 复用底层数组
b := append(base, 4) // 也从 len(base) 的位置写入
fmt.Println(a) // 很可能是 [1 2 4],而不是 [1 2 3]
fmt.Println(b) // [1 2 4]a 和 b 都从同一个 base、同一个长度开始追加,第二次追加覆盖了第一次写入的位置。这个 bug 常见于“从公共前缀派生多个结果”的代码。
如果希望派生结果不能复用前缀后面的容量,可以使用完整切片表达式:
prefix := base[:len(base):len(base)]
a := append(prefix, 3) // cap 已限制为 len,append 必须申请新数组
b := append(prefix, 4)它比“先 append 看看会不会扩容”可靠,因为是否隔离已经写进了代码语义。
预分配时,长度和容量别写反
下面的代码最后会得到 6 个元素,前 3 个是零值:
result := make([]int, 3)
for _, value := range []int{1, 2, 3} {
result = append(result, value)
}如果准备通过 append 填充,应当把长度设为 0,只预留容量:
result := make([]int, 0, 3)
for _, value := range []int{1, 2, 3} {
result = append(result, value)
}如果结果长度一开始就确定,则分配长度并按下标写入,少一次长度增长判断:
result := make([]int, 3)
for i, value := range []int{1, 2, 3} {
result[i] = value
}小切片可能拖住一个大对象
GC 判断的是底层数组是否仍可达,而不是当前切片的长度。下面的函数虽然只返回 32 个字节,返回值仍可能让整个响应体留在堆上:
func prefix(payload []byte) []byte {
if len(payload) <= 32 {
return payload
}
return payload[:32]
}如果大数组后续不再需要,应复制真正需要的部分:
func prefix(payload []byte) []byte {
n := min(len(payload), 32)
result := make([]byte, n)
copy(result, payload[:n])
return result
}该不该复制取决于所有权和生命周期,而不是切片大小。短生命周期热路径中,复制可能是浪费;缓存、队列和跨请求保存的数据则要格外警惕内存滞留。
nil 切片和空切片
var s []T 与 s := []T{} 都满足 len(s) == 0,也都可以 range 和 append。差别主要出现在协议边界:某些编码器会把它们分别编码为 null 和 []。
业务内部通常不必区分;对外 JSON、数据库字段或补丁语义需要区分时,应在边界处明确规范,不要让调用方猜。
切片不会自动获得并发安全
一个 goroutine 执行 append,另一个读取同一切片,即使“只是追加”也可能产生数据竞争。append 会修改长度,还可能复制底层数组;共享描述符或共享数组的并发访问都需要同步。
当多个 goroutine 产生结果时,常见做法是让每个 goroutine 写自己的局部变量,最后由单个 goroutine 汇总;或者预先分配固定长度,每个 goroutine 只写互不重叠的下标。后者仍要确保没有 goroutine 同时修改切片长度。
判断切片代码是否可靠
看到切片跨越函数、缓存、队列或 goroutine 边界时,问四件事:
- 谁拥有底层数组,谁允许修改?
- append 是否可能复用调用方的容量?
- 返回的小切片会不会延长大数组生命周期?
- 是否存在对同一底层数组的并发访问?
切片的性能来自共享,切片的大部分坑也来自共享。只要把所有权说清楚,问题就少了一半。