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 - lowcap = 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 - lowcap = 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=nil,s == nil为 true,JSON 序列化为null。 - 空切片(
[]int{}或make([]int,0)):array指向一个非 nil 的空数组,s == nil为 false,JSON 序列化为[]。 - 二者
len都是 0,都能安全 append。判断是否为空一律用len(s)==0。
Q6:切片会导致内存泄漏吗?怎么解决?
会。从大切片截取一个小切片并长期持有时,小切片仍引用整个大底层数组,导致大数组(及其中的指针对象)无法被 GC 回收。解决:用 make+copy 或 slices.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 与哈希表原理。
xingliuhua