目录

Go-05 数组与切片原理

数组(array)和切片(slice)是 Go 中最基础也最容易踩坑的两个数据结构。数组是值类型,赋值即拷贝;切片是对底层数组的一个"视图",看似灵活,但共享底层数组、扩容、内存泄漏等问题让无数人栽过跟头。本章从底层结构讲起,把 append 扩容、共享数组陷阱、内存泄漏一次讲透。


1. 数组(Array)

1.1 定义与基本用法

数组是固定长度元素类型相同的连续内存块。长度是类型的一部分,[3]int[4]int 是两种不同的类型。

package main

import "fmt"

func main() {
	// 声明并初始化
	var a [3]int              // 零值:[0 0 0]
	b := [3]int{1, 2, 3}      // 字面量
	c := [...]int{1, 2, 3, 4} // 让编译器推断长度,len=4

	// 指定索引初始化(稀疏数组)
	d := [5]int{0: 10, 4: 40} // [10 0 0 0 40]

	fmt.Println(a, b, c, d)
	fmt.Printf("c 的长度:%d,类型:%T\n", len(c), c) // 4, [4]int
}

[...]int{...} 中的 ... 表示由编译期根据元素个数确定长度,本质仍是定长数组,不要与切片混淆。

1.2 值类型 / 值拷贝语义

数组是值类型。赋值、函数传参都会产生一次完整的元素拷贝,两个变量互不影响。

func main() {
	a := [3]int{1, 2, 3}
	b := a       // 完整拷贝一份
	b[0] = 100   // 只改 b

	fmt.Println(a) // [1 2 3]  ← a 不受影响
	fmt.Println(b) // [100 2 3]

	// 地址不同,证明是两块内存
	fmt.Printf("%p %p\n", &a, &b) // 不同地址
}

这与切片、map、channel(引用/指针语义)完全不同,是初学者最常混淆的点。

1.3 数组作为函数参数的拷贝开销

因为是值拷贝,把大数组直接传给函数会复制整个数组,既慢又浪费内存,且函数内的修改不会反映到外部。

// 值传递:拷贝整个数组,内部修改无效
func modifyByValue(arr [5]int) {
	arr[0] = 999 // 改的是副本
}

// 指针传递:只拷贝 8 字节指针,内部修改生效
func modifyByPtr(arr *[5]int) {
	arr[0] = 999 // 直接改原数组(Go 允许 arr[0] 语法糖,等价 (*arr)[0])
}

func main() {
	a := [5]int{1, 2, 3, 4, 5}

	modifyByValue(a)
	fmt.Println(a) // [1 2 3 4 5]  ← 没变

	modifyByPtr(&a)
	fmt.Println(a) // [999 2 3 4 5] ← 变了
}

拷贝开销示例:把一个 [1024]int(8KB)传值给函数,每次调用都拷贝 8KB。实际开发中几乎总是用切片代替大数组作参数,正是为了避免这种拷贝。

1.4 多维数组

func main() {
	// 二维数组:3 行 4 列,一整块连续内存(行优先存储)
	var grid [3][4]int
	grid[1][2] = 7

	// 字面量初始化
	matrix := [2][3]int{
		{1, 2, 3},
		{4, 5, 6},
	}
	fmt.Println(matrix) // [[1 2 3] [4 5 6]]
}

注意:[3][4]int 是"元素为 [4]int 的长度 3 的数组",整块内存连续,与"切片的切片"([][]int,每行独立分配)截然不同。

1.5 数组可比较

若元素类型可比较,则数组可用 == / != 直接比较(逐元素比较),这也是数组能作为 map 的 key 的原因。切片则不可比较(只能和 nil 比)。

func main() {
	a := [3]int{1, 2, 3}
	b := [3]int{1, 2, 3}
	c := [3]int{1, 2, 4}

	fmt.Println(a == b) // true  逐元素相等
	fmt.Println(a == c) // false

	// 数组可作 map key
	m := map[[2]int]string{
		{0, 0}: "origin",
		{1, 1}: "diagonal",
	}
	fmt.Println(m[[2]int{0, 0}]) // origin

	// s := []int{1,2,3}
	// s == s              // 编译错误:slice can only be compared to nil
}

2. 切片是什么

2.1 三要素结构体

切片本身不是动态数组,而是一个描述"底层数组某一段"的结构体(runtime 中的 slice):

