跳到主要内容

Go 切片 (Slice) 本质深度解析

1. 核心定义:它是"描述符",不是"容器"

切片(Slice)在 Go 中 不是 一个动态数组,而是一个 引用底层数组的"窗口"或"描述符"

当我们定义一个切片时,我们并没有复制数据,只是创建了一个指向数据的 轻量级结构体

1.1 内部结构 (The Slice Header)

在 Go 的运行时(Runtime)和反射(Reflect)包中,切片的本质就是下面这个只有 3 个字段的结构体:

type SliceHeader struct {
Data uintptr // 指针:指向底层数组中切片开始的位置
Len int // 长度:切片当前包含的元素个数 (len())
Cap int // 容量:从 Data 位置开始,到底层数组末尾的元素个数 (cap())
}

这意味着,无论切片引用的数据是 1KB 还是 1GB,切片变量本身的大小固定为 24字节(在 64 位机器上)。

2. 内存模型:切片与底层数组

切片必须依赖于一个 底层数组(Backing Array) 才能存在。

2.1 视觉化模型

假设我们有一个数组 arr = [A, B, C, D, E]。 当我们创建一个切片 s := arr[1:4] (即 [B, C, D]):

  • Data: 指向内存地址 &arr[1] (元素 B)。
  • Len: 3 (包含 B, C, D)。
  • Cap: 4 (包含 B, C, D, E。因为从 B 开始往后数,数组还剩 4 个位置)。

2.2 代码验证

package main

import "fmt"

func main() {
// 1. 定义底层数组
arr := [5]int{10, 20, 30, 40, 50}

// 2. 创建切片
s := arr[1:4] // 对应 [20, 30, 40]

// 3. 修改切片
s[0] = 999

// 4. 观察数组
fmt.Println(arr)
// 输出: [10 999 30 40 50]
// 结论:切片只是窗口,修改切片就是修改底层数组!
}

3. 关键行为:传参机制 (Pass by Value)

Go 语言中 所有参数传递都是值传递(Copy)

当你把一个切片传递给函数时:

  1. Go 会 拷贝 SliceHeader(指针、长度、容量)。
  2. 但是,拷贝后的指针依然指向 同一个 底层数组。

这就是为什么:

  • 在函数内修改元素 (s[i] = x),外部会变(因为改的是底层数组)。
  • 在函数内对切片本身做 append 导致扩容,外部不会变(因为函数内的 SliceHeader 是副本,它的指针指向了新数组,但外部的 SliceHeader 还是指着旧数组)。

4. 扩容机制:Append 的双重人格

append 是切片最复杂也最精妙的地方。它根据 Cap(容量) 决定行为。

4.1 场景 A:容量足够 (In-Place)

如果 len + 1 <= cap

  • 动作:直接在底层数组的下一个位置写入数据。
  • 代价:极低。
  • 副作用会覆盖底层数组该位置原有的数据! 如果有其他切片也共享这段数组,它们的数据也会变。

4.2 场景 B:容量不足 (Reallocation)

如果 len + 1 > cap

  • 动作

    1. 分配一块 更大 的内存空间(通常是原来的 2 倍或 1.25 倍)。
    2. 把老数据 拷贝 过去。
    3. 把新数据追加到末尾。
    4. 修改切片的 Data 指针,指向新家。
  • 代价:较高(内存分配 + 数据拷贝)。

  • 副作用:切片与原来的底层数组 断绝关系。从此修改新切片,不再影响旧切片。

5. 常见陷阱与最佳实践

5.1 陷阱:内存泄漏 (Memory Leak)

场景:你读入了一个 100MB 的文件到内存(大数组),然后只想要其中一小段(比如 1KB)。

func getHeader() []byte {
bigData := loadBigFile() // 100MB
return bigData[:10] // 返回一个小切片
}

后果:虽然你只返回了 10 个字节,但因为这个切片引用了那个大数组,导致那 100MB 内存无法被垃圾回收(GC)

解决:强制拷贝 (Copy)。

func getHeader() []byte {
bigData := loadBigFile()
smallSlice := make([]byte, 10)
copy(smallSlice, bigData[:10]) // 拷贝数据到新数组
return smallSlice // bigData 没人引用了,可以被回收
}

5.2 最佳实践:预分配容量

如果你知道大概需要存多少数据,使用 make 的第三个参数。

  • Bad: s := make([]int, 0) 然后循环 append。这会导致多次扩容(多次搬家,性能差)。
  • Good: s := make([]int, 0, 1000)。底层数组一次分配好,后续 append 只是移动指针,无需搬家。

6. 总结:切片心法

概念核心口诀
结构指长容(指针、长度、容量),这三个决定一切。
传递传的是 钥匙(SliceHeader),仓库(底层数组)是同一个。
修改容量够时是 原地修改,容量不够时是 搬家扩容
性能尽量 预估容量,避免频繁搬家;小心 大引用小,导致内存不释放。

相关阅读