slice 底层结构
切片在运行时的表现是 SliceHeader 结构体,定义如下:
1
2
3
4
5
6
type SliceHeader struct {
Data uintptr
Len int
Cap int
}
- Data:指向具体的底层数组。
- Len:代表切片的长度。
- Cap:代表切片的容量。
要点是:切片真正存储数据的地方,是一个数组。切片的 Data
属性中存储的是指向所引用的数组指针地址。
可以将切片理解成一片连续的内存空间加上长度与容量的标识.
初始化切片
1
2
3
arr := arr[1:3] // 下标
arr := []int{1, 2, 3} // 字面量
arr := make([]int, 2, 3) // 关键字
- 使用下标初始化切片 不会拷贝原数组或者原切片中的数据,它只会创建一个指向原数组的切片结构体,所以修改新切片的数据也会修改原切片。
- 字面量初始化切片 是在编译时完成的。
- 根据切片中的元素数量对底层数组的大小进行推断并创建一个数组
- 将这些字面量元素存储到初始化的数组中
- 创建一个同样指向 [3]int 类型的数组指针
- 将静态存储区的数组 vstat 赋值给 vauto 指针所在的地址
- 通过 [:] 操作获取一个底层使用 vauto 的切片
- 关键字 创建切片时,很多工作都需要运行时的参与。不仅会检查 len 是否传入,还会保证传入的容量 cap 一定大于或者等于 len。
当切片发生逃逸或者非常大时,运行时需要 runtime.makeslice 在堆上初始化切片,如果当前的切片不会发生逃逸并且切片非常小的时候,会直接使用下标得到得到数组对应的切片。
整形切片
1
var ints []int
slice 的元素要存在连续的内存中,也就是连续数组。 data
是底层数组的起始地址,这里只分配了切片结构没有分配底层数组,此时 data = nil
,Len 和 Cap
都为零。
1
2
3
var arr []int = make([]int, 2, 5)
arr = append(arr, 1)
arr[0] = 1
通过 make
的方式定义变量,不仅会分配结构还会开辟一段内存作为它的底层数组。此时分配的值都为 0。通过 append
之后此时索引 2 的位置被修改为 1,通过索引下标 0 修改后第一个元素为 1, 其他位置还是默认值 0。
字符串类型切片
1
2
3
4
5
6
arr := new([]string)
*arr = append(*arr, "kevin")
// append(arr, "kevin") 会报错
// invalid argument: arr (variable of type *[]string) is not a slicecompiler
// 因为 new 返回的是指针起始地址
上面 new
一个 slice 对象同样会分配切片的三部分,它不负责底层数组的分配,new 的返回值是 slice 的指针地址,如果这时候 (*arr)[0] = "kevin"
通过下标修改切片内容是不允许的,此时可以通过 append 进行分配底层数组。
和字符串相关的底层数组
底层数组是相同类型的元素一个挨一个的存储,不同的slice 可以关联到同一个数组。slice 的 data 起始指针并不一定指向数组的开头,如下例:
1
2
3
4
arr := [10]int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
var s1 = arr[1:4]
var s2 = arr[7:]
s1 的元素是 arr 索引 1到4 的元素,左闭又开,长度是 3,但是容量 Cap
是从索引 1 开始到末尾 为 9,s2 的元素是从 索引 7 到末尾,总共三个元素容量 Cap 也是 3。slice 访问和修改的都是底层数组的元素。
1
s1[3] = 5
上面 s1 就会越界产生 panic,只能扩大读写区间范围。此时如果给 s2 添加元素 s2 = append(s2, 10)
会开辟新数组,原来的元素要拷过来同时添加新元素,元素个数改为 4,容量扩到 6。
扩容规则
如果 append
返回的新切片不需要赋值回原有的变量,就会 makeslice
创建一个新的 slice;
如果使用 slice = append(slice, 1, 2, 3)
语句,那么 append
后的切片会覆盖原切片。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// append(slice, 1, 2, 3)
ptr, len, cap := slice
newlen := len + 3
if newlen > cap {
...
}
*(ptr+len) = 1
*(ptr+len+1) = 2
*(ptr+len+2) = 3
return makeslice(ptr, newlen, cap)
// slice = append(slice, 1, 2, 3)
a := &slice
... // 同上
if uint(newlen) > uint(cap) {
*a.cap = newcap
*a.ptr = newptr
}
newlen = len + 3
*a.len = newlen
*(ptr+len) = 1
*(ptr+len+1) = 2
*(ptr+len+2) = 3
预估容量
1
2
arr := []int{1, 2}
arr = append(arr, 3, 4, 5)
上面扩容后容量到 5,因为整形元素占有 8 字节,根据内存规格匹配到 48 。下面分析为什么?
在分配内存空间之前需要先确定新的切片容量,运行时根据切片的当前容量选择不同的策略进行扩容:
- 如果期望容量大于当前容量的两倍就会使用期望容量(也就是当前容量翻倍)
- 如果当前切片的长度小于 1024 就会将容量翻倍
- 如果当前切片的长度大于 1024 就会每次增加 25% 的容量,直到新容量大于期望容量
内存分配
内存空间=切片中元素大小×目标容量。
语言的内存管理模块会提前向操作系统申请一批内存,分成常用规格管理起来。当申请内存时会匹配合适的规格进行分配。
1
2
3
4
5
6
7
8
9
10
var class_to_size = [_NumSizeClasses]uint16{
0,
8,
16,
32,
48,
64,
80,
...,
}
例子如下:
1
2
a := []string{"my", "name", "is"}
a = append(a, "kevin")
- 字符串在 64 位机器上每个元素占 16 字节,扩容前容量是 3,添加一个最少扩容到4,原容量翻倍等于 6 大于 4,小于1024,直接翻倍预估容量为 6。
- 预估容量元素大小(616=96byte)
- 匹配到内存规格 96 所以最终扩容后容量为 6
切片 copy
无论是编译期间拷贝还是运行时拷贝,两种拷贝方式都会通过 runtime.memmove
将整块内存的内容拷贝到目标的内存区域中。
Classic problem
1
s2 := s1[2:6:7]
长度 s2 从 s1 的索引2(闭区间)到索引6(开区间,元素真正取到索引5),容量到索引7(开区间,真正到索引6),为5。
…
Go 提供的语法糖 ...
,可以将 slice
传进可变函数,不会创建新的切片
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func main() {
slice := make([]int, 5, 5)
slice[0] = 1
slice[1] = 2
// 此处因为 append 操作底层数组发生了扩容,原 slice 的底层数组不会改变
change(slice...)
fmt.Println(slice) // [1, 2, 0, 0, 0]
// 此处 [0:2] 截取获得一个新切片,长度为 2, 容量为 5
// 在 append 操作中原切片底层数据容量足够,不会发生扩容,影响到原切片的长度
change(slice[0:2]...)
fmt.Println(slice) // [1, 2, 3, 0, 0]
}
func change(s ...int) {
s = append(s, 3)
fmt.Println(s)
}
Len and cap
Go 语言中的所有东西都是以值传递的。也就是说,一个函数总是得到一个被传递的东西的副本,就像有一个赋值语句将值赋给参数一样。
如果传过去的值是指向内存空间的地址,是可以对这块内存空间做修改的。
1
2
3
4
5
6
7
8
9
10
11
12
13
func main() {
sl := make([]int, 0, 10)
var appenFunc = func(s []int) {
s = append(s, 10, 20, 30)
fmt.Println(s)
}
fmt.Println(sl)
appenFunc(sl)
fmt.Println(sl)
fmt.Println(sl[:10])
fmt.Println(sl[:])
}
实质上在调用 appenFunc(sl)
函数时,实际上修改了底层所指向的数组,自然也就会发生变化,也就不难理解为什么 10, 20, 30 元素会出现了
常用的访问切片我们会用:
1
s[low : high]
注意这里是:low
、high
。没有用 len
cap
这种定性的词语,也就代表着这里的取值是可变的。
当是切片(slice)时,表达式 s[low : high]
中的 high
,最大的取值范围对应着切片的容量cap
,不是单纯的长度len
。因此调用 fmt.Println(sl[:10])
时可以输出容量范围内的值,不会出现越界。 相对的 fmt.Println(sl[:])
因为该切片 len
值为 0,没有指定最大索引值,high
则取 len
值,导致输出结果为空。
slice 的泄漏
1
2
3
4
5
6
7
8
9
10
11
var a []int
func f(b []int) []int {
a = b[:2]
return a
}
func main() {
...
}
// 切片 a 和 b 都共享着同一个底层数组(共享内存块),sliceB 包含全部所引用的字符。sliceA 只包含了 [:2],也就是 0 和 1 两个索引位的字符。
泄露的点 就在于虽然切片 b 已经在函数内结束了他的使命了,不再使用了。 但切片 a 还在使用,切片 a 和 切片 b 引用的是同一块底层数组(共享内存块)。
虽然切片 a 只有底层数组中 0 和 1 两个索引位正在被使用,其余未使用的底层数组空间毫无作用。但由于正在被引用,他们也不会被 GC
,因此造成了泄露。
解决办法
利用切片的特性。当切片的容量空间不足时,会重新申请一个新的底层数组来存储,让两者彻底分手 。
1
2
3
4
5
6
7
8
9
10
11
12
var a []int
var c []int // 第三者
func f(b []int) []int {
a = b[:2]
// 新的切片 append 导致切片扩容
c = append(c, b[:2]...)
fmt.Printf("a: %p\nc: %p\nb: %p\n", &a[0], &c[0], &b[0])
return a
}