写点什么

讲的是切片,但好像又不只是切片?

用户头像
Gopher指北
关注
发布于: 28 分钟前
讲的是切片,但好像又不只是切片?

来自公众号:Gopher指北


我内心一直有一个欲望,想要高声呼喊“我胡汉三又回来了”,而现在就是合适的时机。


正式开干之前有点手生,太久没有写技术类的文章,总有点怠惰,不得不说坚持确实是一件很难的事情。如果不是因为愧疚和写点东西能让自己稍微平静下来一些,我可能还将继续怠惰下去。


另外还有一件很有意思的事情分享一下。前一篇在公众号上的文章仅思考就花了近一个月,写只花了一天,而技术文章我一般边思考边写平均耗时一周。结果是不会骗人的,前一篇文章阅读量首次突破一千,果然这届读者的思想深度至少也有一个月那么多,老许佩服佩服。


切片底层结构

切片和结构体的互转

其他不扯多了,我们还是回归本篇主题。 在正式了解切片底层结构之前, 我们先看几行代码。


type mySlice struct {  data uintptr  len  int  cap  int}
s := mySlice{}fmt.Println(fmt.Sprintf("%+v", s))// {data:0 len:0 cap:0}s1 := make([]int, 10)s1[2] = 2fmt.Println(fmt.Sprintf("%+v, len(%d), cap(%d)", s1, len(s1), cap(s1))) // [0 0 2 0 0 0 0 0 0 0], len(10), cap(10)s = *(*mySlice)(unsafe.Pointer(&s1))fmt.Println(fmt.Sprintf("%+v", s)) // {data:824634515456 len:10 cap:10}fmt.Printf("%p, %v\n", s1, unsafe.Pointer(s.data)) // 0xc0000c2000, 0xc0000c2000
复制代码


在上述代码中,通过获取切片的地址,并将其转为*mySlice, 成功获得了切片的长度和容量。以及一个类似于指针一样的东西。而这个指针就是指向存储真实数据的数组,下面我们来进行验证。


//Data强转为一个数组s2 := (*[5]int)(unsafe.Pointer(s.data))s3 := (*[10]int)(unsafe.Pointer(s.data))// 修改数组中的数据后切片中对应位置的值也发生了变化s2[4] = 4fmt.Println(s1)  // [0 0 2 0 4 0 0 0 0 0]fmt.Println(*s2) // [0 0 2 0 4]fmt.Println(*s3) // [0 0 2 0 4 0 0 0 0 0]
复制代码


到这里,切片的底层结构已经呼之欲出了,不过为了做更进一步的验证,我们继续测试结构体转为切片的过程。


