Golang切片的底层实现原理

切片究竟是什么

我们的宗旨是『知其然也必知其所以然』,说切片之前,不得不说一句go语言的数组,数组是一个固定长度的、容纳同类型元素的连续序列,比如,var a [8]int 就是定义了一个长度为8,类型为int类型的数组。

在Go语言中传递数组是纯粹的值拷贝,对于元素类型长度较大或元素个数较多的数组,如果直接以数组类型参数传递到函数中会有不小的性能损耗。有同学可能说我可以用数组指针类型做形参,对数组变量取地址传进去,可以吗?可以,能避免性能消耗,但是不如切片好使,继续往下看,带你知其所以然。

切片之于数组就像是文件描述符之于文件。数组退居幕后,承担起底层存储空间,而切片走向前台,给开发者一个更便捷使用数组的窗口。打开 GOROOT/src/runtime/slice.go,可以看到切片的类型定义:

Golang切片的底层实现原理_第1张图片

看到切片的数据结构包含3个字段:

array:指向下层数组的指针;不用着急知道这个指针指向哪,后边验证代码中就知道了

len:切片的长度,即切片中当前元素的个数,即内置函数len()返回的值

cap:切片的最大容量,即内置函数cap()返回的值,cap >= len

所以,每个切片变量都是一个runtime.slice结构体类型的实例,接下来我们通过日常定义切片的集中方式,来慢慢揭开这个结构体的真身,尤其是数组指针。

定义切片的几种方式

数组的切片化 [low:high]

定义一个数组,a := [10]int {11,12,13,14,15,16,17,18,19,20}

通过数组定义切片,s := a[3:7]

此时,数组和切片的内存布局是这样的:

Golang切片的底层实现原理_第2张图片

『知其然也必知其所以然』,是我们的宗旨,上代码:

Golang切片的底层实现原理_第3张图片

既然说切片复用的数组的存储空间,那我们把切片结构中的数组指针以及指向数组的元素a[3]地址打出来看看,并通过切片来修改元素

Golang切片的底层实现原理_第4张图片

从程序打印结果可以看到,切片的数组指针array字段和数组a[3]地址是相同的,验证了s是复用了数组a的存储空间,结构体中的数组指针指向了a[3],len=high-low,cap取决数组的长度,并且因为共享存储空间,通过修改切片的数据,反映到了数组的数据也修改了

抛一个小问题:

定义切片s的时候,如果high-low 大于数组的空间,比如 s := a[3:15], 15-3=12,会发生什么?能编译成功吗?能正确执行吗?

切片的切片

还可以通过[low:high]基于已有的切片创建新的切片,比如我们基于上边的切片s定义一个s1,s1 := s[low:high],此时的内存不均是这样的:

Golang切片的底层实现原理_第5张图片

还是我们的宗旨,继续上代码:

Golang切片的底层实现原理_第6张图片

我们基于切片s定义新切片s1,同样输出新切片的数组指针,以及切片s的第一个元素的地址和原数组a[4]的地址,并通过s1来修改元素

从打印结果看,新切片s1的数组指针array字段和数组a[4]的地址以及切片s的第一个元素的地址是相同的,证明新切片和原切片都是共享底层数组存储空间的,并且通过修改s1,也反映到了切片s和数组a

问题2:

定义s1的时候,如果high-low大于s的cap大小,比如s1 := s[1:10],会发生什么?编译是否成功?执行是否成功?

make方式和直接声明方式

此时,编译器会自动为切片建立一个底层数组,内存布局是这样的

Golang切片的底层实现原理_第7张图片

宗旨又来了,上代码:

Golang切片的底层实现原理_第8张图片

代码中,我们用make方式定义了s2和s3,然后直接定义声明方式定义了s4和s5,并把s4和s5与nil做比较

问题3:

上边打印结果中,s4和s5的len以及cap都是0,为啥和nil值的比较结果不一样?可以把最后一行注释打开,看下输出结果就知道了

到这,可以回答最开始,为什么不用数组的指针做形参而是用切片,首先,切片作为参数传递,性能损耗是恒定的而且很小的,就是上边的runtime.slice的结构体实例,另外就是切片有比数组更强大的功能使用,比如接下来要说的动态扩容

