揭露切片的真实面目
本节主要内容位于 runtime/slice.go
, 基于 go1.16.4/amd64
创建一个切片十分简单,如下即可创建一个长度为 10 的 int
切片
1 | slice := make([]int, 10) |
但是具体是如何实现的呢,我们慢慢来看。
slice 结构
在 slice.go
中首先定义的就是 slice
结构体
1 | type slice struct { |
这样一看,其实 slice
也不是那么神奇,结构体中包含了一个指向实际数据的指针array
,以及切片的长度 len
和切片的容量 cap
。
切片作为参数进行传递的时候,传递的不是整个结构体,实际上是传入了 slice
中的 array
数据指针,如果在函数内部修改了切片,那么同样会修改外部的切片。
切片的创建
对于切片的创建,我们可以使用 make
关键字,在声明长度的时候,也可以同时声明容量
1 | slice := make([]int, 10, 10) |
当我们使用 make 创建一个新的切片的时候,系统会调用 makeslice
函数为我们创建一个切片。
1 | func makeslice(et *_type, len, cap int) unsafe.Pointer |
首先会计算需要分配的空间大小,如果分配的时候存在下面的任意一个问题,那么直接panic
- 分配的空间大小溢出
- 分配的空间大小大于最大的分配大小
maxAlloc
- 长度
len
< 0 - 长度
len
大于容量cap
1 | mem, overflow := math.MulUintptr(et.size, uintptr(cap)) |
然后最终调用 mallocgc
进行内存的分配
1 | mallocgc(mem, et, true) |
这里的 mem
表示分配的空间大小,et
表示分配的类型,不同的类型会有不同的字节大小,最后一个参数 needzero=true
表示需要将申请的空间数据清零,也就是相当于设置了零值
append 扩容机制
当我们调用 append
往切片中添加元素的时候,如果切片的容量 cap
不够了,会触发一次扩容,调用底层的 growslice
方法
1 | func growslice(et *_type, old slice, cap int) slice |
当之前的容量小于 1024
,那么会扩容的时候会容量会 double
,如果否则的话,新的容量是原来的 1.25
倍
1 | newcap := old.cap |
确定了容量大小之后,会判断容量是否溢出或者大于最大的分配空间大小,如果满足分配条件,那么会调用 mallocgc
进行内存的分配,然后调用 memmove
将原来的数据复制到分配的空间中
1 | memmove(p, old.array, lenmem) |
最后返回一个新的切片,这个切片的数据和之前的数据是一样的,但是所在的空间不同,同时容量设置成新的数值 newcap
。
1 | return slice{p, old.len, newcap} |
我们可以使用下面的代码进行测试
1 | slice := make([]int, 3, 4) |
输出
1 | 没有扩容之前的slice地址:0xc0000121a0, 长度:4, 容量:4 |
所以在我们使用一个切片的时候尤其要注意添加元素之后是否进行了扩容,如果进行了扩容,那么前后两个切片对应的完全不是同一个切片了,这也是 growslice
会返回一个 slice
类型的原因。如果有另一个变量引用了当前切片变量,当没有进行扩容的时候,这两个切片指向的是同一个地址,如果进行了扩容,那么两个切片指向的地址不同,对元素的修改对于另外一个切片不可见
1 | // 引用修改 |
因为 a,b
指向的是 同一块地址空间
,所以修改 b
,同时会影响 a
。
在测试的时候有一个很有趣的想法,比如说下面的代码,输出的结果其实还是很简单的因为 a
的长度为 1
,b
修改的索引在 a
中访问不到,所以输出为:a: [0], b: [0 2]
1 | a := make([]int, 1, 2) |
这里之所以出现这种情况,因为 b:=a
的时候,执行的是值拷贝,a,b
两个指针也是一样的,指向同样的数据,其它的两个字段 len, cap
也是一样的值拷贝,但是这两个双方修改起来都是不可见的,它们的地址不同。
之所以上面的输出为 a: [0], b: [0 2]
,并不是说 a
对应的数据空间中的数据没有修改,而只是字段长度 len
限制了访问的内存,那么我们有一个很自然的想法,修改 a
对应的长度,那么是不是就可以就可以访问到修改的区域数据,那么肯定要试试啊,使用 unsafe
包修改一下底层数据
1 | (*reflect.SliceHeader)(unsafe.Pointer(&a)).Len = 2 |
执行上面的代码,yeah,我们得到了想要的输出 a: [0 2], b: [0 2]
当然,这些都是一些 黑魔法
,平时不建议使用
切片的复制
我们知道切片的传递类似于引用(准确的来说,Go中只有值传递,只不过传递的是指针,我们可以认为是引用传递),所以在使用的时候我们不想修改原来切片中的数据,那么可以先使用 copy
复制一份,然后在新的切片中进行修改。
当我们调用 copy
的时候,底层一般情况下使用的是 makeslicecopy
函数
1 | func makeslicecopy(et *_type, tolen int, fromlen int, from unsafe.Pointer) unsafe.Pointer |
这个函数其实也很简单,主要分为三步:
1.计算需要分配的空间大小
1 | if uintptr(tolen) > uintptr(fromlen) { |
2.使用 mallocgc
进行内存的数据分配
1 | var to unsafe.Pointer |
3.使用 memmove
复制原来的数据到新的内存空间中
1 | memmove(to, from, copymem) |
性能优化
从上面的分析我们知道,当使用 append
函数添加元素的时候,如果切片的容量 cap
不够,那么就需要进行扩容处理,期间会调用 memmove
进行一次拷贝,一定程度上会影响性能。如果我们知道确切的大小,使用下标进行赋值最理想,我们如果大概知道容量,那么可以采取预分配的方式,先分配一定大容量的切片,从而可以减少切片扩容的次数。
下面我们用实际的测试来说明
1 | func makeSlice1() { |
运行基准测试,我们可以得到下面的结果,很明显可以看出性能差异来
1 | goos: linux |
所以在业务中,选择 下标 > 预分配容量大小 > 0容量
生活杂笔,学习杂记,偶尔随便写写东西。