// runtime/slice.go 中的定义(简化)
type slice struct {
	array unsafe.Pointer // 指向底层数组的指针(起始元素)
	len   int            // 长度:当前可访问的元素个数
	cap   int            // 容量:从 array 起到底层数组末尾的元素个数
}

一个切片变量在 64 位平台上固定占 24 字节(3 个字,指针 8 + len 8 + cap 8),无论它"装"了多少元素。

2.2 ASCII 图示

s := make([]int, 3, 5)   // len=3, cap=5

切片头 slice header(24 字节 = array 8 + len 8 + cap 8)
┌──────────────┐
│ array = ptr  │
├──────────────┤
│ len   = 3    │
├──────────────┤
│ cap   = 5    │
└──────────────┘
   |  array = ptr 指向底层数组首元素
   v
底层数组(长度 5,连续内存)
┌────┬────┬────┬────┬────┐
│  0 │  0 │  0 │ ?? │ ?? │
└────┴────┴────┴────┴────┘
  |<--- len=3 --->|
  |<------- cap=5 ------->|

- 通过 s[i] 只能访问 [0, len) 区间,越界 s[3] 会 panic
- 但底层数组其实有 5 个格子,append 时可复用后面 2 个空位

2.3 切片与数组的区别

维度 数组 [N]T 切片 []T
长度 固定,是类型一部分 动态,可增长
类型语义 值类型(拷贝全部元素) 引用语义(拷贝 header,共享底层数组)
传参开销 拷贝所有元素 拷贝 24 字节 header
可比较 可(元素可比较时) 不可(只能比 nil)
作 map key 可以 不可以
零值 各元素零值 nil

3. 切片的创建

3.1 四种创建方式

func main() {
	// 1. 字面量:底层数组由编译器自动创建,len=cap=3
	s1 := []int{1, 2, 3}

	// 2. make(类型, len):len=cap=3,元素为零值
	s2 := make([]int, 3)

	// 3. make(类型, len, cap):len=2, cap=5,预留容量
	s3 := make([]int, 2, 5)

	// 4. 从数组/切片切取
	arr := [5]int{10, 20, 30, 40, 50}
	s4 := arr[1:4] // [20 30 40],len=3

	fmt.Println(s1, len(s1), cap(s1)) // [1 2 3] 3 3
	fmt.Println(s2, len(s2), cap(s2)) // [0 0 0] 3 3
	fmt.Println(s3, len(s3), cap(s3)) // [0 0] 2 5
	fmt.Println(s4, len(s4), cap(s4)) // [20 30 40] 3 4
}

3.2 切取的 cap 从哪来

s := arr[low:high] 切取后:

  • len = high - low
  • cap = cap(原) - low(即从 low 起,一直到底层数组末尾)
func main() {
	arr := [5]int{10, 20, 30, 40, 50}
	s := arr[1:3]                  // [20 30]
	fmt.Println(len(s), cap(s))    // len=2, cap=4(从索引1到末尾共4个)

	// cap 之内可以再切回来,看到"隐藏"的元素
	s2 := s[:cap(s)]               // [20 30 40 50]
	fmt.Println(s2)
}

3.3 三索引:s[low:high:max] 控制 cap

Go 支持三索引切取,用第三个参数限制容量

  • len = high - low
  • cap = max - low
func main() {
	arr := [5]int{10, 20, 30, 40, 50}

	// 两索引:cap 一直到末尾
	a := arr[1:3]        // len=2, cap=4

	// 三索引:cap 被限制到 max=3
	b := arr[1:3:3]      // len=2, cap=2

	fmt.Println(len(a), cap(a)) // 2 4
	fmt.Println(len(b), cap(b)) // 2 2
}

三索引最大的用途:防止 append 意外写到后面共享的数据(见第 5 节)。

3.4 nil 切片 vs 空切片

func main() {
	var s1 []int          // nil 切片:array=nil, len=0, cap=0
	s2 := []int{}         // 空切片:array 指向一个 runtime 空数组, len=0, cap=0
	s3 := make([]int, 0)  // 空切片

	fmt.Println(s1 == nil) // true
	fmt.Println(s2 == nil) // false
	fmt.Println(s3 == nil) // false

	// 二者行为几乎一致:len 都是 0,都能 append
	fmt.Println(len(s1), len(s2)) // 0 0
	s1 = append(s1, 1)            // 对 nil 切片 append 完全合法
	fmt.Println(s1)               // [1]
}