var (  // 一个长度为5的数组  dt [5]int  s4 []int)s5 := mySlice{  // 将数组地址赋值给data  data: uintptr(unsafe.Pointer(&dt)),  len:  2,  cap:  5,}// 结构体强转为切片s4 = *((*[]int)(unsafe.Pointer(&s5)))fmt.Println(s4, len(s4), cap(s4)) // [0 0] 2 5// 修改数组中的值, 切片内容也会发生变化dt[1] = 3fmt.Println(dt, s4) // [0 3 0 0 0] [0 3]
复制代码


通过上述三段代码,我们将切片的底层结构以结构体的形式更加清晰的表达出来。如下图所示,其中第一部分(Data)为指向数组的地址,第二部分(Len)为切片的长度即数组已使用的长度, 第三部分(Cap)为数组的长度。



小结:切片是对数组的包装,底层仍使用数组存储数据。


额外再多说一嘴:

reflect 包要操作切片时通过reflect.SliceHeader结构体,详见 https://github.com/golang/go/blob/master/src/reflect/value.go#L2329

runtime 对切片进行扩容时使用slice结构体, 详见 https://github.com/golang/go/blob/master/src/runtime/slice.go#L12

unsafe 题外话

在前一部分的 Demo 中几乎离不开unsafe包的使用。当然本篇并不是介绍此包的用法,只是作为一个题外话简单看一下它为什么不安全。


func otherOP(a, b *int) {  reflect.ValueOf(a)  reflect.ValueOf(b)}
var ( a = new(int) b = new(int))otherOP(a, b) // 如果注释此函数调用,最终输出结果会发生变化*(*int)(unsafe.Pointer(uintptr(unsafe.Pointer(a)) + unsafe.Sizeof(int(*a)))) = 1fmt.Println(*a, *b)
复制代码


上述代在是否注释otherOP时,其输出结果是不一致的。



当变量逃逸至堆上时变量a和变量b内存地址相邻,故能够通过a变量地址去设置b变量的值。当未逃逸到堆上时,设置变量b的值并未生效,如此我们根本无法得知修改了哪一块儿内存的值,这种不确定性在老许看来即是我们需要慎重使用此包的原因。


关于上述 demo 的补充解释:

  1. reflect.ValueOf 会调用底层的 escapes 方法以保证对象逃逸到堆中

  2. Go 中采用按大小分割的空闲链表内存分配器以及多级缓存,故 a,b 变量在大小一致且本 demo 变量较少的的情况下很有可能被分配到连续的内存空间中

创建切片

创建切片方式有四种。第一种直接通过var进行变量声明,第二种通过类型推导,第三种通过make方式去创建,第四种通过切片表达式创建。


// 通过变量声明的方式创建var a []int// 类型推导b := []int{1, 2, 3}// make创建c := make([]int, 2) // c := make([]int, 0, 5)// 切片表达式d := c[:3]
复制代码


上述例子中,前三种没什么特别好说的,老许主要介绍一下第四种,以及它的相关限制和注意事项。

简单的切片表达式

对于字符串、数组、数组指针和切片(切片指针不能使用下面的表达式)均可使用下面的表达式:


s[low:high] // 生成的切片长度为high-low
复制代码


通过上述表达式可创建新的子串或者切片。特别注意的是,对字符串使用此表达式时既不是生成字符串切片也不是生成字节切片而是生成子字符串。另外,老许在go中字符串的编码问题中提到 Go 中的字符串存储的就是 utf8 字节切片,所以我们在使用此方法获取包含中文等特殊字符时有可能会出现意想不到的结果。正确得到子串的方式应该是先转为 rune 切片再截取。


上述表达式已经可以十分方便地创建新的切片,然而更加方便地是lowhigh还可以省略。


s[2:]  // same as s[2 : len(a)]s[:3]  // same as s[0 : 3]s[:]   // same as s[0 : len(a)]
复制代码


下标限制


对不同的类型使用切片表达式,lowhigh的取值范围不同。对于字符串和数组/数组指针而言,lowhigh的取值范围为0 <= low <= len(s)。对于切片而言,lowhigh的取值范围为0 <= low <= cap(s)。在切片面试题系列一中正是对此知识点的考察。


切片容量


通过切片表达式生成的切片,其底层数组共享,因此切片的容量为底层数组的长度减去low。由此可以推断下述代码输出结果为3 83 13


var (  s1 [10]int  s2 []int = make([]int, 10, 15))s := s1[2:5]fmt.Println(len(s), cap(s))s = s2[2:5]fmt.Println(len(s), cap(s))return
复制代码

完整的切片表达式

说实话这种方式真的不常用,虽然它可以控制切片的容量,但老许在实际开发中并未使用过。其完整表达式如下:


s[low : high : max]
复制代码


这种表达式有几个需要注意的点分别是:


  • 只适用于数组、数组指针和切片不适用于字符串。

  • 和简单切片表达式不同的是,它只能忽略low这个下标且忽略后该下标默认值为 0。

  • 和简单切片表达式一样,通过完整切片表达式生成的切片底层数组共享


下标限制


对数组/数组指针而言,下标取值范围为0 <= low <= high <= max <= len(s)。对切片而言,下标取值范围为0 <= low <= high <= cap(s)。在切片面试题系列二中正是对此知识点的考察。


切片容量


前面提到此切片表达式可以控制切片的容量。在low一定的情况下,通过改变max在允许范围内的值即可改变切片的容量,其容量计算方式为max - low

切片扩容

runtime/slice.go文件的growslice函数实现了切片的扩容逻辑,在正式分析内部逻辑之前我们先看看growslice的函数签名:


type slice struct {  array unsafe.Pointer  len   int  cap   int}
func growslice(et *_type, old slice, cap int) slice
复制代码


第一个参数_type是 Go 语言类型的运行时表示,其中包含了很多元信息,例如:类型大小、对齐以及种类等。


第二个参数为待扩容切片的信息。


第三个参数为真实需要的容量,即原容量和新增元素数量之和,老许对其简称为所需容量。为了更加容易理解所需容量的含义,我们先看一段代码:


s := []int{1,2,3} // 此时切片长度和容量均为3s = append(s, 4) // 此时所需容量为3 + 1s1 := []int{1,2,3} // 此时切片长度和容量均为3s1 = append(s1, 4, 5, 6) // 此时所需容量为3 + 3
复制代码

扩容逻辑

有了上面的概念之后,下面我们看看切片扩容算法:



上图逻辑总结如下:


首先,如果所需容量大于 2 倍当前容量则新容量为所需容量。


其次,判断当前容量是否大于 1024。如果当前容量小于 1024 则新容量等于 2 倍当前容量。如果当前容量大于等于 1024 则新容量循环增加 1/4 倍新容量直到新容量大于等于所需容量。


老许在这里特别提示,和0的比较是有用的。初始时,老许也觉得这逻辑十分多余,后来有一天突然顿悟,这实际上是对整形溢出的判断。因为平时开发中很少会考虑这个问题,一时间惊为天人。也许我们和大神之间的代码差距仅仅是少了对溢出的判断。


另外一个有意思的事情是,切片的逻辑最开始也不是这样的。这逻辑并不复杂,即使是刚入门的人写起来也毫无压力。然而即便是这样简单的逻辑,也是经过多个版本的迭代才有了如今的模样。



有一说一,在老许看来 1.6 中的扩容逻辑并不算优雅。想到这儿,一种“我赢了”的感觉油然而生,程序猿的快乐就是如此简单。

计算内存容量

前文中的扩容逻辑是理想的内存分配容量,而真实的内存分配十分复杂。在 Go1.6 中切片扩容分配内存时分为四种情况,分别是类型大小为 1 字节、类型大小为指针大小、类型大小为 2 的 n 次幂和其他。而切片扩容分配内存时在不同的 Go 版本中又略有不同,这里只介绍 1.16 中类型大小为 2 的 n 次幂时内存分配。


下面直接上代码:


var shift uintptrif sys.PtrSize == 8 {  // Mask shift for better code generation.  // et.size = 1 << n  // shift = n  // &63是因为uint64中1最大左移63,再大就溢出了  shift = uintptr(sys.Ctz64(uint64(et.size))) & 63} else {  shift = uintptr(sys.Ctz32(uint32(et.size))) & 31}
复制代码


上述代码中,通过对指针大小判断以区分当前运行的是 32 位还是 64 位平台。Ctz64Ctz32函数是针对不同类型计算最低位0的个数。又因为类型大小是 2 的 n 次幂,则 0 的个数即为 n。


类型大小为 2 的 n 次幂,则类型大小一定为 1 << n,因此计算最低位 0 的个数即可得到左移的位数。

源码中通过x&(x-1) == 0表达式判断一个无符号整数是否为 2 的 n 次幂。我们平时开发中如果有类似的逻辑,请参考切片扩容源码开始装逼之旅。


接下来是计算内存容量的逻辑:


capmem = roundupsize(uintptr(newcap) << shift)newcap = int(capmem >> shift)
复制代码


结合前文易知,uintptr(newcap) << shift实际上可以理解为uintptr(newcap) * et.sizecapmem >> shift可理解为capmem / et.sizeuintptr(newcap) << shift是最理想的所需内存大小,而实际分配内存时因为内存对齐等问题无法达到理想状况,所以通过roundupsize计算出实际需要的内存大小,最后再计算出真实容量。有了这个理解之后我们接下来着重分析roundupsize函数。


func roundupsize(size uintptr) uintptr {  if size < _MaxSmallSize {    if size <= smallSizeMax-8 {      return uintptr(class_to_size[size_to_class8[divRoundUp(size, smallSizeDiv)]])    } else {      return uintptr(class_to_size[size_to_class128[divRoundUp(size-smallSizeMax, largeSizeDiv)]])    }  }  if size+_PageSize < size {    return size  }  return alignUp(size, _PageSize)}
复制代码


上面函数有很多含义不清楚的变量,老许接下来会对其一一解释。


_MaxSmallSize: 其值为32768,即 32kb 大小。在 Go 中,当对象大小超过 32kb 时,内存分配策略和小于等于 32kB 时是有区别的。


smallSizeMax: 其值为 1024 字节。


smallSizeDiv: 其值为 8 字节。


largeSizeDiv: 其值为 128 字节。


_PageSize: 8192 字节,即 8kb 大小。Go 按页来管理内存,而每一页的大小就为 8kb。


class_to_size: Go 中的内存分配会按照不同跨度(也可理解为内存大小)将内存分割成不同内存块链表。当需要分配内存时,按照对象大小去匹配最合适的跨度找到空闲的内存块儿。Go 中总共分为 67 个跨度,class_to_size是一个长度为 68 的数组,分别记录0和这 67 个跨度的值。源码详见sruntime/izeclasses.go


size_to_class8: 这是一个长度为 129 的数组,代表的内存大小区间为0~1024字节。以索引i为例,此位置的对象大小mi * smallSizeDivsize_to_class8[i]的值为class_to_size数组中跨度最接近m的下标。


size_to_class128:这是一个长度为 249 的数组,代表的内存大小区间为1024~32768字节。以索引i为例,此位置的对象大小msmallSizeMax + i*largeSizeDiv, size_to_class128[i]的值为class_to_size数组中跨度最接近m的下标。


divRoundUp: 此函数返回a/b向上舍入最接近的整数。


alignUp: alignUp(size, _PageSize) = _PageSize * divRoundUp(size, _PageSize)


最终将计算实际需要内存大小的逻辑表示如下:



到这里,切片扩容的核心逻辑就已经分析完毕。本篇不对类型大小为 1 字节、类型大小为指针大小以及其他大小进行扩容逻辑分析的原因是整体逻辑差别不大。在老许看来源码中对类型大小区分的主要目的是为了尽可能减少除法和乘法运算。每每阅读这些优秀的源码都令老许直呼细节怪物。


为了加深印象我们以切片面试题系列三中的一个例子进行一次演算。


s3 := []int{1, 2}s3 = append(s3, 3, 4, 5)fmt.Println(cap(s3))
复制代码


根据前文知,所需容量为 5,又因所需容量大于 2 倍当前容量,故新容量也为 5。


又因为 int 类型大小为 8(等于 64 位平台上的指针大小),所以实际需要的内存大小为5 * 8 = 40字节。而 67 个跨度中最接近 40 字节的跨度为 48 字节,所以实际分配的内存容量为 48 字节。


最终计算真实的容量为48 / 8 = 6,和老许实际运行输出一致。


最后,衷心希望本文能够对各位读者有一定的帮助。


  1. 写本文时, 笔者所用 go 版本为: go1.16.6

  2. 文章中所用完整例子:https://github.com/Isites/go-coder/blob/master/slice/main.go

发布于: 28 分钟前阅读数: 5
用户头像

Gopher指北

关注

还未添加个人签名 2020.09.15 加入

欢迎关注公众号:Gopher指北

评论

发布
暂无评论
讲的是切片,但好像又不只是切片?