Go底层探索(三):切片
@注:以下内容来自《Go语言底层原理剖析》、《Go语言设计与实现》书中的摘要信息,本人使用版本(Go1.19)与书中不一致,源码路径可能会有出入。
1.介绍
切片是Go
语言中常用的数据类型之一,使用方式和数组一样,但是其长度不固定,我们可以向切片中追加元素,它会在容量不足时自动扩容。
1.1 声明
在Go
语言中,切片类型的声明方式和数组类似,不过由于切片的长度是动态的,所以声明时只需要指定切片中的元素类型。
1 | []int |
从切片的定义,我们可以推测出,切片在编译期间生成的类型只会包含切片中的元素类型,即int
或interface{}
等,cmd/compile/internal/types.NewSlice
就是编译期间用于创建切片类型的函数。
1 | func NewSlice(elem *Type) *Type { |
上述方法返回结构体中的
Extra
字段是一个只包含切片内元素类型的结构,也就是说切片内元素的类型是在编译期间确定的,编译器确定了类型之后,会将类型存储在Extra
字段中帮助程序在运行时动态获取。
2.数据结构
编译期间的切片cmd/compile/internal/types.Slice
类型,但是在运行时切片会转换成reflect.SliceHeader
结构体,如下:
1 | type SliceHeader struct { |
Data
:指向数组的指针Len
:当前切片的长度Cap
:当前切片的容量,即Data
数组的大小
2.1 存储原理
Data
是一片连续的内存空间,这片内存空间可以用于存储切片中的全部元素,数组中的元素只是逻辑上的概念,底层存储其实都是连续的,所以我们可以将切片理解成一片连续的内存空间加上长度和容量的标识,如下图所示:
从上图中,我们会发现,切片和数组的关系非常密切,切片引入了一个抽象层,提供了对数组中部分连续片段的引用,而作为数组的引用,我们在运行期间可以修改其长度的范围,当切片底层的数组长度不足时就会触发扩容,切片指向的数组可能会发生变化,不过在上层来看切片是没有变化的,上层只需要和切片打交道,不需要关心数组的变化。
3.初始化
Go
语言包含三种初始化切片的方式:
- 通过下标的方式获取数组或者切片的一部分
- 使用字面量初始化新的切片
- 使用关键字
make
创建切片
3.1 使用下标
使用下标创建切片是最原始也是最接近汇编语言的方式,它是所有方法中最为底层的一种,编译器会将arr[0:3]
或slice[0:3]
等语句转换成OpSliceMake
操作,我们可以通过下面的代码进行验证:
1 | import "fmt" |
通过GOSSAFUNC
变量编译上述代码可以得到一系列SSA
中间代码,其中slice := arr[0:1]
语句在decompose builtin
阶段对应的代码如下图所示:
SliceMake
操作会接收四个参数来创建新的切片:元素类型、数组指针、切片大小和容量,这也是我们在数据结构一节中提到的切片的几个字段,需要注意的是使用下标初始化不会拷贝原数组或者原切片中的数据,它只会创建一个指向原数组或者原切片的切片结构体,所以修改新切片的数据也会修改原数组或者原切片。
1 | func TestRun(t *testing.T) { |
3.2 使用字面量
当我们使用字面量[]int{1,2,3}
创建新切片时,cmd/compile/internal/gc.slicelit
函数会在编译期间将它展开成如下所示的代码片段:
1 | var vstat [3]int |
对上述代码的分析:
- 根据切片中的元素数量对底层数组的大小进行推断并创建一个数组
- 将这些字面量元素存储到初始化的数组中
- 创建一个同样指向
[3]int
类型的数组指针 - 将静态存储区的数组
vstat
赋值给指针所在的地址 - 通过
[:]
操作获取一个底层使用vstat
的切片
第5步中的
[:]
就是使用下标创建切片的方法,从这一点我们也可以看出[:]
操作时创建切片的最底层的方法。
3.3 使用make
如果使用字面量的方式创建切片,大部分的工作都是在编译期完成。但是我们使用make
关键字创建切片时,很多工作都需要运行时的参与;调用方必须向make
函数传入切片的大小以及可选的容量。
类型检查期间的cmd/compile/internal/gc.typecheck1
函数会校验入参:
1 | func typecheck1(n *Node, top int) (res *Node) { |
上述函数不仅会检查len
是否传入,还会保证传入的容量cap
一定大于或等于len
。除了校验参数,当前函数会将OMAKE
节点转换成OMAKESLICE
,中间代码生成的cmd/compile/internal/gc.walkexpr
函数会依据下面两个条件转换成OMAKESLICE
类型的节点。
- 切片的大小和容量是否足够小
- 切片是否发生了逃逸,最终在堆上初始化
当切片发生逃逸或者非常大时,运行时需要runtime.makeslice
在堆上初始化切片,如果当前的切片不会发生逃逸并且切片非常小的时候,那么切片运行时最终会被分配在栈区。
此临界值定义在
cmd/compile/internal/ir.MaxImplicitStackVarSize
变量中,默认为64KB
,可以通过指定编译时smallframes
标识进行更新,因此,make([]int64,1023)
与make([]int64,1024)
实现的细节是截然不同的。
4.扩容原理
4.1 扩容触发
关键字append
是触发扩容的主要方式,但不是每次使用append
函数就一定会扩容,看下面的代码实例:
1 | func main() { |
代码分析:
- 切片
a
容量为4,目前长度为3(初始化3个0
),所以还能在容纳1个元素,在执行append
时,不会进行扩容,所以cap
还是4 - 切片
b
容量为3,目前长度也是3(初始化3个0
),如果再append
,切片容量会不足,便会进行扩容。所以cap
为6,至于为什么是6,看后续源码分析
4.2 容量判定
当切片的容量不足时,append
函数在运行时会调用runtime.growslice
函数为切片扩容。
扩容是为切片分配新的内存空间,并拷贝原切片中的元素到新空间的过程。
runtime.growslice
的源码如下:
1 | func growslice(et *_type, old slice, cap int) slice { |
上述代码显示了扩容的核心逻辑,Go
语言中切片扩容的策略整理如下:
- 如果新申请容量(
cap
)大于2倍的旧容量(old.cap
),则最终容量(newcap
)是新申请的容量(cap
) - 如果旧切片的容量小于
1024
,则最终容量是旧容量的2倍,即newcap=doublecap
- 如果旧切片长度大于或等于
1024
,则最终容量从旧容量开始循环增加原来的1/4
,直到最终容量大于或等于新申请的容量为止,即newcap ≥ cap
- 如果最终容量计算值溢出,即超过了
int
的最大范围,则最终容量就是新申请容量
growslice
函数会根据切片的类型,分配不同大小的内存,为了内存对齐,申请的内存可能大于实际的类型大小 * 容量大小
。
5.切片复制
5.1 示例代码
先看一段代码,试着分析会输出什么?
1 | func TestCopySlice(t *testing.T) { |
切片的复制其实也是值复制,但是这里的值复制指对于运行时SliceHeader
结构的复制,但是底层指针指向的是相同的底层数组,因此可以理解为数据进行了引用传递。
切片的这一特性使得即使切片中有大量数据,在复制时的成本也比较小。数组也是如此。
5.2 使用copy
复制的切片不会改变指向底层的数组,但有时我们希望创建一个新切片,并且与旧切片不共享底层的数组,这时可以使用copy
函数。示例如下:
1 | func TestCopySlice2(t *testing.T) { |
copy
函数在运行时主要调用了memmove
函数,用于实现内存的复制。如果采用协程调用的方式go copy(numbers1, numbers)
后者加入了race
检测,则会调用运行时runtime.slicecopy
函数,无论是编译期间拷贝还是运行时拷贝,两种拷贝方式都会通过runtime.memmove
将整块内存的内容拷贝到目标的内存区域中。