实践建议

  • 声明"稍后追加"的切片时用 var s []T(nil 切片),零分配、更省。
  • 只有在 JSON 序列化需要区分 null[] 时才在意二者差异(nil 序列化为 null,空切片序列化为 [])。
  • 判断"是否为空"永远用 len(s) == 0,不要用 s == nil,因为空切片不等于 nil。

4. 切片扩容机制

4.1 append 原理

append 的核心逻辑:

1. 若 len < cap:直接把新元素写到 array[len],len++(不分配新内存)
2. 若 len == cap:容量不够
   a. 计算新容量 newcap(按扩容规则)
   b. 分配一块新的底层数组
   c. 把旧数据拷贝到新数组
   d. 追加新元素,返回指向新数组的切片

关键点:扩容后底层数组换了地址,所以必须用 s = append(s, x) 接收返回值,否则改动丢失。

func main() {
	s := make([]int, 0, 2)
	fmt.Printf("初始   ptr=%p len=%d cap=%d\n", s, len(s), cap(s))

	s = append(s, 1)
	s = append(s, 2) // 此时 len==cap==2,未扩容
	fmt.Printf("满容量 ptr=%p len=%d cap=%d\n", s, len(s), cap(s))

	s = append(s, 3) // 触发扩容,ptr 改变
	fmt.Printf("扩容后 ptr=%p len=%d cap=%d\n", s, len(s), cap(s))
}

4.2 扩容规则的演进

Go 1.18 之前

1024 为阈值的"倍增"策略:

if 原cap < 1024:
    newcap = 原cap * 2
else:
    newcap = 原cap * 1.25   // 每次增长 25%

问题:在 1024 附近,扩容倍率从 2 骤降到 1.25,存在明显"断崖",导致某些场景下扩容抖动。

Go 1.18 之后(平滑扩容)

阈值改为 256,并让 1.25 到 2 之间平滑过渡:

// runtime/slice.go growslice 核心逻辑(Go 1.18+,简化)
const threshold = 256
newcap := oldcap
if newLen > 2*oldcap {
	newcap = newLen // 一次追加太多,直接用需要的长度
} else if oldcap < threshold {
	newcap = 2 * oldcap // 小切片仍按 2 倍
} else {
	// 大切片:在 1.25x 基础上平滑加一个常数,逐步逼近 1.25 倍
	for 0 < newcap && newcap < newLen {
		newcap += (newcap + 3*threshold) / 4
	}
}

(newcap + 3*256)/4 相当于 newcap*1.25 + 192,让增长曲线从"小切片 2 倍"平滑过渡到"大切片 1.25 倍",消除了 1024 处的断崖。

场景 Go 1.17 及之前 Go 1.18 及之后
阈值 1024 256
阈值以下 2 倍 2 倍
阈值以上 固定 1.25 倍 平滑逼近 1.25 倍(含 +192 偏移)

4.3 内存对齐 roundupsize

算出 newcap 后并不是最终容量。runtime 还会调用 roundupsize 把"所需字节数"向上取整到 Go 内存分配器(mspan)的规格档位(size class),以减少内存碎片。因此实测的 cap 常常比理论值更大

func main() {
	var s []int
	last := cap(s)
	for i := 0; i < 2048; i++ {
		s = append(s, i)
		if cap(s) != last {
			fmt.Printf("len=%-5d cap: %-5d -> %d\n", len(s), last, cap(s))
			last = cap(s)
		}
	}
}

Go 1.22 下 []int(元素 8 字节)的典型输出(受 size class 对齐影响):

len=1     cap: 0     -> 1
len=2     cap: 1     -> 2
len=3     cap: 2     -> 4
len=5     cap: 4     -> 8
len=9     cap: 8     -> 16
len=17    cap: 16    -> 32
...
len=257   cap: 256   -> 512      ← 256 以内是 2 倍
len=513   cap: 512   -> 848      ← 超过 256 转为约 1.25 倍 + 偏移,再对齐
len=849   cap: 848   -> 1280

可以看到:256 之前是标准 2 倍;之后增长率明显下降到约 1.25 倍,且经过内存对齐后是一些"不太整齐"的数字(如 848、1280)。不同 Go 版本、不同元素大小结果会有出入,切勿硬编码依赖具体 cap 值


5. 切片共享底层数组的陷阱