切片的动态扩容

切片除了可以作为数组的一个操作窗口,还可以动态扩容,很简单,演示一下:

Golang切片的底层实现原理_第9张图片

代码中定义空切片,然后往切片通过append追加元素,每次输出len和cap

Golang切片的底层实现原理_第10张图片

可以看到len是随着追加,线性增长的,但cap不是,用图看一下这个过程:

Golang切片的底层实现原理_第11张图片

append会根据切片的需要,在当前底层数组容量无法满足的情况下,动态分配新的数组,把旧数组里的数据复制到新数组中,之后新数组作为切片的底层数组,旧数组会被gc垃圾回收掉。具体的扩容策略,在$GOROOT/src/runtime/slice.go中的growslice函数中,golang版本不一样,扩容策略不一样,大家可以看下自己的go版本的扩容算法实现,好像在golang 1.18版本有新的实现策略,我的go版本是1.16,截取核心逻辑,看下扩容策略:

Golang切片的底层实现原理_第12张图片

看代码是,扩容机制是:

  1. 所需容量大于原先容量的2倍,则最终容量为所需容量

  1. 不满足1前提下,在原数组容量小于1024的时候,新数组容量是原数组容量的2倍

  1. 原数组容量大于等于1024的时候,新数组的容量,每次增加原容量的25%,直到大于所需容量为止

宗旨来了,验证一把:

Golang切片的底层实现原理_第13张图片

for循环2048次,每次追加一个元素,当触发扩容的时候,输出原有容量和新容量

Golang切片的底层实现原理_第14张图片

因此看到append的实现后,其实重新分配底层数组并且复制数据的操作代价还是挺大的,尤其是元素较多的清下,一个是重新分配的代价,另一个就是旧数组带来的频繁gc问题,那么如何减少这种代价呢,一个有效的方法就是根据切片的使用场景,预估出切片的容量,在定义切片的时候,使用make内置函数带cap参数的方式来定义,即 s := make([]int,len, cap),这样可以减少重新分配底层数组的次数以及gc的频率,并且性能也会提高很多,继续验证

Golang切片的底层实现原理_第15张图片

代码中,我们通过make,带和不带cap参数形式,初始化切片,然后往切片追加10000个元素,看下各自的耗时:

Golang切片的底层实现原理_第16张图片

执行了几次,从耗时结果看,带cap初始化切片比不带cap初始化切片,耗时小很多,缩小的时间就是动态扩容以及数据拷贝所耗费的时间

切片拷贝

如果你想复制拷贝一份切片单独使用,如何做呢,2种方式:

直接切片赋值; 2.使用内置函数copy,区别就是赋值是浅拷贝,copy是深拷贝,

继续我们的宗旨:

Golang切片的底层实现原理_第17张图片

代码中,我们用两种方式拷贝切片,并切输出新切片的数组指针,来说明深拷贝和浅拷贝

Golang切片的底层实现原理_第18张图片

从打印结果看,赋值的浅拷贝,新切片和原切片是共享底层数组空间的,不管对谁修改,都会反映到另一个切片上;而copy,新切片是创建了新的底层数组空间,修改不会对原切片造成影响

切片是非线程安全的

在并发写同一个切片的时候,并不是线程安全的,并不会按照切片的顺序追加,继续我们的宗旨,知其然也必知其所以然

Golang切片的底层实现原理_第19张图片

代码中,我们先定义一个空切片,然后创建100个goroutine,每个goroutine都往切片追加元素,先忽略掉注释的代码,那理论上最后切片的长度应该是100,我们看下结果

Golang切片的底层实现原理_第20张图片

从执行结果看,append在并发情况下,是会被覆盖的,所以最终切片的长度并不是100

所以要保证结果符合预期,需要做保护,把代码中注释的代码打开,再执行

Golang切片的底层实现原理_第21张图片

从执行结果看,切片的长度是100,保证了并发协程之间不会覆盖写入,不过要保证顺序,还要做下其它逻辑

总结

至此,切片的底层实现原理以及使用注意事项,在我们的『知其然也必知其所以然』的宗旨下介绍完了,你清楚了吗?

你可能感兴趣的:(Golang深度学习,golang,go)