文章目录
一句话总结:
数组是固定长度、值语义的连续内存块;切片是动态长度,由“底层数组指针 + len + cap”组成的结构体。切片传参时拷贝的是这个结构体,但底层数组仍然共享。append在容量足够时直接写,容量不足时触发扩容:计算新容量、内存对齐、分配新数组、复制旧元素。
下面从数组、切片、两者区别,再到切片扩容源码机制,层层递进。
一、数组:固定长度的值类型
数组声明方式:
var a [3]int
b := [3]int{1, 2, 3}
c := [...]int{1, 2, 3} // 编译器推断长度,类型仍是 [3]int
数组的特点:
- 长度固定,编译期确定。
- 长度是类型的一部分,
[3]int和[4]int是不同类型。 - 值类型,赋值和传参都会拷贝整个数组。
- 内存连续,所有元素在内存中挨着存放。
- 可比较,如果元素类型可比较,那么数组可以用
==、!=比较。 - 可以作为
map的 key。
示例:
func modifyArr(a [3]int) {
a[0] = 100
}
func main() {
arr := [3]int{1, 2, 3}
modifyArr(arr)
fmt.Println(arr) // [1 2 3],函数内修改的是副本
}
如果想在函数内修改原数组,要传数组指针:
func modifyArr(a *[3]int) {
a[0] = 100
}
数组的长度是类型的一部分:
var a [3]int
var b [4]int
// a = b // 编译错误:cannot use b (variable of type [4]int) as [3]int
数组适合固定长度的场景,比如:
- 固定大小的矩阵;
- 哈希 key;
- 需要值语义拷贝的小集合。
二、切片:动态长度的“数组视图”
切片声明方式:
var s []int // nil slice
s1 := []int{} // 空切片,但不是 nil
s2 := make([]int, 0, 10) // len=0, cap=10
s3 := []int{1, 2, 3} // len=cap=3
切片底层并不是直接存所有元素,而是存一个结构体。运行时大致是这样的:
type slice struct {
array unsafe.Pointer // 指向底层数组的某个位置
len int // 当前长度
cap int // 当前容量
}
在 64 位机器上,一个切片头大约 24 字节:
8 字节指针 + 8 字节 len + 8 字节 cap。
所以切片本身是值类型,但因为它内部有指针,所以表现出“引用语义”。
1. 切片表达式与容量
arr := [5]int{0, 1, 2, 3, 4}
s := arr[1:4]
fmt.Println(len(s), cap(s)) // 3 4
为什么 cap(s) 是 4?
因为 s 从 arr[1] 开始,到底层数组末尾 arr[4],还有 4 个位置:
arr: [0] [1] [2] [3] [4]
s: ^----------> cap=4
切片表达式规则:
s[low:high]
新切片的:
array指向s[low];len = high - low;cap = cap(s) - low。
如果想限制容量,可以使用三索引切片:
arr := [5]int{0, 1, 2, 3, 4}
s := arr[1:4:4] // len=3, cap=3
cap = max - low = 4 - 1 = 3。
这样后续 append 一旦超过 3,就会扩容,不会影响原数组。
2. nil 切片和空切片
var s1 []int // nil
s2 := []int{} // 非 nil,但 len=0
s3 := make([]int, 0) // 非 nil,但 len=0
fmt.Println(s1 == nil) // true
fmt.Println(s2 == nil) // false
fmt.Println(s3 == nil) // false
区别:
nil切片:len=0,cap=0,可以append,可以遍历。- 空切片:
len=0,cap=0,但不是nil。 - JSON 序列化时:
nil切片变成null,空切片变成[]。
3. 切片共享底层数组
s1 := []int{1, 2, 3, 4, 5}
s2 := s1[1:3] // len=2, cap=4
s2[0] = 100
fmt.Println(s1) // [1 100 3 4 5]
因为 s2 和 s1 共享底层数组。
如果 append 时容量足够,也会修改原数组:
s1 := []int{1, 2, 3, 4, 5}
s2 := s1[1:3] // len=2, cap=4
s2 = append(s2, 99)
fmt.Println(s1) // [1 2 3 99 5]
如果使用三索引限制容量:
s1 := []int{1, 2, 3, 4, 5}
s2 := s1[1:3:3] // len=2, cap=2
s2 = append(s2, 99)
fmt.Println(s1) // [1 2 3 4 5]
fmt.Println(s2) // [2 3 99]
此时 s2 容量不足,触发扩容,不再共享原数组。
三、数组 vs 切片 对比
| 维度 | 数组 [N]T | 切片 []T |
|---|---|---|
| 长度 | 固定,编译期确定 | 动态,运行期可变 |
| 类型 | 长度是类型一部分 | 长度不是类型一部分 |
| 赋值/传参 | 拷贝整个数组 | 拷贝切片头,底层数组共享 |
| 可比较 | 可以比较,若元素可比较 | 不可直接比较,只能和 nil 比 |
| 作为 map key | 可以 | 不可以 |
| 扩容 | 不支持 | append 可触发扩容 |
| 底层结构 | 连续元素 | 指针 + len + cap |
| 使用场景 | 固定长度、值语义 | 动态集合、函数参数、缓冲区 |
一句话:
数组是“一整个盒子”,切片是“盒子的一个窗口 + 长度 + 容量”。
四、切片扩容机制
1. 什么时候扩容?
使用 append 时:
s = append(s, x)
如果追加后长度没有超过容量:
newLen := len(s) + 1
if newLen <= cap(s) {
// 直接写入底层数组
// 返回新的 slice header
}
此时不会分配新数组,只是 len 增加。
如果:
newLen > cap(s)
就会触发扩容,进入 runtime.growslice。
流程简化如下:
append(s, elems...)
|
newLen = len(s) + len(elems)
|
newLen <= cap(s)? --是--> 直接写入,返回 s[:newLen]
|
否
v
growslice:
1. 计算 newcap
2. 内存对齐 roundupsize
3. 分配新数组
4. 复制旧元素
5. 返回新 slice
2. 容量计算策略:Go 1.18+
目前主流 Go 版本,扩容策略大致如下:
newcap := oldCap
doublecap := newcap + newcap
if newLen > doublecap {
newcap = newLen
} else {
const threshold = 256
if oldCap < threshold {
newcap = doublecap
} else {
for newcap < newLen {
newcap += (newcap + 3*threshold) / 4
}
}
}
解释:
-
如果需要的容量超过旧容量的 2 倍,直接取需要的容量。
例如旧容量 2,一次追加 100 个元素,需要 102,那么直接按 102 算。 -
旧容量小于 256:直接翻倍。
例如 1 → 2,2 → 4,4 → 8,8 → 16…… -
旧容量大于等于 256:按近似 1.25 倍平滑增长。
公式是:newcap += (newcap + 3*256) / 4它不是严格 1.25 倍,而是为了从小切片的 2 倍增长平滑过渡到大切片的 1.25 倍增长。
-
官方不保证扩容策略。
不同 Go 版本、不同元素类型、不同架构,实际容量都可能不同,所以不要依赖扩容倍数。
Go 1.17 及以前策略不同,大致是:
- 旧容量小于 1024:翻倍;
- 旧容量大于等于 1024:增长 25%。
3. 内存对齐:实际容量可能比计算值大
计算完 newcap 后,并不是直接分配:
newcap * 元素大小
而是要把字节数向上取整到内存分配器的 size class,这个过程叫 roundupsize。
例如:
s := make([]int, 0)
s = append(s, make([]int, 100)...)
理论需要 100 个 int,64 位下 int 是 8 字节:
100 * 8 = 800 字节
内存分配器可能把 800 字节对齐到 896 字节:
896 / 8 = 112
所以最终 cap(s) 可能不是 100,而是 112。
这就是为什么你看到:
理论扩容 1.25 倍,实际
cap却不是精确的 1.25 倍。
因为还要经过内存对齐。
4. 一个容量增长观察示例
以 64 位、[]int、Go 1.18+ 为例,可能观察到类似序列:
0 -> 1
1 -> 2
2 -> 4
4 -> 8
8 -> 16
16 -> 32
32 -> 64
64 -> 128
128 -> 256
256 -> 512
512 -> 848 左右
848 -> 1280 左右
1280 -> 1792 左右
...
注意:
- 后面的 848、1280 是理论计算 + 内存对齐的结果;
- 不同 Go 版本、不同元素类型可能不同;
- 如果是
[]int32、[]struct{},最终cap也会不同。
示例代码:
s := make([]int, 0)
prev := cap(s)
for i := 0; i < 20; i++ {
s = append(s, i)
if cap(s) != prev {
fmt.Printf("len=%d cap=%d\n", len(s), cap(s))
prev = cap(s)
}
}
可能输出:
len=1 cap=1
len=2 cap=2
len=3 cap=4
len=5 cap=8
len=9 cap=16
len=17 cap=32
...
5. 扩容时做了什么?
growslice 核心步骤:
- 计算
newcap; - 根据元素大小计算字节数;
- 调用
roundupsize做内存对齐; - 分配新底层数组;
- 把旧数组的
oldLen个元素复制到新数组; - 返回新的 slice header。
注意:
- 只复制旧
len个元素,不是复制旧cap个; - 是浅拷贝,元素值复制;
- 如果元素包含指针,指针值被复制,指向的对象仍相同;
- 旧数组如果没有其他引用,会被 GC 回收;
append返回的是新切片,必须接收返回值:
s = append(s, x) // 正确
append(s, x) // 错误,返回值被丢弃
五、常见坑与面试题
1. 切片作为函数参数,append 后外层看不到
func f(s []int) {
s = append(s, 1)
}
func main() {
s := []int{}
f(s)
fmt.Println(len(s)) // 0
}
原因:函数内拷贝的是切片头,append 返回的新切片头只赋给了函数内局部变量。
正确写法:
func f(s []int) []int {
return append(s, 1)
}
2. 容量足够时,函数内修改元素会影响外层
func f(s []int) {
s[0] = 100
}
func main() {
s := []int{1, 2, 3}
f(s)
fmt.Println(s) // [100 2 3]
}
因为底层数组共享。
3. 多个切片共享底层数组
s1 := []int{1, 2, 3, 4, 5}
s2 := s1[1:3]
s2[0] = 100
fmt.Println(s1) // [1 100 3 4 5]
4. 大切片内存泄漏
func lastN(s []int, n int) []int {
return s[len(s)-n:]
}
返回的切片仍然引用原底层数组,如果原数组很大,会导致大数组无法回收。
优化:
func lastN(s []int, n int) []int {
res := make([]int, n)
copy(res, s[len(s)-n:])
return res
}
5. 切片不可比较
s1 := []int{1, 2, 3}
s2 := []int{1, 2, 3}
// fmt.Println(s1 == s2) // 编译错误
只能和 nil 比较:
fmt.Println(s1 == nil)
Go 1.21 可以使用 slices.Equal:
import "slices"
fmt.Println(slices.Equal(s1, s2)) // true
6. 并发 append 不安全
切片本身不是并发安全的。多个 goroutine 同时 append 会导致数据竞争,必须加锁:
var mu sync.Mutex
var s []int
mu.Lock()
s = append(s, 1)
mu.Unlock()
或者使用 channel。
7. 预分配容量提升性能
如果知道大概要放多少元素:
s := make([]int, 0, 1000)
for i := 0; i < 1000; i++ {
s = append(s, i)
}
如果知道确切长度:
s := make([]int, 1000)
for i := range s {
s[i] = i
}
Go 1.21+ 还可以用:
import "slices"
s = slices.Grow(s, 1000)
六、总结
-
数组:
- 固定长度;
- 长度是类型一部分;
- 值类型,赋值和传参拷贝整个数组;
- 可比较,可作为 map key;
- 适合固定大小场景。
-
切片:
- 动态长度;
- 底层是
array + len + cap; - 传参拷贝切片头,但底层数组共享;
- 不可直接比较;
- 适合动态集合、函数参数、缓冲区。
-
切片扩容:
append时newLen <= cap,直接写底层数组;- 否则进入
growslice; - Go 1.18+:小于 256 翻倍,大于等于 256 按约 1.25 倍平滑增长;
- 计算完还要经过
roundupsize内存对齐,所以实际cap可能更大; - 扩容会分配新数组、复制旧元素;
- 官方不保证扩容策略,不要依赖具体倍数。
-
最佳实践:
- 能预估容量就预分配:
make([]T, 0, n); - 注意切片共享底层数组带来的副作用;
- 大切片截取小切片时,必要时用
copy避免内存泄漏; - 并发写切片必须加锁。
- 能预估容量就预分配:

1095

被折叠的 条评论
为什么被折叠?