这是切片最大的"暗坑"来源:多个切片可能指向同一块底层数组,一个改了,其他都跟着变。

5.1 append 意外修改"原数组"

func main() {
	arr := [5]int{1, 2, 3, 4, 5}
	s := arr[1:3] // [2 3],len=2, cap=4(共享 arr)

	// s 还有富余容量(cap=4>len=2),append 直接写进 arr[3]!
	s = append(s, 100)

	fmt.Println(s)   // [2 3 100]
	fmt.Println(arr) // [1 2 3 100 5]  ← arr[3] 被 append 悄悄改成了 100!
}

5.2 多个切片共享同一底层数组

func main() {
	base := []int{1, 2, 3, 4, 5}
	s1 := base[0:3] // [1 2 3]
	s2 := base[2:5] // [3 4 5],与 s1 在索引 2 处重叠

	s1[2] = 999 // 改 s1[2] 就是改 base[2]

	fmt.Println(s1)   // [1 2 999]
	fmt.Println(s2)   // [999 4 5]  ← s2[0] 也变了!
	fmt.Println(base) // [1 2 999 4 5]
}

5.3 用三索引避免越界写

回到 5.1,如果切取时用三索引把 cap 卡死,append 就会被迫扩容分配新数组,不再污染原数组

func main() {
	arr := [5]int{1, 2, 3, 4, 5}
	s := arr[1:3:3] // len=2, cap=2(cap 卡到 3)

	s = append(s, 100) // cap 满了,扩容到新数组

	fmt.Println(s)   // [2 3 100]
	fmt.Println(arr) // [1 2 3 4 5]  ← arr 安然无恙
}

经验法则:当你把一个切片的子片"交出去"(作为返回值 / 传给外部),且不希望对方 append 时踩到你的数据,用 s[low:high:high] 把 cap 收紧。

5.4 copy 实现深拷贝

想要一份完全独立、互不影响的数据,用 copy 或(Go 1.21+)slices.Clone

import "slices"

func main() {
	src := []int{1, 2, 3}

	// 方式一:make + copy
	dst := make([]int, len(src))
	n := copy(dst, src) // 返回实际拷贝的元素个数 = min(len(dst), len(src))
	fmt.Println(dst, n) // [1 2 3] 3

	// 方式二:slices.Clone(Go 1.21+,一行搞定)
	dst2 := slices.Clone(src)

	dst[0] = 999
	fmt.Println(src)  // [1 2 3]  ← 原切片不受影响
	fmt.Println(dst)  // [999 2 3]
	fmt.Println(dst2) // [1 2 3]
}

copy(dst, src) 只拷贝 min(len(dst), len(src)) 个元素,不会自动扩容 dst,这点务必注意——若 dst 是 nil 或空切片,copy 什么都不会做。


6. 切片增删元素技巧

6.1 追加

func main() {
	s := []int{1, 2}
	s = append(s, 3)          // 追加单个:[1 2 3]
	s = append(s, 4, 5)       // 追加多个:[1 2 3 4 5]

	other := []int{6, 7}
	s = append(s, other...)   // 展开追加另一切片:[1 2 3 4 5 6 7]
	fmt.Println(s)
}

6.2 插入(在索引 i 处插入 x)

// 在索引 i 处插入元素 x
func insert(s []int, i, x int) []int {
	s = append(s, 0)          // 先扩一个位置
	copy(s[i+1:], s[i:])      // i 及之后整体后移一格
	s[i] = x                  // 填入新值
	return s
}

func main() {
	s := []int{1, 2, 4, 5}
	s = insert(s, 2, 3) // 在索引 2 插入 3
	fmt.Println(s)      // [1 2 3 4 5]

	// Go 1.21+ 标准库
	// s = slices.Insert(s, 2, 3)
}

6.3 删除

// 删除索引 i 的元素(保持顺序)
func remove(s []int, i int) []int {
	return append(s[:i], s[i+1:]...) // 后面的元素前移覆盖
}

// 删除索引 i(不保序,O(1):用末尾元素填补)
func removeFast(s []int, i int) []int {
	s[i] = s[len(s)-1]
	return s[:len(s)-1]
}

func main() {
	s := []int{1, 2, 3, 4, 5}
	fmt.Println(remove(s, 2))     // [1 2 4 5]
	// Go 1.21+:slices.Delete(s, 2, 3)
}

