2916 字
约 9 分钟
13
S09-X05 · Go 数组与切片:底层数组与扩容规则
2024-12-31
2026-11-08

切片几乎在每个 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. 自测

  1. base[1:4] 的 cap 是多少?由什么决定?
  2. s[:] 会复制底层数据吗?想真正断开共享该怎么做?
  3. append 之后两个切片还共享底层数组吗?取决于什么条件?
  4. base[0:2:2] 的第三个索引改变了什么?解决什么具体问题?
  5. 函数里改切片元素生效、append 后长度不生效,原因分别是什么?
  6. 一个返回 big[:10] 的函数为什么会造成内存泄漏?怎么修?
S09-X05 · Go 数组与切片:底层数组与扩容规则
https://www.clxhxhhr.top/posts/5775/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。