切片底层结构
1. 从数组到切片
Go 的数组是固定长度的连续内存块。实际工程中我们极少直接用数组,因为长度是类型的一部分------[3]int 和 [5]int 是不同类型,无法互相赋值,也无法动态扩容。
切片解决的就是"长度可变"的问题。但切片并非动态数组那么简单,它的本质是一个指向底层数组的引用描述符------它自己不存数据,只存"数据在哪里、有多长、容量多大"。
2. SliceHeader 三元组
Go 运行时用 reflect.SliceHeader 描述切片的内部结构(Go 1.22 起源码中用 unsafe.Slice,但概念一致):
python
┌──────────────────────────────────┐
│ slice 变量 (24 字节) │
├──────────┬──────────┬─────────────┤
│ Data │ Len │ Cap │
│ (指针) │ (长度) │ (容量) │
│ 8 bytes │ 8 bytes │ 8 bytes │
└────┬─────┴──────────┴─────────────┘
│
▼
┌───┬───┬───┬───┬───┬───┬───┬───┐
│ 0 │ 1 │ 2 │ 3 │ 4 │ 5 │ 6 │ 7 │ ← 底层数组
└───┴───┴───┴───┴───┴───┴───┴───┘
←── Len=3 ──→ ←──── Cap=8 ────→
三个字段的含义:
| 字段 | 类型 | 作用 |
|---|---|---|
Data |
*Array (unsafe.Pointer) |
指向底层数组首个元素的指针 |
Len |
int |
当前切片中有效元素的数量 |
Cap |
int |
底层数组从 Data 指针位置开始,可供切片使用的总容量 |
关键认知:切片本身只占 24 字节(64 位系统),无论它引用了多少数据。传参时拷贝的是这 24 字节的描述符,底层数组不会被复制。
3. 切片 vs 数组:一字之差
go
a := [5]int{10, 20, 30, 40, 50} // 数组:长度是类型的一部分
s := []int{10, 20, 30, 40, 50} // 切片:长度不参与类型
[5]int 是数组,在栈上分配(逃逸分析决定),传参时整块拷贝。[]int 是切片,传参时只拷贝 24 字节的 header。
4. 多切片共享底层数组
这是切片最核心的特性,也是最容易踩坑的地方:对底层数组做切片操作,产生的新切片和原切片共享同一块内存。
go
s := []int{1, 2, 3, 4, 5}
a := s[1:3] // a = [2, 3],共享底层数组
a[0] = 99 // 修改 a 的第一个元素
// s[1] 也变成了 99!
画个图:
ini
s.Data ─────────────────┐
▼
┌───┬───┬───┬───┬───┐
│ 1 │ 99│ 3 │ 4 │ 5 │ 底层数组
└───┴───┴───┴───┴───┘
▲
a.Data ─┘ (a.Len=2, a.Cap=4)
s: Len=5, Cap=5
a: Len=2, Cap=4 (从索引1到末尾)
5. nil 切片 vs 空切片
这俩在 JSON 序列化时表现不同,但 len() 都返回 0。
| 声明方式 | Data 指针 | Len | Cap | s == nil |
|---|---|---|---|---|
var s []int |
nil | 0 | 0 | true |
s := []int{} |
非 nil | 0 | 0 | false |
s := make([]int, 0) |
非 nil | 0 | 0 | false |
nil 切片的 Data 指针是 nil,空切片的 Data 指针指向一个非 nil 的零长度内存块。在 encoding/json 中,nil 切片序列化为 null,空切片序列化为 []。
6. 实战代码:用 unsafe 验证切片结构
下面的代码用 unsafe.Pointer 直接读取切片 header 的三个字段,并用指针算术验证多个切片确实指向同一块底层数组。
go
package main
import (
"fmt"
"unsafe"
)
// sliceHeader 模拟 reflect.SliceHeader 的结构
type sliceHeader struct {
Data uintptr // 底层数组指针
Len int // 长度
Cap int // 容量
}
// headerOf 通过 unsafe.Pointer 将切片转换为 sliceHeader
// 注意:这不会拷贝底层数组,只是把 24 字节的 header 重新解释
func headerOf(s []int) sliceHeader {
return *(*sliceHeader)(unsafe.Pointer(&s))
}
func main() {
// === 1. 验证 SliceHeader 三元组 ===
s := make([]int, 3, 8)
s[0], s[1], s[2] = 10, 20, 30
h := headerOf(s)
fmt.Println("=== 切片结构验证 ===")
fmt.Printf("Data 指针: 0x%x\n", h.Data)
fmt.Printf("Len: %d, Cap: %d\n", h.Len, h.Cap)
fmt.Printf("切片变量大小: %d 字节\n", unsafe.Sizeof(s))
// === 2. 验证多切片共享底层数组 ===
fmt.Println("\n=== 共享底层数组验证 ===")
original := []int{1, 2, 3, 4, 5, 6, 7, 8}
sub1 := original[1:4] // [2, 3, 4]
sub2 := original[3:6] // [4, 5, 6]
h0 := headerOf(original)
h1 := headerOf(sub1)
h2 := headerOf(sub2)
fmt.Printf("original: Data=0x%x Len=%d Cap=%d\n", h0.Data, h0.Len, h0.Cap)
fmt.Printf("sub1: Data=0x%x Len=%d Cap=%d\n", h1.Data, h1.Len, h1.Cap)
fmt.Printf("sub2: Data=0x%x Len=%d Cap=%d\n", h2.Data, h2.Len, h2.Cap)
// 验证指针偏移:sub1.Data 应该比 original.Data 大 1 个 int(8 字节)
elemSize := unsafe.Sizeof(original[0])
fmt.Printf("\n元素大小: %d 字节\n", elemSize)
fmt.Printf("sub1.Data - original.Data = %d 字节 (预期: %d)\n",
h1.Data-h0.Data, 1*elemSize)
fmt.Printf("sub2.Data - original.Data = %d 字节 (预期: %d)\n",
h2.Data-h0.Data, 3*elemSize)
// === 3. 验证修改 sub1 会影响 original ===
fmt.Println("\n=== 共享修改验证 ===")
sub1[0] = 99
fmt.Printf("sub1[0] = 99 后,original[1] = %d (预期 99)\n", original[1])
// === 4. nil 切片 vs 空切片 ===
fmt.Println("\n=== nil vs 空切片 ===")
var nilSlice []int
emptySlice := []int{}
madeSlice := make([]int, 0)
hn := headerOf(nilSlice)
he := headerOf(emptySlice)
hm := headerOf(madeSlice)
fmt.Printf("var s []int: Data=0x%x Len=%d Cap=%d nil=%v\n", hn.Data, hn.Len, hn.Cap, nilSlice == nil)
fmt.Printf("s := []int{}: Data=0x%x Len=%d Cap=%d nil=%v\n", he.Data, he.Len, he.Cap, emptySlice == nil)
fmt.Printf("make([]int, 0): Data=0x%x Len=%d Cap=%d nil=%v\n", hm.Data, hm.Len, hm.Cap, madeSlice == nil)
// === 5. 三段切片操作对 Cap 的影响 ===
fmt.Println("\n=== 切片操作对 Cap 的影响 ===")
full := make([]int, 10, 10)
for i := range full {
full[i] = i + 1
}
// 从索引 2 切到索引 5
mid := full[2:5] // Len=3, Cap=8 (从索引2到末尾)
// 用三段索引限制容量
limited := full[2:5:5] // Len=3, Cap=3 (明确限制)
hm1 := headerOf(mid)
hl := headerOf(limited)
fmt.Printf("full[2:5]: Len=%d Cap=%d\n", hm1.Len, hm1.Cap)
fmt.Printf("full[2:5:5]: Len=%d Cap=%d (三段索引限制容量)\n", hl.Len, hl.Cap)
}
7. 运行结果预期
ini
=== 切片结构验证 ===
Data 指针: 0x1400012e030
Len: 3, Cap: 8
切片变量大小: 24 字节
=== 共享底层数组验证 ===
original: Data=0x... Len=8 Cap=8
sub1: Data=0x... Len=3 Cap=7
sub2: Data=0x... Len=3 Cap=5
...
sub1.Data - original.Data = 8 字节 (预期: 8)
sub2.Data - original.Data = 24 字节 (预期: 24)
=== 共享修改验证 ===
sub1[0] = 99 后,original[1] = 99 (预期 99)
=== nil vs 空切片 ===
var s []int: Data=0x0 Len=0 Cap=0 nil=true
s := []int{}: Data=0x... Len=0 Cap=0 nil=false
make([]int, 0): Data=0x... Len=0 Cap=0 nil=false
=== 切片操作对 Cap 的影响 ===
full[2:5]: Len=3 Cap=8
full[2:5:5]: Len=3 Cap=3 (三段索引限制容量)
8. 知识要点总结
- 切片是引用描述符:24 字节的 header(Data 指针 + Len + Cap),不存储实际数据。
- 共享底层数组:切片操作产生的新切片与原切片共享内存,修改会互相影响。
- Cap 的含义:从 Data 指针位置到底层数组末尾的可用空间。
- 三段索引
[low:high:max]:可以用第三个参数限制新切片的 Cap,防止意外扩容时覆盖后续数据。 - nil 切片 vs 空切片 :nil 切片的 Data 为 nil,空切片的 Data 非 nil。JSON 序列化时 nil→
null,空→[]。 - 传参不拷贝数据:切片传参只拷贝 24 字节的 header,底层数组始终共享。
9. 常见陷阱
陷阱 1:切片传参后修改影响原数据
go
func modify(s []int) {
s[0] = 999 // 会修改调用方的底层数组!
}
如果不想影响原数据,需要用 copy() 显式拷贝。
陷阱 2:append 后原切片可能"看不到"新元素
go
s := make([]int, 3, 5)
s = append(s, 100)
// s 的 Len 变成 4,但调用方传进来的切片 Len 还是 3
// 如果 append 没触发扩容,修改的是共享的底层数组
// 如果触发扩容,Data 指针变了,原切片完全不受影响
陷阱 3:大切片做小切片导致内存泄漏
go
big := make([]byte, 1000000)
small := big[10:20] // small.Cap = 999990,底层数组不会被 GC
// big 的整个底层数组因为 small 还引用着,无法回收
// 解决:copy 出来
small = make([]byte, 10)
copy(small, big[10:20])