注意:保序删除会移动内存,若被删元素是指针/含指针的结构体,末尾会残留一份"游离引用",应手动置零后再截断,避免内存泄漏(见第 8 节)。

6.4 去重(保序,原地)

func dedup(s []int) []int {
	if len(s) < 2 {
		return s
	}
	seen := map[int]struct{}{s[0]: {}}
	res := s[:1]
	for _, v := range s[1:] {
		if _, ok := seen[v]; !ok {
			seen[v] = struct{}{}
			res = append(res, v)
		}
	}
	return res
}

func main() {
	fmt.Println(dedup([]int{1, 2, 2, 3, 3, 3, 1})) // [1 2 3]

	// 若已排序,Go 1.21+ 有标准库:slices.Compact(s)
}

6.5 栈与队列

func main() {
	// 栈(LIFO)
	stack := []int{}
	stack = append(stack, 1, 2, 3) // push
	top := stack[len(stack)-1]     // peek: 3
	stack = stack[:len(stack)-1]   // pop
	fmt.Println("pop:", top, "剩余:", stack)

	// 队列(FIFO)
	queue := []int{1, 2, 3}
	front := queue[0]  // 队首
	queue = queue[1:]  // 出队(注意:底层数组头部空间不会立即释放)
	fmt.Println("出队:", front, "剩余:", queue)
}

队列用 queue = queue[1:] 出队时,底层数组前面的空间无法被复用(切片头往后移了),长期运行会浪费内存。高频队列场景建议用环形缓冲区或 container/list

6.6 slices 包(Go 1.21+)

Go 1.21 起标准库 slices 提供了大量泛型工具,能替代大量手写代码:

import "slices"

func main() {
	s := []int{3, 1, 2, 1}

	slices.Sort(s)                       // 排序 -> [1 1 2 3]
	fmt.Println(slices.Contains(s, 2))   // true
	fmt.Println(slices.Index(s, 2))      // 2
	fmt.Println(slices.Max(s), slices.Min(s)) // 3 1

	s2 := slices.Clone(s)                // 深拷贝
	fmt.Println(slices.Equal(s, s2))     // true

	s = slices.Insert(s, 0, 99)          // 插入
	s = slices.Delete(s, 1, 2)           // 删除 [1,2) 区间
	s = slices.Compact(s)                // 去除相邻重复(需先排序)
	fmt.Println(s)
}
函数 作用
slices.Sort / SortFunc 排序
slices.Contains / Index 查找
slices.Insert / Delete 插入 / 删除
slices.Clone 深拷贝底层数组
slices.Equal 逐元素比较
slices.Compact 去除相邻重复元素
slices.Max / Min 极值

7. 切片作为函数参数

7.1 引用语义:改元素生效

切片传参拷贝的是 24 字节的 header,但 array 指针仍指向同一块底层数组,所以函数内修改已有元素对外可见:

func setFirst(s []int) {
	s[0] = 999 // 通过共享的底层数组,改动可见
}

func main() {
	s := []int{1, 2, 3}
	setFirst(s)
	fmt.Println(s) // [999 2 3]  ← 生效
}

7.2 经典坑:函数内 append 可能不生效

函数拿到的是 header 的副本append 修改的是副本的 len(甚至换了 array 指针),这些都写不回调用方的原变量:

func addOne(s []int) {
	s = append(s, 999) // 只改了局部副本的 len / array
}

func main() {
	s := []int{1, 2, 3}
	addOne(s)
	fmt.Println(s) // [1 2 3]  ← append 没生效!
}

原因分析:

调用方 s:  {array=A, len=3, cap=3}
              │ 拷贝 header
              ▼
函数内 s:  {array=A, len=3, cap=3}
   append 触发扩容 → 分配新数组 B
函数内 s:  {array=B, len=4, cap=6}   ← 只有副本变了
调用方 s:  {array=A, len=3, cap=3}   ← 原封不动

即便未扩容(有富余 cap),函数内 len 变成 4 也写不回调用方(调用方 len 仍是 3),所以看不到新元素。

7.3 两种正确写法

// 写法一:返回新切片,调用方接收(最常用,最地道)
func addOne(s []int) []int {
	return append(s, 999)
}

// 写法二:传切片指针
func addOnePtr(s *[]int) {
	*s = append(*s, 999)
}

func main() {
	s := []int{1, 2, 3}

	s = addOne(s)      // 接收返回值
	fmt.Println(s)     // [1 2 3 999]

	addOnePtr(&s)      // 传指针
	fmt.Println(s)     // [1 2 3 999 999]
}

首选写法一(返回新切片),这也是标准库 append 本身的设计哲学。只有在需要频繁修改、或修改多个切片字段时才考虑传指针。


8. 内存泄漏问题

8.1 大切片截取小切片

切片截取共享底层数组。如果从一个巨大的切片里截出很小一段并长期持有,整个大底层数组都无法被 GC 回收,因为小切片仍引用着它。

// 危险:返回值只要 5 个元素,却让整个 100 万元素的数组无法回收
func firstFive(huge []int) []int {
	return huge[:5] // 底层数组仍是那个 100 万的数组!
}

func main() {
	huge := make([]int, 1_000_000)
	small := firstFive(huge)
	_ = small
	// huge 逻辑上不再使用,但 small 引用着它的底层数组
	// → 8MB 内存无法释放
}

含指针的元素(如 []*User[]string)问题更严重:不仅数组本身,被引用的每个对象也都无法回收。

8.2 解决方案:copy / slices.Clone

拷贝出一份"刚好够用"的新底层数组,切断对大数组的引用:

import "slices"

// 修复方案一:make + copy
func firstFiveSafe(huge []int) []int {
	res := make([]int, 5)
	copy(res, huge[:5]) // 独立的小数组,大数组可被回收
	return res
}

// 修复方案二:slices.Clone(Go 1.21+)
func firstFiveClone(huge []int) []int {
	return slices.Clone(huge[:5])
}

8.3 删除元素时的"游离指针"泄漏

删除含指针元素时,末尾残留的引用会阻止对象回收,应显式置零:

func removeUser(users []*User, i int) []*User {
	copy(users[i:], users[i+1:])
	users[len(users)-1] = nil // ★ 关键:清除末尾残留引用,允许 GC 回收
	return users[:len(users)-1]
}

Go 1.21+ 的 slices.Delete 已经内部帮你把尾部空出的槽位清零,优先使用它。


9. 性能优化

9.1 预分配 cap 减少扩容

已知(或能估算)元素个数时,用 make([]T, 0, n) 预分配容量,一次到位,避免多次扩容 + 拷贝

func main() {
	const N = 100000

	// ❌ 不预分配:从 0 一路扩容,多次分配 + 拷贝
	bad := []int{}
	for i := 0; i < N; i++ {
		bad = append(bad, i)
	}

	// ✅ 预分配:一次分配到位,append 全程零扩容
	good := make([]int, 0, N)
	for i := 0; i < N; i++ {
		good = append(good, i)
	}
	_ = bad
	_ = good
}

Benchmark 对比(N=100000,示意):

BenchmarkNoPrealloc   xxx ns/op   ~800KB allocated, 多次 grow
BenchmarkPrealloc     yyy ns/op   一次 allocated       ← 快 2~5 倍

9.2 避免频繁 append / 无谓拷贝

  • 循环内向同一切片 append 时,把预分配提到循环外。
  • 需要"清空并复用"切片时,用 s = s[:0](保留底层数组容量),而不是 s = nil 或重新 make
buf := make([]int, 0, 1024)
for round := 0; round < 100; round++ {
	buf = buf[:0] // 复用底层数组,len 归零、cap 保留,零分配
	for i := 0; i < 1000; i++ {
		buf = append(buf, i)
	}
	// 处理 buf ...
}

9.3 sync.Pool 复用大切片

对生命周期短、创建频繁的大切片(如网络读写 buffer),用 sync.Pool 复用,减轻 GC 压力:

import "sync"

var bufPool = sync.Pool{
	New: func() any {
		b := make([]byte, 0, 4096) // 预置 4KB 容量
		return &b                  // 存指针,避免装箱额外分配
	},
}

func handle(data []byte) {
	bufPtr := bufPool.Get().(*[]byte)
	buf := (*bufPtr)[:0] // 复用,清空 len
	defer func() {
		*bufPtr = buf
		bufPool.Put(bufPtr)
	}()

	buf = append(buf, data...)
	// ... 使用 buf 处理数据 ...
}

注意:Pool 里存指针 *[]byte 而非 []byte,避免 Get/Put 时切片头的额外堆分配(interface 装箱)。


10. 高频面试题

Q1:slice 的底层结构是什么?占多少字节?

