Go slice 底层实现原理全解析
· 2598 words · ~ 13 min read
Go 的 slice 是日常开发中最常用的数据结构之一,但它的很多行为——为什么 append 有时会让两个变量"断开",为什么 copy 需要目标 slice 有足够长度,为什么 nil slice 和空 slice 在某些场合表现不同——如果不了解底层机制,遇到时容易踩坑。
本文从 runtime 源码出发,完整梳理 slice 的数据结构、创建、append、copy、子切片、扩容算法,并给出每个操作的执行流程图和内存布局示意。
数据结构
Go 运行时里 slice 对应 runtime/slice.go 中的 slice 结构体:
|
|
编译器对外暴露的是 reflect.SliceHeader(与 slice 内存布局完全一致):
|
|
一个 slice 变量在栈上占 24 字节(64 位平台:3 × 8 字节),存的是"描述符"而不是数据本身。
|
|
创建方式
字面量
|
|
编译器在静态区分配初始数组,运行时构造一个 slice 描述符指向它:
|
|
make
|
|
|
|
执行路径:
flowchart TD
A["make([]int, 3, 6)"] --> B{编译器判断能否\n在栈上分配}
B -->|小对象且不逃逸| C[栈上分配]
B -->|大对象或逃逸| D["runtime.makeslice()"]
D --> E["math.MulUintptr(et.size, cap)\n检查溢出"]
E --> F["mallocgc(size, et, true)\n分配并清零内存"]
F --> G[返回 unsafe.Pointer]
G --> H["构造 slice{array, len, cap}"]
C --> H
makeslice 的关键源码:
|
|
从数组切片
|
|
编译器直接用 arr[1] 的地址构造 slice 描述符,不分配新内存:
|
|
子切片(re-slice)
|
|
re-slice 不分配新内存,两个描述符指向同一底层数组:
|
|
关键推论:修改 s2[0] 等于修改 s1[2],因为它们指向同一内存地址。
三索引切片(full slice expression)
|
|
通过限制 cap,可以防止子切片意外修改父 slice 后面的数据。
append 操作
append 是 Go slice 中最复杂的操作,分两条路径:
flowchart TD
A["append(s, elems...)"] --> B{"len + len(elems) <= cap?"}
B -->|是,容量足够| C["将元素写入底层数组\ns[len:len+n]"]
C --> D[更新 len,返回新描述符]
B -->|否,需要扩容| E["growslice()"]
E --> F[计算新容量 newcap]
F --> G["mallocgc() 分配新底层数组"]
G --> H[memmove 拷贝旧数据]
H --> I[写入新元素]
I --> J["更新 len, cap, array\n返回新描述符"]
容量足够时 append:
|
|
|
|
容量不足时 append:
|
|
|
|
扩容算法(growslice)
Go 1.18 对扩容算法做了调整,以下是 Go 1.18+ 的逻辑:
|
|
扩容策略流程图:
flowchart TD
A["需要容量 cap,当前 old.cap"] --> B{"cap > 2 × old.cap?"}
B -->|是| C[newcap = cap]
B -->|否| D{"old.cap < 256?"}
D -->|是| E["newcap = 2 × old.cap\n(直接翻倍)"]
D -->|否| F["循环:newcap += (newcap + 768) / 4\n直到 newcap >= cap\n(从 2x 平滑收敛到 1.25x)"]
C --> G[内存对齐,按 size class 向上取整]
E --> G
F --> G
G --> H["mallocgc() 分配新数组"]
H --> I[memmove 拷贝旧数据]
Go 1.18 之前临界值是 1024,之后改为 256,目的是让中等大小的 slice 扩容更平滑。
copy 操作
|
|
copy 底层调用 runtime.memmove,按元素类型的大小做内存拷贝:
|
|
执行路径:
flowchart TD
A["copy(dst, src)"] --> B["n = min(len(dst), len(src))"]
B --> C{n == 0?}
C -->|是| D[返回 0]
C -->|否| E{elementSize == 0?}
E -->|是,零大小类型| F[返回 n,无需拷贝]
E -->|否| G["memmove(dst.array, src.array, n × elementSize)"]
G --> H[返回 n]
copy 的内存行为示意:
|
|
nil slice 与 empty slice
|
|
内存布局对比:
|
|
runtime.zerobase 是一个全局的零大小变量,所有空 slice 和空 map 的 array 指针都指向它,避免了 0 字节分配对 GC 造成压力。
关键差异:
| 操作 | nil slice | empty slice |
|---|---|---|
len(s) |
0 | 0 |
cap(s) |
0 | 0 |
s == nil |
true | false |
append(s, 1) |
可以 | 可以 |
json.Marshal(s) |
"null" |
"[]" |
json.Marshal 是最常见的陷阱:返回 null 还是 [] 取决于 slice 是否为 nil。
完整操作时序图
sequenceDiagram
participant User as 用户代码
participant Compiler as 编译器
participant Runtime as 运行时 (runtime)
participant GC as GC / 内存分配器
User->>Compiler: make([]int, 3, 6)
Compiler->>Runtime: makeslice(*_type, 3, 6)
Runtime->>Runtime: 检查 len/cap 合法性
Runtime->>GC: mallocgc(6×8, ..., true)
GC-->>Runtime: unsafe.Pointer(清零内存)
Runtime-->>User: slice{ptr, 3, 6}
User->>Compiler: append(s, 10, 20, 21, 22)
Compiler->>Runtime: 计算 need = 3+4=7 > cap=6
Runtime->>Runtime: growslice(*_type, old, 7)
Runtime->>Runtime: newcap = 12(6×2,因 6<256)
Runtime->>GC: mallocgc(12×8, ...)
GC-->>Runtime: newptr
Runtime->>Runtime: memmove(newptr, oldptr, 3×8)
Runtime-->>User: slice{newptr, 7, 12}
User->>Compiler: copy(dst, src)
Compiler->>Runtime: slicecopy(dst.array, dst.len, src.array, src.len, 8)
Runtime->>Runtime: n = min(len(dst), len(src))
Runtime->>Runtime: memmove(dst.array, src.array, n×8)
Runtime-->>User: n
常见陷阱
append 后两个变量"断开"
|
|
子切片导致大数组无法被 GC 回收
|
|
并发写 slice 不安全
多个 goroutine 同时 append 到同一 slice 没有任何内置同步,必须用 sync.Mutex 或 channel 保护。slice 描述符本身的赋值(写 len/cap/array 三个字段)也不是原子操作。
make 的 len 陷阱:append 不从头开始
|
|
如果只想预分配容量,应该用 make([]int, 0, 5)。用 make([]int, 5) 再 append 是最常见的初学者陷阱之一。
函数传参:外部 slice 不会被内部 append 改变
slice 描述符是值传递,函数内对 len/cap/array 的修改不会反射到调用方:
|
|
如果需要在函数内修改 slice 本身(长度、容量、底层数组),需要传指针或返回新 slice:
|
|
注意:函数内直接修改元素(s[0] = 99)是有效的,因为共享底层数组;改变 len/cap 才需要指针或返回值。
append 两个 slice 时意外覆盖
当 s1 有剩余容量时,append(s1, s2...) 会直接写入 s1 底层数组,同时影响所有共享该数组的 slice:
|
|
根源是 base 的 cap 足够,两次 append 都在同一块内存上写。修复方式是每次 append 前先断开共享:
|
|
range 遍历拿到的是值拷贝
|
|
对元素类型较大的 slice,range 还会带来额外的拷贝开销,可以改用下标遍历或存指针的 slice。
删除元素的正确姿势
Go 没有内置的删除方法,常见有两种写法:
删除下标 i 的元素,保持顺序:
|
|
删除下标 i 的元素,不保持顺序(性能更好):
|
|
第一种写法会把 i 之后的元素全部左移,时间复杂度 O(n)。第二种用最后一个元素填坑,O(1)。
对于存指针或含指针字段的元素,删除后还需要清零尾部元素防止内存泄漏:
|
|
循环中 goroutine 捕获 slice 元素地址
|
|
Go 1.22 之前循环变量只有一个,所有 goroutine 共享同一个 v。修复方式:
|
|
Go 1.22 起每次迭代的循环变量是独立的,此问题不再出现。
对 nil slice 调用 append 是安全的,但赋值 index 不是
|
|
nil slice 可以 append,但不能直接通过下标赋值——这两者的行为经常被混淆。
含指针元素的 slice 增加 GC 扫描压力
GC 在标记阶段需要扫描所有可达指针。[]int 不含指针,GC 直接跳过底层数组;[]*MyStruct 或 []interface{} 含指针,GC 必须逐元素扫描。
|
|
如果 slice 元素数量巨大且生命周期长(如全局缓存),优先用值类型而不是指针类型,或者考虑 []uint64 + 手动序列化来彻底绕开 GC 扫描。
截断 slice 后尾部指针不会被 GC 回收
|
|
正确做法是清零截断部分:
|
|
Go 1.21 引入了内置 clear,可以一行完成:
|
|
奇淫技巧
零拷贝 string 与 []byte 互转
标准转换 []byte(str) 和 string(b) 都会分配新内存并拷贝。对性能敏感的路径可以用 unsafe 跳过拷贝:
|
|
使用约束:转换后的 []byte 绝对不能写入,因为 string 底层内存是只读的,写入会触发 panic 或数据竞争。只读操作(序列化、哈希、比较)是安全的。
Go 1.20 之前的写法依赖 reflect.SliceHeader,但该类型在 Go 1.17 起已标记为不推荐,优先用 unsafe.Slice / unsafe.SliceData。
原地 filter,零分配
通常 filter 写法会分配新 slice:
|
|
原地复用底层数组,零分配:
|
|
注意:原地 filter 会修改原底层数组,如果外部还有其他 slice 共享它,需要先 copy 一份。
用 append 实现 insert
Go 没有内置 insert,用 append + copy 组合:
|
|
原地反转
双指针从两端向中间逼近,每次交换一对元素,直到两指针相遇。
flowchart TD
A["reverse(s)"] --> B["i = 0\nj = len(s) - 1"]
B --> C{i < j ?}
C -->|否,相遇或交叉| D[完成,返回]
C -->|是| E["swap s[i] ↔ s[j]"]
E --> F["i++\nj--"]
F --> C
以 [1, 2, 3, 4, 5] 为例,每一轮的内存状态:
|
|
偶数长度(如 [1,2,3,4])时,两指针在中间错过(i=2, j=1,i > j)自然终止,不会多做一次交换。
完整实现(泛型,适用任意可比类型):
|
|
时间复杂度 O(n/2) = O(n),空间复杂度 O(1),原地操作不分配内存。
用 append 实现双端队列
标准库没有双端队列,用 slice 可以快速实现一个,适合数据量不大、不追求极致性能的场景。
数据结构
|
|
底层就是一个普通 slice,不需要额外字段。
四个操作的实现与内存行为
PushBack(尾插)
|
|
flowchart TD
A["PushBack(v)"] --> B{"len < cap ?"}
B -->|是| C["array[len] = v\nlen++"]
B -->|否| D["growslice:分配新数组\nmemmove 拷贝旧数据"]
D --> E["array[len] = v\nlen++,更新 array/cap"]
C --> F[返回]
E --> F
|
|
PushFront(头插)
|
|
flowchart TD
A["PushFront(v)"] --> B["分配临时 []T{v}\n(长度 1 的新数组)"]
B --> C["append([]T{v}, *d...)\n必然触发扩容(cap 只有 1)"]
C --> D["growslice:分配 1+len(d) 大小的新数组"]
D --> E["array[0] = v"]
E --> F["memmove 将旧数据复制到 array[1:]"]
F --> G["更新描述符,len = old_len + 1"]
G --> H[返回]
|
|
PushFront 每次必然分配新数组,时间复杂度 O(n)。频繁头插的场景,应该改用环形缓冲区或链表。
PopBack(尾删)
|
|
flowchart TD
A["PopBack()"] --> B["v = array[len-1]"]
B --> C["len--(缩短描述符,不释放内存)"]
C --> D["返回 v"]
|
|
若元素是指针类型,尾部的指针不会被 GC 回收,需要手动清零:
|
|
PopFront(头删)
|
|
flowchart TD
A["PopFront()"] --> B["v = array[0]"]
B --> C["array 指针前移一个元素\nlen--,cap--"]
C --> D["返回 v"]
|
|
PopFront 的隐患:每次头删只是把 array 指针向后移,底层数组头部的内存永远不会被 GC 回收,持续 PopFront 会造成内存"单向泄漏"。对于只做尾插头删(FIFO 队列)场景,泄漏会随操作次数线性增长。
解决办法一:PopFront 后定期把剩余数据 copy 到新 slice:
|
|
解决办法二:改用环形缓冲区实现,头尾都是 O(1) 且无泄漏,参考 golang.org/x/exp/slices 或自行实现。
各操作复杂度对比
| 操作 | 时间复杂度 | 是否分配内存 |
|---|---|---|
| PushBack | O(1) 均摊 | 仅扩容时 |
| PushFront | O(n) | 每次 |
| PopBack | O(1) | 否 |
| PopFront | O(1) | 否(但有内存泄漏风险) |
slice 实现的双端队列适合"大量尾插、少量头删"或"临时用用"的场景。若头尾操作频率相近,推荐换用基于环形缓冲区的实现。
三索引切片做防御性传参
向函数传 slice 时,如果不希望被调用方通过 append 意外写入父 slice 的后续空间:
|
|
这在实现类似 io.Reader 的缓冲分发逻辑时非常有用。
利用 copy 返回值快速求重叠长度
|
|
有时比显式 if 判断更简洁,且语义清晰。
预分配是最有效的性能手段
|
|
不知道精确大小时,给一个合理的上界估计也比完全不预分配好。append 的均摊复杂度虽然是 O(1),但每次扩容都有 memmove 开销,数据量大时差距明显。
小结
| 操作 | 分配新内存 | 共享底层数组 | 时间复杂度 |
|---|---|---|---|
| 字面量创建 | 是 | 否 | O(n) |
| make | 是 | 否 | O(n)(清零) |
re-slice a[i:j] |
否 | 是 | O(1) |
| append(不扩容) | 否 | 是 | O(k) |
| append(扩容) | 是 | 否 | O(n) |
| copy | 否(目标已有) | 否 | O(min(n,m)) |
理解 slice 的核心是区分"描述符"和"底层数组":描述符是值拷贝,底层数组是引用语义。append 触发扩容时会切断共享,不触发时不会。这条规律能解释绝大多数 slice 相关的困惑行为。
参考: