Go的切片是日常开发中最常用的数据结构之一,但很多人对它的底层机制一知半解,踩坑后才回头补课。本文从源码层面拆解切片的内存布局和扩容策略。
数组 vs 切片
数组是定长的值类型,赋值和传参都会发生完整拷贝:
a := [3]int{1, 2, 3}
b := a // 值拷贝,b 和 a 是两块独立内存
b[0] = 100
fmt.Println(a[0]) // 1,不受影响
切片是对底层数组的一个引用视图,本身是一个轻量结构体。
SliceHeader 结构
reflect.SliceHeader 定义了切片在运行时的内存布局:
type SliceHeader struct {
Data uintptr // 指向底层数组的指针
Len int // 当前长度
Cap int // 当前容量
}
一个切片变量在栈上只占 24 字节(64位系统),传参成本很低。
底层数组共享
多个切片可以引用同一个底层数组,修改其中一个会影响另一个:
arr := [5]int{1, 2, 3, 4, 5}
s1 := arr[1:3] // [2, 3], len=2, cap=4
s2 := arr[2:4] // [3, 4], len=2, cap=3
s1[1] = 99
fmt.Println(s2[0]) // 99,因为 s1[1] 和 s2[0] 指向 arr[2]
这是切片最容易踩坑的地方。如果需要独立的数据,用 copy 或 append 到一个新切片。
append 扩容机制
当 append 时 len 即将超过 cap,Go 会分配新的底层数组。扩容策略在 runtime/slice.go 的 growslice 函数中:
// Go 1.18+ 的扩容逻辑(简化)
func growslice(oldCap, newCap int) int {
if newCap > 2*oldCap {
return newCap
}
if oldCap < 256 {
return 2 * oldCap // 小切片直接翻倍
}
// 大切片按 1.25 倍增长,逐步递增
cap := oldCap
for cap < newCap {
cap += cap/4 + 192
}
return cap
}
核心规则:
- 如果新长度超过旧容量的2倍,直接用新长度
- 旧容量小于 256 时,翻倍
- 否则按约 1.25 倍增长(加上一个常数做平滑过渡)
验证扩容行为
package main
import "fmt"
func main() {
s := make([]int, 0)
prevCap := 0
for i := 0; i < 2000; i++ {
s = append(s, i)
if cap(s) != prevCap {
fmt.Printf("len=%-5d cap=%-5d growth=%.2f\n",
len(s), cap(s), float64(cap(s))/float64(max(prevCap, 1)))
prevCap = cap(s)
}
}
}
func max(a, b int) int {
if a > b { return a }
return b
}
运行结果会清晰展示:小容量阶段倍增,大容量阶段增长率逐渐降到 1.25 左右。
常见陷阱
1. append 后原切片可能失效
s := make([]int, 3, 3)
s2 := append(s, 4) // 触发扩容,s2 指向新数组
s[0] = 100
fmt.Println(s2[0]) // 0,不是 100
2. 切片作为函数参数
切片传参传的是 SliceHeader 的拷贝。函数内修改元素会影响外部(共享底层数组),但 append 导致的扩容不会反映到外部变量:
func addItem(s []int) {
s = append(s, 999) // 如果触发扩容,外部看不到
}
要让外部感知变化,返回新切片或传指针 *[]int。
3. 大数组的内存泄漏
// 读取一个大文件到 data
data := readHugeFile() // len=1000000
// 只取前10个字节
header := data[:10]
// data 的底层数组无法被 GC,因为 header 还在引用
解决方案:copy 到一个新的小切片。
小结
理解 SliceHeader 的三元组(Data/Len/Cap)是掌握切片的关键。记住两个原则:多个切片可能共享底层数组;append 可能导致底层数组更换。写代码时遇到切片相关的 bug,先画出内存布局图,问题往往迎刃而解。