多年以后发现年少无知竟是一个美好的形容词,可是等发现的时候已如梦醒无痕
切片(Slice)的本质
切片是 Go 中对数组的抽象,是一个引用类型,包含三个核心字段:
go
// runtime/slice.go 中的实际定义
type slice struct {
array unsafe.Pointer // 指向底层数组的指针
len int // 当前长度(已使用元素个数)
cap int // 容量(从指针位置到底层数组末尾的元素个数)
}
关键理解:切片本身很小(24字节,64位系统),它只是一个"描述符",真正的数据存储在底层数组中。
1. 切片的内存布局
python
┌─────────────────────────────────────┐
│ slice header (24 bytes) │
│ ┌──────────────┬─────┬─────┐ │
│ │ array ptr │ len │ cap │ │
│ │ 8 bytes │ 8B │ 8B │ │
│ └──────────────┴─────┴─────┘ │
└──────────┬──────────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ 底层数组 (backing array) │
│ ┌─────┬─────┬─────┬─────┬─────┐ │
│ │ 0 │ 1 │ 2 │ 3 │ 4 │ │ ← 索引
│ │ 10 │ 20 │ 30 │ 40 │ 50 │ │ ← 值
│ └─────┴─────┴─────┴─────┴─────┘ │
│ ↑ ↑ │
│ array ptr cap │
│ (5) │
└─────────────────────────────────────┘
slice := []int{10, 20, 30, 40, 50}
// len=5, cap=5
2. 切片的创建方式及底层差异
方式一:字面量创建
go
s := []int{1, 2, 3, 4, 5}
// 编译器会:
// 1. 创建一个长度为5的数组 [5]int{1,2,3,4,5}
// 2. 构建 slice header 指向该数组
// 3. len=5, cap=5
方式二:make 创建
go
s := make([]int, 3, 10) // len=3, cap=10
// 底层分配一个长度为10的数组,但只使用前3个
// 索引 0,1,2 可访问(零值),索引 3-9 已分配但不可访问
方式三:从数组切片
go
arr := [5]int{1, 2, 3, 4, 5}
s := arr[1:3] // 切片表达式
// 底层结构:
// array ptr → &arr[1]
// len = 3 - 1 = 2 (元素 2,3)
// cap = 5 - 1 = 4 (从arr[1]到arr[4])
切片表达式语法:
go
a[low:high:max] // 完整形式
// len = high - low
// cap = max - low(若省略max,则cap = 原cap - low)
3. 切片共享底层数组(关键!)
go
package main
import "fmt"
func main() {
arr := [5]int{1, 2, 3, 4, 5}
s1 := arr[0:3] // [1,2,3] len=3, cap=5
s2 := arr[1:4] // [2,3,4] len=3, cap=4
fmt.Println("Before:", s1, s2) // [1 2 3] [2 3 4]
s2[0] = 999 // 修改 s2[0] 实际上是修改 arr[1]
fmt.Println("After:", s1, s2) // [1 999 3] [999 3 4]
fmt.Println("arr:", arr) // [1 999 3 4 5]
}
内存视图:
ini
arr: [1, 2, 3, 4, 5]
↑ ↑
│ │
s1: [0:3] 指向 arr[0], len=3, cap=5
s2: [1:4] 指向 arr[1], len=3, cap=4
修改 s2[0] = 999 → arr[1] = 999
4. 切片的扩容机制(append 原理)
当 append 超过容量时,会触发扩容:
go
package main
import "fmt"
func main() {
s := make([]int, 0, 2) // len=0, cap=2
fmt.Printf("初始: len=%d, cap=%d, ptr=%p\n", len(s), cap(s), s)
s = append(s, 1)
fmt.Printf("append 1: len=%d, cap=%d, ptr=%p\n", len(s), cap(s), s)
s = append(s, 2)
fmt.Printf("append 2: len=%d, cap=%d, ptr=%p\n", len(s), cap(s), s)
s = append(s, 3) // 触发扩容!
fmt.Printf("append 3: len=%d, cap=%d, ptr=%p\n", len(s), cap(s), s)
}
扩容规则(Go 1.18+):
| 原容量 | 新容量计算 |
|---|---|
| cap < 256 | 新 cap = 2 × 原 cap |
| cap ≥ 256 | 新 cap ≈ 1.25 × 原 cap(含平滑处理) |
扩容步骤:
- 申请新的底层数组(更大容量)
- 复制旧数据到新数组
- 添加新元素
- 返回新的 slice header(指针已变!)
5. 切片操作的时间复杂度
| 操作 | 复杂度 | 说明 |
|---|---|---|
| 访问 s[i] | O(1) | 直接指针偏移 |
| append(不扩容) | O(1) | amortized |
| append(扩容) | O(n) | 需要内存分配和复制 |
| copy | O(n) | 按实际复制元素数 |
| 切片表达式 s[i:j] | O(1) | 只创建 header,不复制数据 |
6. 常见陷阱与最佳实践
陷阱1:切片共享导致意外修改
go
func getSlice() []int {
data := []int{1, 2, 3, 4, 5}
return data[1:3] // 返回 [2,3],但底层数组仍指向 data
}
// 危险:如果 data 被 GC 持有或复用,可能导致意外
解决 :使用 copy 创建独立切片
go
func getSafeSlice() []int {
data := []int{1, 2, 3, 4, 5}
result := make([]int, 2)
copy(result, data[1:3]) // 深拷贝,独立底层数组
return result
}
陷阱2:append 导致指针变化
go
func addElement(s []int) {
s = append(s, 100) // 如果扩容,s 指向新数组!
// 这里的修改调用者看不到
}
func main() {
s := make([]int, 0, 2)
addElement(s)
fmt.Println(s) // [],不是 [100]
}
解决:返回切片或使用指针
go
func addElement(s []int) []int {
return append(s, 100)
}
// 或
func addElementPtr(s *[]int) {
*s = append(*s, 100)
}
陷阱3:大数组切片导致内存泄漏
go
var bigArray = make([]byte, 1<<20) // 1MB
// 只想要前10字节,但整个 1MB 数组被引用
small := bigArray[:10]
// bigArray 无法被 GC,因为 small 引用它
解决:copy 出小切片
go
small := make([]byte, 10)
copy(small, bigArray[:10])
// 现在 bigArray 可被 GC
7. 完整内存分析示例
go
package main
import (
"fmt"
"unsafe"
)
func main() {
// 查看 slice header 结构
s := []int{1, 2, 3, 4, 5}
// 使用 unsafe 查看底层(仅用于学习)
ptr := unsafe.Pointer(&s)
header := (*[3]uintptr)(ptr) // [ptr, len, cap]
fmt.Printf("Slice Header: ptr=0x%x, len=%d, cap=%d\n",
header[0], header[1], header[2])
// 验证
fmt.Printf("实际: ptr=%p, len=%d, cap=%d\n",
unsafe.Pointer(&s[0]), len(s), cap(s))
}
8. 核心要点总结
- 切片是引用类型,包含 (ptr, len, cap)
- 赋值和传递参数只复制 header(24字节)
- 切片表达式共享底层数组(浅拷贝)
- append 可能触发扩容(分配新数组)
- 扩容后原切片不受影响(解耦)
- 需要独立数据使用 copy(深拷贝)
理解切片底层是写出高效、安全 Go 代码的关键。记住:切片是描述符,数组才是数据。
