什么是切片
切片是建立在数组之上的抽象类型
数组
Go 语言中数组是一个值,数组变量表示了整个数组,和 C/C++ 不同(指向数组首元素的指针)
利用代码看一下:
go
package main
import "fmt"
// 将数组传递到函数中,数组的地址不一样
func test(arr [3]int) {
fmt.Printf("arr 内: %p\n", &arr)
}
func f1() {
arr := [3]int{1, 2, 3}
test(arr)
fmt.Printf("arr 外: %p\n", &arr)
}
// 拷贝数组,修改旧数组,对新数组无影响
func f2() {
arr1 := [3]int{1, 2, 3}
arr2 := arr1
arr1[0] = 100
fmt.Println(arr1)
fmt.Println(arr2)
}
func main() {
f1()
fmt.Printf("---------------------------\n")
f2()
}
输出:
bash
[vect@ubuntu-dev ~/golang/priciple/03-slice/demo1]$ go run demo1.go
arr 内: 0xc0000a0018
arr 外: 0xc0000a0000
---------------------------
[100 2 3]
[1 2 3]
可以发现:
- 数组传值到函数中,数组的地址不一样
- 拷贝数组,修改旧的数组,对新的数组无影响
Go 的数组类似 C++ 的 array,定长数组,长度是固定的
而 slice 就类似 C++ 的 vector,变长数组,动态扩容
切片底层原理剖析
切片结构
切片底层就是一个结构体:
go
type slice struct {
// 指向一块连续内存空间的起始位置
array unsafe.Pointer
len int
cap int
}
切片扩容机制
1.计算目标容量(预估阶段)
当 slice 触发 append 导致超出当前容量时,Go 会通过以下两个阶段来决定最终的容量。首先是计算预估容量:
- case1 :新切片长度 > 旧切片容量的两倍,则预估容量直接定为新切片长度。
- case2 :若不满足 case1,则根据旧切片容量进行平滑过渡:
- 旧切片容量 < 256 :新切片的预估容量直接翻倍,即
newcap = 2 * oldcap。 - 旧切片容量 ≥\ge≥ 256 :每次扩大为原来的 1.25 倍,并且每次为了平滑过渡,还会固定加上 34×256\frac{3}{4} \times 25643×256(即
+192),直到预估容量 ≥\ge≥ 新切片长度。
- 旧切片容量 < 256 :新切片的预估容量直接翻倍,即
go
// newcap = newcap * 1.25 + 192 的底层高效位移写法
newcap += (newcap + 3*threshold) / 4
2. 内存对齐(最终容量确定)
关键结论 :计算出预估容量后,最终容量并不一定等于预估容量,而是由底层内存分配器决定。
Go 运行时会调用 roundupsize 函数,将预估容量占用的内存大小(预估容量 ×\times× 元素大小)向上对齐到与其最接近的底层内存规格(Size Class)
- 例如 :若预估容量计算出需要 300 字节,而 Go 内存分配器现有的固定分配规格中没有 300 字节,只有 320 字节,则系统会直接分配 320 字节。此时反向推导出的最终
cap就会比预估值稍大一些。 - 不严谨之处在于:这种机制虽然会导致容量多出预期的几个元素,但在宏观上极大减少了堆内存碎片,并加速了内存分配效率。
和 C++ vector 进行对比
先说结论:
扩容策略差异: vector 追求 确定性的几何增长(1.5倍或2倍) ,slice 计算出预估容量后,强行接入内存对齐,最终容量由底层内存分配器决定
用个表格总结:
| 维度 | Go slice | C++ std::vector |
|---|---|---|
| 扩容系数 | <256<256<256 元素时 2 倍;≥256\ge 256≥256 时过渡到 1.25 倍 + 192 | GCC/Clang 固定 2 倍;MSVC 固定 1.5 倍 |
| 最终容量确定性 | 不确定。受限于底层内存分配器的 Size Class 规格向上取整 | 确定 |
内存对齐规则对比
内存对齐本质是用空间换时间,确保CPU能通过一次总线周期高效读取数据
C++ 内存对齐
前提:编译器都有默认的对齐数,64位默认为8
规则一:成员自身对齐(决定字段偏移量)
min(自身类型大小,默认对齐数)min(自身类型大小,默认对齐数)min(自身类型大小,默认对齐数)的整数倍
规则二:结构体整体对齐(决定结构体最终大小)
结构体大小=min(内部最大基础成员的大小,默认对齐数)min(内部最大基础成员的大小,默认对齐数)min(内部最大基础成员的大小,默认对齐数)的整数倍
例如:64位系统
cpp
struct A {
char c;
int b;
double c;
}
c _ _ _ b b b b c c c c c c c c
0 1 2 3 4 5 6 7 8 ....... 15
最终大小为16字节
Go 内存对齐
和 C++ 完全一致,但多了针对垃圾回收机制的特殊尾部边界处理:
零大小尾部阻隔:
若一个结构体的最后一个字段 的大小是0(例如空结构体struct{}),且该结构体还会被其他对象引用或者作为数组元素,Go 编译器会在尾部强制填充1字节并进行对齐
设计原因:
若不填充,指向该空结构体的指针就会直接指向结构体外部的下一个对象,误认为下一个对象还在被引用,导致内存泄漏
还是64位系统:
go
type BadLayout struct {
a int32 // 4 字节
b struct{} // 0 字节,但处于尾部。为了 GC 安全,强行填充并对齐至 4 字节
} // 总大小 = 4 + 4 = 8 字节
type GoodLayout struct {
b struct{} // 0 字节,处于头部
a int32 // 4 字节
} // 总大小 = 0 + 4 = 4 字节 (无需尾部填充)
切片行为分析
1. 切片传参的本质:值传递 + 共享底层数组
go
package main
import (
"fmt"
"reflect"
"unsafe"
)
func PrintSlice(s *[]int) {
// Go 是强类型,正常情况 *[]int 绝对不能转成 *reflect.SliceHeader
// 而 unsafe.Pointer 类似 void*,可以接收任意类型指针
// 对于 reflect.SliceHeader
// type SliceHeader struct {
// Data uintptr // 对应底层数组地址,这个不是指针,就是存了地址数字的类型而已
// Len int // 对应长度
// Cap int // 对应容量
// }
ss := (*reflect.SliceHeader)(unsafe.Pointer(s))
fmt.Printf("slice struct: %+v, slice is %v\n", ss, s)
}
func test(s []int) {
PrintSlice((&s))
}
func demo2_slice_func() {
s := make([]int, 5, 10)
PrintSlice(&s)
test(s)
}
func main() {
demo2_slice_func()
}
输出:
bash
slice struct: &{Data:824633884752 Len:5 Cap:10}, slice is &[0 0 0 0 0]
slice struct: &{Data:824633884752 Len:5 Cap:10}, slice is &[0 0 0 0 0]
先看代码做了什么:用 make([]int, 5, 10) 创建一个 len=5、cap=10 的切片,在 main 里打印一次 header,传到 test 函数里再打印一次。
两次输出的 Data 地址完全一样。
这说明什么?Go 所有函数参数都是值传递,切片也不例外------传进去的是 slice header 的一份拷贝 。但 header 里的 Data 字段是个指针(准确说是 uintptr,存的是地址值),拷贝后仍然指向同一块底层数组。
text
main 里的 s: test 里的 s (拷贝):
+------------------+ +------------------+
| Data: 0x...4752 |---┐ | Data: 0x...4752 |---┐
| Len: 5 | | | Len: 5 | |
| Cap: 10 | | | Cap: 10 | |
+------------------+ | +------------------+ |
| |
v 同一块底层数组 v
+-----------------------------------+
| [0] [1] [2] [3] [4] (预留 5 个空位) |
+-----------------------------------+
len = 5 cap = 10
两个 header 是独立的(Len/Cap 互不影响),但它们看到的底层数组是同一片内存。
总结一下:
切片传到函数里,切片 header 是复制品,底层数组是共享的
2. 修改切片:下标修改 vs append
go
package main
import (
"fmt"
"reflect"
"unsafe"
)
func PrintSlice(s *[]int) {
ss := (*reflect.SliceHeader)(unsafe.Pointer(s))
fmt.Printf("slice struct: %+v, slice is %v\n", ss, s)
}
func test(s []int) {
PrintSlice((&s))
}
// 底层数组不变
func demo3_case1(s []int) {
s[1] = 1000
PrintSlice(&s)
}
// 底层数组变化
func demo3_case2(s []int) {
s = append(s, 1000)
s[1] = 1000
PrintSlice(&s)
}
func demo3_infunc_modify() {
s := make([]int, 5)
demo3_case1(s)
demo3_case2(s)
PrintSlice(&s)
}
func main() {
demo3_infunc_modify()
}
输出:
bash
slice struct: &{Data:824633811472 Len:5 Cap:5}, slice is &[0 1000 0 0 0]
slice struct: &{Data:824633884832 Len:6 Cap:10}, slice is &[0 1000 0 0 0 1000]
slice struct: &{Data:824633811472 Len:5 Cap:5}, slice is &[0 1000 0 0 0]
对于通过索引修改:
调用前 s := make([]int, 5),底层数组全是 0。s[1] = 1000 直接修改了底层数组的第 1 号位置。外部切片和它共享同一个底层数组,所以外部看到的也是 [0 1000 0 0 0]。
对于先 append 再通过索引修改
go
func demo3_case2(s []int) {
s = append(s, 1000) // len==cap==5,append 触发扩容!
s[1] = 1000 // 这次改的是新数组
}
关键在这里:make([]int, 5) 创建的切片 len=cap=5,没有预留空间 。append(s, 1000) 发现 len(6) > cap(5),必须扩容------于是分配一块新的底层数组,把旧元素拷过去,再追加 1000。
此时函数里的 s 已经指向新数组了,后续 s[1] = 1000 改的是新数组,跟外部切片已经没关系了。
text
append 前(case1 执行后):
外部 s: case2 内 s:
+------------------+ +------------------+
| Data: 0x...1472 |--+ | Data: 0x...1472 |--+
| Len: 5 | | | Len: 5 | |
| Cap: 5 | | | Cap: 5 | |
+------------------+ | +------------------+ |
+-------> [0,1000,0,0,0] <----+
(底层数组,cap=5,已满)
append(s, 1000) 之后:
外部 s: case2 内 s (重新赋值后):
+------------------+ +------------------+
| Data: 0x...1472 |--+ | Data: 0x...4752 |-----+
| Len: 5 | | | Len: 6 | |
| Cap: 5 | | | Cap: 10 | |
+------------------+ | +------------------+ |
| |
v v
[0,1000,0,0,0] [0,1000,0,0,0,1000]
(旧数组,外部还指着它) (新数组,函数里的 s 指着它)
s[1]=1000 改这里
输出验证了这一点:
- case1 内 Data 和外部一样(
0x...1472) - case2 内 Data 变了(
0x...4752),是新数组的地址 - 外部 Data 仍然是旧地址
0x...1472,且值还是 case1 留下的[0 1000 0 0 0]
总结一下:
通过下标改元素,影响的是共享的底层数组,外部可见。通过 append 追加导致扩容时,函数内部的 s 指向新数组,后续操作跟外部完全脱钩。能不能影响外部,取决于 append 是否触发扩容。
3. 截取切片:新建视图,不复制数据
go
package main
import (
"fmt"
"reflect"
"unsafe"
)
func PrintSlice(s *[]int) {
ss := (*reflect.SliceHeader)(unsafe.Pointer(s))
fmt.Printf("slice struct: %+v, slice is %v\n", ss, s)
}
func case1(s []int) {
s = s[1:]
PrintSlice(&s)
}
func case2(s []int) {
s = s[1:3]
PrintSlice(&s)
}
func case3(s []int) {
s = s[len(s)-1:]
PrintSlice(&s)
}
func case4(s []int) {
s1 := s[2:]
PrintSlice(&s1)
}
func main() {
s := make([]int, 5)
case1(s)
case2(s)
case3(s)
case4(s)
PrintSlice(&s)
}
输出:
bash
slice struct: &{Data:824633811480 Len:4 Cap:4}, slice is &[0 0 0 0]
slice struct: &{Data:824633811480 Len:2 Cap:4}, slice is &[0 0]
slice struct: &{Data:824633811504 Len:1 Cap:1}, slice is &[0]
slice struct: &{Data:824633811488 Len:3 Cap:3}, slice is &[0 0 0]
slice struct: &{Data:824633811472 Len:5 Cap:5}, slice is &[0 0 0 0 0]
原始切片 s := make([]int, 5) 生成 5 个零值 int,Data 从 0x...1472 开始,每个 int 占 8 字节。
text
底层数组 (每个格子 8 字节):
地址: 0x1472 0x147A 0x1482 0x148A 0x1492 (十六进制,差 8)
+-------+-------+-------+-------+-------+
| [0] | [1] | [2] | [3] | [4] |
+-------+-------+-------+-------+-------+
^ ^
| |
原始 s.Data 原始 s 能看到的最远位置
(0x...1472) (0x...1472 + 5*8)
四个 case 分别做了不同截取,看输出数据来推理规律:
| 操作 | Data 地址 | 相比原 Data 偏移 | Len | Cap |
|---|---|---|---|---|
原始 s |
0x...1472 |
0 | 5 | 5 |
s[1:] |
0x...1480 |
+8(跳 1 个 int) | 4 | 4 |
s[1:3] |
0x...1480 |
+8(跳 1 个 int) | 2 | 4 |
s[4:] |
0x...1504 |
+32(跳 4 个 int) | 1 | 1 |
s[2:] |
0x...1488 |
+16(跳 2 个 int) | 3 | 3 |
规律非常清楚:s[i:j] 不会分配新内存,只是构造了一个新的 slice header。
text
s[1:] = s[1:5] → Data = 原Data + 1*8, Len = 4, Cap = 原Cap - 1
s[1:3] → Data = 原Data + 1*8, Len = 2, Cap = 原Cap - 1
s[4:] = s[4:5] → Data = 原Data + 4*8, Len = 1, Cap = 原Cap - 1
s[2:] = s[2:5] → Data = 原Data + 2*8, Len = 3, Cap = 原Cap - 2
通用公式(s[low:high]):
newData = oldData + low * sizeof(element)newLen = high - lownewCap = oldCap - low
注意 s[1:3] 虽然 Len 只有 2,但 Cap 还有 4------说明它"记得"自己底层数组从位置 1 往后还有 3 个元素的空间(只是当前不暴露)。这意味着如果对它做 append,只要不超出 cap,仍然会在原底层数组上操作。
原始 s 的 Data、Len、Cap 全程不变------四个 case 里截取出的都是新 header,赋值给了函数内的局部变量,不影响外部。
总结一下:
截取切片就是"换个角度看同一块内存"。Data 指针往后挪、Len 缩短、Cap 缩短,没有数据拷贝。所有截取出来的切片共享底层数组,一个改了元素,其他都看得见。
4. 删除元素:append 拼接的陷阱
go
package main
import (
"fmt"
"reflect"
"unsafe"
)
func PrintSlice(s *[]int) {
ss := (*reflect.SliceHeader)(unsafe.Pointer(s))
fmt.Printf("slice struct: %+v, slice is %v\n", ss, s)
}
func main() {
s := []int{0, 1, 2, 3, 4}
_ = s[4]
PrintSlice(&s)
// 删除第一个元素,从0开始计数
// [0,1) + [2, len(s))
s1 := append(s[:1], s[2:]...)
{
// 拷贝元素
// 0, 1, 2, 3, 4
// 0, 2, 3, 4, 4
}
PrintSlice(&s1)
PrintSlice(&s)
// 访问原切片
_ = s[4]
// 访问从原切片中删除了一个元素的切片
_ = s1[4]
}
输出:
bash
slice struct: &{Data:824634392576 Len:5 Cap:5}, slice is &[0 1 2 3 4]
slice struct: &{Data:824634392576 Len:4 Cap:5}, slice is &[0 2 3 4]
slice struct: &{Data:824634392576 Len:5 Cap:5}, slice is &[0 2 3 4 4]
panic: runtime error: index out of range [4] with length 4
goroutine 1 [running]:
main.main()
/home/vect/golang/priciple/03-slice/demo4/demo4_delete.go:35 +0x1b6
exit status 2
代码要做的事:从 [0, 1, 2, 3, 4] 里删除索引 1 的元素(值 1),得到 [0, 2, 3, 4]。
Go 没有内置的删除切片元素的方法,惯用写法是 s = append(s[:i], s[i+1:]...)。
拆解这个过程:
text
原始 s: [0, 1, 2, 3, 4] len=5 cap=5
底层: +---+---+---+---+---+
| 0 | 1 | 2 | 3 | 4 |
+---+---+---+---+---+
^ ^
s.Data s.Data+4*8
s[:1]: 取前 1 个元素 [0]
Data 同 s, Len=1, Cap=5 ← 注意 cap 还是 5,有 4 个空位
s[2:]: 从索引 2 开始取到底 [2,3,4]
Data = s.Data + 2*8, Len=3, Cap=3
append(s[:1], s[2:]...):
s[:1] 还剩 4 个 cap 空位,能装下 3 个元素,不扩容!
在底层数组的位置 1、2、3 依次写入 2、3、4
执行后底层数组的变化:
text
操作前: +---+---+---+---+---+
| 0 | 1 | 2 | 3 | 4 |
+---+---+---+---+---+
写入后: +---+---+---+---+---+
| 0 | 2 | 3 | 4 | 4 | ← 位置 4 的旧值 4 没被覆盖
+---+---+---+---+---+
^~~~~~~~~~~~^
s1 看到的范围 len=4
^~~~~~~~~~~~~~~~^
s 看到的范围 len=5
这就解释了输出:
s1=[0 2 3 4],len=4,cap=5s=[0 2 3 4 4],len=5------原切片底层数组被 append 就地修改了,尾部多了一个4s[4]访问成功(s 的 len=5),但s1[4]panic(s1 的 len=4)
两个关键问题被暴露出来:
- 原切片被"污染"了 。
append没有扩容,直接在共享的底层数组上写,s看到的内容跟着变了。 - 新切片 len 变小了 。
s1是"逻辑上删除了一个元素"的切片,它的 len=4,访问s1[4]直接越界 panic------即使底层数组那个位置确实有值。
总结一下:
用 append 拼接来删除元素,本质是把后面的元素往前拷贝,覆盖掉要删的那个位置。如果原切片 cap 够大,不会触发扩容,操作就在原底层数组上发生------原切片的内容也会被连带修改。删除后新切片的 len 少 1,按 len 访问才是安全的,不要以为底层数组还有值就能越界访问。
5. append 的行为:有容量走原地,没容量走扩容
go
package main
import (
"fmt"
"reflect"
"unsafe"
)
func PrintSlice(s *[]int) {
ss := (*reflect.SliceHeader)(unsafe.Pointer(s))
fmt.Printf("slice struct: %+v, slice is %v\n", ss, s)
}
func case1() {
s1 := make([]int, 3, 3)
s1 = append(s1, 1)
PrintSlice(&s1)
}
func case2() {
s1 := make([]int, 3, 4)
s2 := append(s1, 1)
PrintSlice(&s1)
PrintSlice(&s2)
}
func case3() {
s1 := make([]int, 3, 3)
s2 := append(s1, 1)
PrintSlice(&s1)
PrintSlice(&s2)
}
func main() {
case1()
case2()
case3()
}
bash
slice struct: &{Data:824633811472 Len:4 Cap:6}, slice is &[0 0 0 1]
slice struct: &{Data:824633827584 Len:3 Cap:4}, slice is &[0 0 0]
slice struct: &{Data:824633827584 Len:4 Cap:4}, slice is &[0 0 0 1]
slice struct: &{Data:824633819424 Len:3 Cap:3}, slice is &[0 0 0]
slice struct: &{Data:824633811520 Len:4 Cap:6}, slice is &[0 0 0 1]
三个 case 对比了 append 的两种情况。
| Case | 原始切片 | append 后 | 是否扩容 | Data 是否变 |
|---|---|---|---|---|
| case1 | len=3, cap=3 |
len=4, cap=6 |
是(3 < 256,扩容到 6) | 变 |
| case2 | len=3, cap=4 |
len=4, cap=4 |
否(4 ≤ 4,原地追加) | 不变 |
| case3 | len=3, cap=3 |
len=4, cap=6 |
是(3 < 256,扩容到 6) | 变 |
case2 最值得关注:
text
s1 := make([]int, 3, 4) // len=3, cap=4, 还有一个预留空位
s2 := append(s1, 1) // 不扩容,直接在预留空位写 1
底层数组:
+---+---+---+---+
| 0 | 0 | 0 | | ← s1 创建后的状态 (cap=4, len=3 只暴露前 3 个)
+---+---+---+---+
s1 可见 ↑
+---+---+---+---+
| 0 | 0 | 0 | 1 | ← append 后 (位置 3 填入了 1)
+---+---+---+---+
s1 可见 ↑ ↑ s2 可见
s1 和 s2 的 Data 地址相同(都是 0x...0800),因为 append 没有扩容,直接用了预留空间 。s1 的 len 是 3,看不到第 4 个元素;s2 的 len 是 4,能看到。
case3 和 case1 本质一样------cap 已满,append 必须扩容,s2 拿到新数组,跟 s1 彻底分家。
对比三组 Data 地址:
- case2(未扩容):s1.Data == s2.Data → 共享底层
- case1 / case3(扩容):s1.Data 和新的 s1/s2.Data 完全不同 → 独立底层
总结:
append返回的切片不一定和原切片共享底层数组。能不能共享,取决于原切片的 cap 是否还有余量。这就是为什么官方一直强调s = append(s, ...)------你永远不知道 append 会不会换底层数组,不接收返回值就等于把新数据丢了。
6. 深拷贝:copy 才是真正的复制
go
package main
import (
"fmt"
"reflect"
"unsafe"
)
func PrintSlice(s *[]int) {
ss := (*reflect.SliceHeader)(unsafe.Pointer(s))
fmt.Printf("slice struct: %+v, slice is %v\n", ss, s)
}
func main() {
s1 := []int{1, 2, 3}
s2 := make([]int, len(s1))
copy(s2, s1)
PrintSlice(&s1)
PrintSlice(&s2)
}
输出:
golang
slice struct: &{Data:824634392576 Len:3 Cap:3}, slice is &[1 2 3]
slice struct: &{Data:824634392600 Len:3 Cap:3}, slice is &[1 2 3]
go
s1 := []int{1, 2, 3}
s2 := make([]int, len(s1)) // 先分配一个等长的独立切片
copy(s2, s1) // 把 s1 的元素逐个拷贝到 s2 的底层数组
输出里 s1.Data 和 s2.Data 是不同的地址------两个切片各自拥有独立的底层数组,互不影响。
text
s1: s2:
+------------------+ +------------------+
| Data: 0x...9328 |--+ | Data: 0x...9352 |--+
| Len: 3 | | | Len: 3 | |
| Cap: 3 | | | Cap: 3 | |
+------------------+ | +------------------+ |
v v
+---+---+---+ +---+---+---+
| 1 | 2 | 3 | | 1 | 2 | 3 |
+---+---+---+ +---+---+---+
数组 A (独立) 数组 B (独立)
copy(dst, src) 的行为要点:
- 拷贝的元素数量 =
min(len(dst), len(src)) - 只拷贝元素值,不共享底层数组
- 如果 dst 比 src 短,src 多出来的元素不会拷过去;如果 dst 比 src 长,多出来的位置保持原值
对比:截取切片 s2 := s1[:] 仍然共享底层数组,改 s2 会影响 s1,这不是深拷贝。
总结:
要想得到一个和原切片完全独立 的副本,必须先
make分配新切片,再copy拷贝元素。截取只是创建了一个新视图,底层还是同一块内存。
易错点
-
把切片传给函数后,在函数里 append 却指望外部看到 。如果触发了扩容,外部切片完全不受影响。要对外部切片做 append,应该传
*[]int指针,或者把新切片 return 回去。 -
截取出来的切片仍持有对原底层数组的引用 。比如从一个百万元素的大切片截一小段出来,虽然新切片的 len 很小,但 cap 可能很大(
cap = oldCap - low),底层大数组不会被 GC。如果只想要那一小段,应该make+copy。 -
用 append 拼接方式删除元素后,原切片的内容也变了 。案例 4 中
s从[0,1,2,3,4]变成[0,2,3,4,4]------因为 append 在原底层数组上直接覆写了。这不是 bug,但要知道自己在干什么。 -
把 nil slice 和空 slice 搞混 。
var s []int(nil slice,Data 为空,len=cap=0)和s := make([]int, 0)(空 slice,Data 有地址,len=cap=0)不一样,但大部分操作(append、len、cap、for range)对两者行为一致。 -
copy的拷贝数量由较短的那个切片决定 。如果copy(dst, src)的 dst 比 src 短,src 多余的元素会被丢弃,不会自动扩容 dst。
done~