切片是 Go 里最容易被误用的数据结构之一。很多人知道「append 可能触发扩容」,但真正写业务代码时,仍然会踩进同一个坑:函数里对切片 append 之后,调用方的数据没变;或者两个切片共享底层数组,改一个另一个跟着变,排查半天才发现是别名(alias)问题。这篇文章不讲概念复述,而是给出可运行的判定手段:怎么用 unsafe 拿到底层数组指针来判断扩容是否发生、cap 的增长遵循什么规律、copy 的复制语义在重叠与非重叠场景下分别如何表现,以及生产代码里应该怎么规避这类问题。
一、先把切片的三元组和「扩容」说透
切片在运行时是一个三元组:array unsafe.Pointer(底层数组首地址)、len int、cap int。这一点决定了切片是值类型,但它的数据是引用语义——切片头(slice header)被复制,底层数组不会。
关键结论:append 是否修改原切片,取决于这次 append 有没有触发扩容(底层数组重新分配)。如果没有扩容,append 直接写进原数组的 len 位置,此时原切片头看不到这个元素(因为它的 len 没变),但底层数组已经被改了;如果扩容了,append 会分配新数组、把旧数据拷过去,原切片完全不受影响。
所以「切片是引用传递」这句话只在没有扩容时成立,这是所有坑的根源。下面用 unsafe 把这件事变成可观测的。
二、用 unsafe 判定底层数组指针是否变化
切片的底层数组首地址可以用 unsafe.SliceData(Go 1.20+ 引入)直接取,比 &s[0] 更安全,因为空切片也不会 panic。旧版本可以用 unsafe.Pointer(&s[0]),但空切片会越界,需要先判空。
package main
import (
"fmt"
"unsafe"
)
// ptr 返回切片底层数组首地址;空切片返回 nil
func ptr[T any](s []T) unsafe.Pointer {
return unsafe.Pointer(unsafe.SliceData(s))
}
func main() {
s := make([]int, 0, 4)
fmt.Printf("init: len=%d cap=%d ptr=%p\n", len(s), cap(s), ptr(s))
s = append(s, 1, 2, 3, 4)
fmt.Printf("fill: len=%d cap=%d ptr=%p\n", len(s), cap(s), ptr(s))
s = append(s, 5) // 触发扩容
fmt.Printf("grow: len=%d cap=%d ptr=%p\n", len(s), cap(s), ptr(s))
s = append(s, 6) // 未触发扩容(cap 足够)
fmt.Printf("same: len=%d cap=%d ptr=%p\n", len(s), cap(s), ptr(s))
}
在 Go 1.22 / linux/amd64 上运行,输出形如:
init: len=0 cap=4 ptr=0xc0000181e0
fill: len=4 cap=4 ptr=0xc0000181e0
grow: len=5 cap=8 ptr=0xc000018200
same: len=6 cap=8 ptr=0xc000018200
这里能读出三件事:第一,fill 阶段 cap 已经用满,指针没变;第二,第 5 个元素触发扩容,cap 从 4 变成 8(小于 256 时按 2 倍增长),指针变了;第三,cap 扩容后还有余量,第 6 个元素没有再分配。
注意 ptr 用了泛型约束 [T any],unsafe.SliceData 在 Go 1.20 之前不存在,如果你的项目锁在 1.19,把泛型版本换成下面这个:
func intPtr(s []int) unsafe.Pointer {
if len(s) == 0 {
return nil
}
return unsafe.Pointer(&s[0])
}
生产代码里不建议长期依赖 unsafe,更好的判定方式是拿 &s[:cap(s)][cap(s)-1] 之类的地址比较,但指针比较最直接。调试期用 unsafe 是完全可接受的,只是别把它写进核心业务逻辑。
三、扩容规律:别背结论,跑一组数据
网上流传的「2 倍扩容、超过 1024 后 1.25 倍」是 Go 1.17 及之前的实现。Go 1.18 起,运行时把增长公式改成了:新容量小于 256 时按 2 倍增长,大于等于 256 时反复乘以 (1 + 1/4) 直到满足需求,并做一些内存对齐(roundupsize)修正。不同元素类型和操作系统页大小会影响最终取整结果,所以不要背常数,写一段程序打印你当前版本的实测值最可靠:
package main
import "fmt"
func main() {
var s []int
prevCap := cap(s)
for i := 0; i < 1025; i++ {
s = append(s, i)
if cap(s) != prevCap {
fmt.Printf("len=%4d cap %4d -> %4d\n", len(s), prevCap, cap(s))
prevCap = cap(s)
}
}
}
在 Go 1.22 / linux/amd64 下,你能看到 1、2、4、8、16、32、64、128、256、512、848、1280……这样的跳变。注意 256 之后并不是严格 1.25 倍:256 之后期望是 512(不对,256 是 2 倍阶段边界),512 之后乘 1.25 得 640,加上内存对齐修正后实际会更大,达到 848。这种细节你只要知道「有修正」即可,实际容量以 cap() 为准。
另一个常被忽略的事实:扩容是 O(n) 操作,因为要 memmove 整个底层数组。如果在循环里对一个大切片反复 append 又不预分配 cap,复杂度会从 O(n) 劣化为 O(n²)。正确做法是 make([]T, 0, n),或者在已知上界时直接 make([]T, n) 用下标赋值。
四、copy 语义验证:长度按 min(src, dst) 截断
copy(dst, src) 的定义是:复制 min(len(dst), len(src)) 个元素,返回实际复制数量;它不看 cap,也不改变 dst 的长度。这三点构成了绝大多数 copy 误用的原因。
package main
import "fmt"
func main() {
src := []int{1, 2, 3, 4, 5}
// 场景一:dst 长度小于 src,只复制前面 len(dst) 个
dst1 := make([]int, 3)
n := copy(dst1, src)
fmt.Printf("dst1 len=%d cap=%d n=%d data=%v\n", len(dst1), cap(dst1), n, dst1)
// 场景二:dst 长度大于 src,只覆盖 src 覆盖到的部分
dst2 := make([]int, 8)
n = copy(dst2, src)
fmt.Printf("dst2 len=%d cap=%d n=%d data=%v\n", len(dst2), cap(dst2), n, dst2)
// 场景三:cap 足够但 len 为 0,copy 复制 0 个
dst3 := make([]int, 0, 5)
n = copy(dst3, src)
fmt.Printf("dst3 len=%d cap=%d n=%d data=%v\n", len(dst3), cap(dst3), n, dst3)
}
输出:
dst1 len=3 cap=3 n=3 data=[1 2 3]
dst2 len=8 cap=8 n=5 data=[1 2 3 4 5 0 0 0]
dst3 len=0 cap=5 n=0 data=[]
场景三是高频 bug 现场:开发者 make([]int, 0, 5) 之后 copy,结果一个元素都没复制进去,还以为 copy 失效了。make([]T, 0, n) 的 len 是 0,copy 按 len 算,当然复制 0 个。要复制满就得写成 make([]int, len(src)),或者 dst = append(dst, src...)。
重叠复制:copy 的行为与 memmove 一致
当 src 和 dst 指向同一底层数组且有重叠时,copy 的语义类似 memmove:它保证结果是「按逻辑上先读到原始值」的正确结果,不是 memcpy 那种未定义行为。验证一下:
package main
import "fmt"
func main() {
s := []int{1, 2, 3, 4, 5}
// 后移:把 [0:3] 复制到 [2:5]
copy(s[2:], s[0:3])
fmt.Println("shift right:", s) // [1 2 1 2 3]
s2 := []int{1, 2, 3, 4, 5}
// 前移:把 [2:5] 复制到 [0:3]
copy(s2[0:], s2[2:5])
fmt.Println("shift left:", s2) // [3 4 5 4 5]
}
两个结果都是符合直觉的「快照式复制」,说明运行时做了正确处理。但要注意:依赖重叠 copy 的代码可读性极差,除非在做环形缓冲区、字节流搬迁这类明确场景,否则建议先用 append([]T(nil), src...) 或新建切片再搬。
五、三个真实会踩的坑与修复方案
坑 1:函数内 append 无法回传
func addBad(s []int, v int) {
s = append(s, v) // 只在本地生效,尤其是触发扩容后
}
func addGood(s []int, v int) []int {
return append(s, v)
}
调用方必须用返回值重新赋值:s = addGood(s, 1)。addBad 在未扩容时其实能「偷偷」改到底层数组的 len 位置,但调用方的 len 不会变,读不到,这是更隐蔽的坑。统一用返回值是最省心的写法。
坑 2:切片表达式共享底层数组导致数据污染
base := []int{1, 2, 3, 4, 5}
sub := base[1:3] // len=2 cap=4,与 base 共享数组
sub = append(sub, 99)
fmt.Println(base) // [1 2 3 99 5],base[3] 被覆盖
原因是 base[1:3] 的 cap 是从下标 1 到数组末尾,等于 4,append 不需要扩容,直接写 base[3]。修复方式:用 base[1:3:3] 的三索引切片把 cap 限制为 2,迫使 append 扩容,或者直接做一次深拷贝:
sub := base[1:3:3] // cap=2,append 必然扩容
// 或
sub := append([]int(nil), base[1:3]...)
Go 1.21 起还可以用 slices.Clone(base[1:3]),语义更清晰。
坑 3:把切片当 map 的 value 追加
m := map[string][]int{}
for _, v := range []int{1, 2, 3} {
m["k"] = append(m["k"], v)
}
fmt.Println(m["k"]) // [1 2 3],这条其实是对的
这段是对的,因为 m["k"] = append(...) 每轮都写回。真正错的是忘记写回:append(m["k"], v) 而不赋值——注意 Go 里这样的语句本身编译不过(append 结果未使用),所以这类错误会被编译器拦下。但 for _, s := range m { s = append(s, 1) } 能编译通过,却什么都不改,这才是需要警惕的。
六、工程上的取舍建议
- 能用
make([]T, 0, n)预分配的,一律预分配。循环内 append 大切片时这是最便宜的性能优化。 - 跨函数传递切片、并且函数内会 append 的,一律通过返回值传出,不要依赖副作用。
- 切子切片后还要 append 的,优先使用三索引切片
a[low:high:max]或slices.Clone,把「共享底层数组」这件事变成显式决策。 - 需要真正独立的副本时用
copy(dst, src),但dst必须make([]T, len(src));一行写法是append([]T(nil), src...)。 - 调试扩容问题时用
unsafe.SliceData打印地址,不要靠猜。
小结
切片的「引用语义」只在没有扩容时成立。用 unsafe.SliceData 比对新旧切片头里的数组指针,可以精确判定 append 有没有触发重新分配;copy 按 min(len(dst), len(src)) 复制,与 cap 无关,也不会改动 dst 的 len;重叠复制的行为等同 memmove,结果可预期。把这三点记住,再配合预分配 cap 和返回值传出,切片相关的隐蔽 bug 基本可以消灭。
FAQ
Q:为什么我的 append 有时改了原切片、有时没改?
因为是否扩容取决于 cap - len 的余量,而余量又受之前所有 append、切片表达式、三索引切片影响。不要试图预测,用 unsafe 打印指针,或者干脆通过返回值传递。
Q:make([]int, 0, 5) 和 make([]int, 5) 有什么区别?
前者 len=0、cap=5,元素是「已分配但不可见」;后者 len=5、cap=5,元素是零值且可读写。copy 按 len 工作,对前者复制 0 个元素,这是最常见的误解来源。
Q:扩容后的旧底层数组会被回收吗?
只要还有切片或指针引用旧数组,它就不会被 GC 回收。常见泄漏场景:从一个巨大切片切出一小段长期持有,即使只用几个元素,整个底层数组也会被钉住。解决办法是用 slices.Clone 或 append([]T(nil), sub...) 做一次真实拷贝。
Q:unsafe.SliceData 在空切片上会 panic 吗?
不会,对 nil 或 len 为 0 的切片返回 nil。而 &s[0] 在 len 为 0 时会 panic: index out of range [0] with length 0,这是优先使用前者的直接理由。该函数要求 Go 1.20 及以上。
Q:三索引切片 a[1:3:3] 的第三个参数可以是任意值吗?
不是,必须满足 low <= high <= max <= cap(a),否则运行时 panic(常量下标则在编译期报 invalid slice index)。