由三部分组成:指向底层数组的指针 array、长度 len、容量 cap。在 64 位平台上切片头固定 24 字节(3×8),与元素个数无关。len 是当前可访问元素数,cap 是从起始指针到底层数组末尾的元素数。

Q2:切片的扩容规则?1.18 前后有什么变化?

  • 1.18 前:阈值 1024,小于 1024 翻 2 倍,大于等于 1024 每次增 1.25 倍——在 1024 处有断崖。
  • 1.18 后:阈值降为 256,小于 256 仍翻 2 倍,大于等于 256 按 newcap += (newcap + 3*256)/4(约 1.25 倍 + 偏移)平滑增长,消除断崖。
  • 两个版本最后都会经 roundupsize内存对齐,所以实测 cap 通常大于理论值。

Q3:切片是值传递,为什么函数里能改元素,append 却不生效?

切片传参传的是 header 的副本,但副本里的 array 指针与原切片指向同一底层数组,所以 s[i]=x 这类修改已有元素对外可见。而 append 改的是副本的 len(甚至扩容后换了 array 指针),这些变化只写在副本上,无法回传给调用方,所以看起来"没生效"。解决:s = append(...) 接收返回值,或传 *[]T

Q4:下面代码输出什么?为什么?

arr := [5]int{1, 2, 3, 4, 5}
s := arr[1:3]          // len=2, cap=4
s = append(s, 100)
fmt.Println(arr)       // ?

输出 [1 2 3 100 5]。因为 s 的 cap=4 > len=2,append 无需扩容,直接把 100 写进底层数组的 arr[3],污染了原数组。用 arr[1:3:3] 三索引把 cap 卡死即可避免。

Q5:nil 切片和空切片有什么区别?

  • nil 切片(var s []int):array=nils == nil 为 true,JSON 序列化为 null
  • 空切片([]int{}make([]int,0)):array 指向一个非 nil 的空数组,s == nil 为 false,JSON 序列化为 []
  • 二者 len 都是 0,都能安全 append。判断是否为空一律用 len(s)==0

Q6:切片会导致内存泄漏吗?怎么解决?

会。从大切片截取一个小切片并长期持有时,小切片仍引用整个大底层数组,导致大数组(及其中的指针对象)无法被 GC 回收。解决:用 make+copyslices.Clone 拷贝出独立的小数组,切断对大数组的引用。删除含指针元素时还要把尾部残留槽位置为 nil。

Q7:数组和切片的核心区别?

数组是值类型,长度是类型一部分,赋值/传参拷贝全部元素,可比较、可作 map key;切片是引用语义的 header(指针+len+cap),长度动态、可 append 扩容,不可比较、不可作 map key。工程中作为函数参数几乎总用切片以避免大数组拷贝。

Q8:copy(dst, src) 拷贝几个元素?

拷贝 min(len(dst), len(src)) 个元素。它不看 cap,也不会自动扩容 dst。如果 dst 是 nil 或 len(dst)==0,则一个都拷不进去——这是新手最常见的 copy 失效原因。


小结

  • 数组是值类型:赋值、传参都完整拷贝元素;长度是类型的一部分;可比较、可作 map key。大数组传参应改用切片或指针。
  • 切片是三要素 header(array 指针 / len / cap,24 字节),本质是底层数组的"视图",引用语义。
  • 创建方式:字面量、make(len[, cap])、切取 s[low:high:max](三索引控制 cap);区分 nil 切片与空切片,判空用 len==0
  • 扩容机制:Go 1.18 把阈值从 1024 降为 256 并平滑过渡(约 1.25 倍 + 偏移),最终还要经 roundupsize 内存对齐,实测 cap 常大于理论值。务必用 s = append(...) 接收返回值。
  • 共享底层数组是最大的坑:append 可能污染原数组、多切片互相影响;用三索引卡 cap、用 copy/slices.Clone 深拷贝来隔离。
  • 函数传参:能改元素但 append 不生效,靠返回新切片或传 *[]T 解决。
  • 内存泄漏:大切片截小片长期持有会拖住整块底层数组;用 copy/Clone 切断引用,删除指针元素记得置 nil。
  • 优化:预分配 cap、s[:0] 复用、sync.Pool 复用大 buffer;Go 1.21+ 用 slices 包写更简洁安全的代码。

理解"切片 = 指向底层数组的视图"这一点,几乎所有切片相关的 bug 和面试题都能迎刃而解。下一章我们进入 Map 与哈希表原理。