slice 的底层结构是什么?它的扩容机制是怎样的?
结论先行
slice 是对底层数组的“视图”,运行时由 3 个字段构成:指向数组首元素的指针、长度 len、容量 cap(对应 runtime.slice);当 append 发现 len == cap 时触发扩容:分配一块更大的新数组、把旧元素拷过去,再返回指向新数组的新 slice。
要点
- 三字段视图:type slice struct { array unsafe.Pointer; len int; cap int },64 位平台上结构体本身只有 24 字节。
- 越界保护:下标访问会做边界检查(bounds check),越界直接 panic;编译器会尽量消除可证明安全的检查。
- 扩容阈值(Go 1.18 起):所需容量超过旧 cap 的 2 倍时直接取所需容量;否则旧 cap < 256 时翻倍,≥ 256 时按 newcap += (newcap+3*256)/4 增长(约 1.25 倍),并做溢出保护。
- 容量对齐:算出的 newcap 还要按内存分配规格(size class)向上取整,因此实际 cap 往往大于理论值。
- 扩容即“搬家”:扩容后 slice 指向新数组,旧 slice 仍指向旧数组,两者从此互不影响。
- 未扩容时共享:len < cap 的 append 直接写原数组,两个 slice 对元素的修改互相可见。
- 预分配的意义:make([]int, 0, n) 提前给足容量,可避免多次扩容带来的多次拷贝与分配。
示例
s := make([]int, 3, 3) // len=3 cap=3
old := s
s = append(s, 1) // 触发扩容:换新数组
fmt.Println(old[0], s[3]) // 0 1:old 与 s 已无关
t := []int{1, 2, 3}
u := t[:2] // 共享同一底层数组
u[0] = 100
fmt.Println(t[0]) // 100:共享可见
fmt.Println(cap(t), cap(u)) // 3 3
常见追问/记忆点
- 追问:扩容为什么不是固定 2 倍?答:兼顾大 slice 的内存浪费,超过 256 后增长斜率降到约 1.25,仍是“指数增长”但更省内存。
- 追问:sub-slice 会一直持有大数组吗?答:会,只要没扩容就引用同一数组,这是常见的内存占用隐患。
- 追问:append 一定修改原 slice 吗?答:不一定;容量够时原地写,不够时换新数组,所以 append 结果必须重新赋值给变量。
- 记忆:扩容“可能会搬家”;不确定要不要共享就拷贝;能预估就 make 时给足 cap。