揭露切片的真实面目

本节主要内容位于 runtime/slice.go, 基于 go1.16.4/amd64

创建一个切片十分简单,如下即可创建一个长度为 10 的 int 切片

1
slice := make([]int, 10)

但是具体是如何实现的呢,我们慢慢来看。

slice 结构

slice.go 中首先定义的就是 slice 结构体

1
2
3
4
5
type slice struct {
array unsafe.Pointer
len int
cap int
}

这样一看,其实 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
newcap := old.cap
doublecap := newcap + newcap
if cap > doublecap {
newcap = cap
} else {
if old.cap < 1024 {
newcap = doublecap
} else {
// Check 0 < newcap to detect overflow
// and prevent an infinite loop.
for 0 < newcap && newcap < cap {
newcap += newcap / 4
}
// Set newcap to the requested cap when
// the newcap calculation overflowed.
if newcap <= 0 {
newcap = cap
}
}
}

确定了容量大小之后,会判断容量是否溢出或者大于最大的分配空间大小,如果满足分配条件,那么会调用 mallocgc 进行内存的分配,然后调用 memmove 将原来的数据复制到分配的空间中

1
memmove(p, old.array, lenmem)

最后返回一个新的切片,这个切片的数据和之前的数据是一样的,但是所在的空间不同,同时容量设置成新的数值 newcap

1
return slice{p, old.len, newcap}

我们可以使用下面的代码进行测试

1
2
3
4
5
slice := make([]int, 3, 4)
slice = append(slice, 1)
fmt.Printf("没有扩容之前的slice地址:%p, 长度:%d, 容量:%d\n", slice, len(slice), cap(slice))
slice = append(slice, 2)
fmt.Printf("扩容之后的slice地址:%p, 长度:%d, 容量:%d\n", slice, len(slice), cap(slice))

输出

1
2
没有扩容之前的slice地址:0xc0000121a0, 长度:4, 容量:4
扩容之后的slice地址:0xc00000e340, 长度:5, 容量:8

所以在我们使用一个切片的时候尤其要注意添加元素之后是否进行了扩容,如果进行了扩容,那么前后两个切片对应的完全不是同一个切片了,这也是 growslice 会返回一个 slice 类型的原因。如果有另一个变量引用了当前切片变量,当没有进行扩容的时候,这两个切片指向的是同一个地址,如果进行了扩容,那么两个切片指向的地址不同,对元素的修改对于另外一个切片不可见

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 引用修改
a := make([]int, 2, 2)
// b 引用 a
b := a
b[0] = 1
fmt.Printf("a: %v, b: %v\n", a, b)
// 输出
// a: [1 0], b: [1 0]
//----------------------------------
// 扩容之后不可见
b = append(b, 1)
b[1] = 10
fmt.Printf("a: %v, b: %v\n", a, b)
// 输出
// a: [1 0], b: [1 10 1]

因为 a,b 指向的是 同一块地址空间 ,所以修改 b,同时会影响 a

在测试的时候有一个很有趣的想法,比如说下面的代码,输出的结果其实还是很简单的因为 a 的长度为 1b 修改的索引在 a 中访问不到,所以输出为:a: [0], b: [0 2]

1
2
3
4
5
a := make([]int, 1, 2)
b := a
b = append(b, 1)
b[1] = 2
fmt.Printf("a: %v, b: %v\n", a, b)

这里之所以出现这种情况,因为 b:=a 的时候,执行的是值拷贝,a,b 两个指针也是一样的,指向同样的数据,其它的两个字段 len, cap 也是一样的值拷贝,但是这两个双方修改起来都是不可见的,它们的地址不同。

之所以上面的输出为 a: [0], b: [0 2],并不是说 a 对应的数据空间中的数据没有修改,而只是字段长度 len 限制了访问的内存,那么我们有一个很自然的想法,修改 a 对应的长度,那么是不是就可以就可以访问到修改的区域数据,那么肯定要试试啊,使用 unsafe 包修改一下底层数据

1
2
(*reflect.SliceHeader)(unsafe.Pointer(&a)).Len = 2
fmt.Printf("a: %v, b: %v\n", a, b)

执行上面的代码,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
2
3
4
5
6
7
8
9
10
11
if uintptr(tolen) > uintptr(fromlen) {
var overflow bool
tomem, overflow = math.MulUintptr(et.size, uintptr(tolen))
if overflow || tomem > maxAlloc || tolen < 0 {
panicmakeslicelen()
}
copymem = et.size * uintptr(fromlen)
} else {
tomem = et.size * uintptr(tolen)
copymem = tomem
}

2.使用 mallocgc 进行内存的数据分配

1
2
3
4
5
6
7
8
9
10
11
12
var to unsafe.Pointer
if et.ptrdata == 0 {
to = mallocgc(tomem, nil, false)
if copymem < tomem {
memclrNoHeapPointers(add(to, copymem), tomem-copymem)
}
} else {
to = mallocgc(tomem, et, true)
if copymem > 0 && writeBarrier.enabled {
bulkBarrierPreWriteSrcOnly(uintptr(to), uintptr(from), copymem)
}
}

3.使用 memmove 复制原来的数据到新的内存空间中

1
memmove(to, from, copymem)

性能优化

从上面的分析我们知道,当使用 append 函数添加元素的时候,如果切片的容量 cap 不够,那么就需要进行扩容处理,期间会调用 memmove 进行一次拷贝,一定程度上会影响性能。如果我们知道确切的大小,使用下标进行赋值最理想,我们如果大概知道容量,那么可以采取预分配的方式,先分配一定大容量的切片,从而可以减少切片扩容的次数。

下面我们用实际的测试来说明

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
func makeSlice1() {
slice := make([]int, 1024)
for i := 0; i < 1024; i++ {
slice[i] = i
}
}

func makeSlice2() {
slice := make([]int, 0)
for i := 0; i < 1024; i++ {
slice = append(slice, i)
}
}

func makeSlice3() {
slice := make([]int, 0, 1024)
for i := 0; i < 1024; i++ {
slice = append(slice, i)
}
}

func BenchmarkMakeSlice(b *testing.B) {
b.Run("下标", func(b *testing.B) {
for i := 0; i < b.N; i++ {
makeSlice1()
}
})
b.Run("原始", func(b *testing.B) {
for i := 0; i < b.N; i++ {
makeSlice2()
}
})

b.Run("预分配", func(b *testing.B) {
for i := 0; i < b.N; i++ {
makeSlice3()
}
})
}

运行基准测试,我们可以得到下面的结果,很明显可以看出性能差异来

1
2
3
4
5
6
goos: linux
goarch: amd64
cpu: Intel(R) Core(TM) i7-8565U CPU @ 1.80GHz
BenchmarkMakeSlice/下标-8 3401653 668.9 ns/op
BenchmarkMakeSlice/原始-8 127171 8066 ns/op
BenchmarkMakeSlice/预分配-8 704804 1620 ns/op

所以在业务中,选择 下标 > 预分配容量大小 > 0容量


生活杂笔,学习杂记,偶尔随便写写东西。

作者

Edgar

发布于

2021-06-28

更新于

2021-12-21

许可协议

评论