聊聊 Go 的 slice

date
Nov 20, 2022
slug
slice
status
Published
tags
Go
summary
通过源码分析slice
type
Post

查看slice的源码

查看go的版本
切换到我们的go安装路径,输入
得到输出
我们直接进slice.go 这个文件看下
算上注释也就300行不到的代码,短小精悍,看看定义和函数列表
notion image
结构体定义
slice是在底层的数组上面包了一层,array是指向底层数组的指针,底层数组是真正存储值的地方。而len和cap和分别表示数组当前长度和数组的容量,slice根据它们的大小关系来进行扩容。

slice的初始化

我们初始化一个slice,可以有两种方法,分别是:
对于slice来说,make的第一个参数指定存储类型,这里是int,第二个是size,可选为0,第三个是cap,指定容量
我们一般推荐使用第二种方式,指定好cap,避免扩容带来的开销,而size我们一般指定为0,表示不存储任何元素。
如果我们指定了size,那么会在生成size个默认值在slice中,比如说int类型的默认值就是0。请看下面的打印结果
make([]int, size) 方式初始化,很容易忘记我们已经存储了若干个值了。除非我们真的需要这样的slice,不然还是用第二种初始化方式比较好。
下面我们看看初始化的源码:
外部的逻辑是检查我们开的容量会不会溢出,如果溢出就抛出panic,否则的话调用mallocgc 进行初始化,mallocgc的源码涉及到一些go内存分配的知识,这里就不展开了,感兴趣可以自己看看。

slice的扩容机制

step 1. 步长

当我们len超过cap的时候就会触发扩容,实际调用的函数是growslice,我们来看看go是怎么实现的
变量
含义
cap
期望容量
old.cap
当前容量
doublecap
当前容量的两倍
newcap
最终容量
决策逻辑:
  • 如果期望容量超过当前容量的两倍,直接设置为期望容量,否则
  • 如果当前容量的值小于1024,直接翻倍,否则
  • 如果newcap < cap,不断以1.25倍的速度增长
notion image
 
对于1.25倍扩增速度,go的1.18.1版本作出了优化
这里的区别就是newcap += (newcap + 3*threshold) / 4 ,注释上说相比于旧版本,这里增长速度更加smooth,具体效果可以用python画图模拟一下,这里就不展开了。
下面我们来看一段代码,看看结果是不是和我们上面分析得一样
但是输出结果是5 6,cap被设置成了6,这是为什么呢?原因是go还会进行内存对齐

step 2. 内存对齐

et.size指的是存储元素类型的大小,这里int类型字节是8,使用下面的函数判断通过
先不看代码,我们分析一下怎么内存对齐:
我们的slice现在存储了5个元素,int类型8个字节,则新的存储容量大小newcap = 5 * 8 = 40,但是为了更好的内存分配减少碎片,go使用了和c一样的内存对齐策略,使用roundupsize 向上调整大小,这里调整成了48,那么最终的cap = 48 / 8 = 6,所以在上面的例子cap被设置为了6
为什么被调整到48呢?
原因是Go语言的内存管理通常把这些内存块都预先申请好,并且被分为常用的规格,比如8,16, 32, 48, 64等,当我们申请40 bytes的时候,会申请一个足够大的内存块给我们,这就返回的是48 bytes
至于怎么设置的呢,这里有两个很巧妙的函数 Ctz64roundupsize
然后我们再看这一块代码
首先我们通过Ctz64(et.size) 得到shift的值,int类型是8个字节,则8的二进制末尾有3个0,我们得到shift=3
然后newcap<<shift 就是 5 << 3 得到40
经过roundupsize,得到capmem = 48
最终再还原回来 newcap = int(48 >> 3) = 6
这样就能内存对齐啦

总结

slice的扩容步骤有两步
  1. 调整期望容量
    1. 如果期望容量是当前容量的两倍,直接更新,否则
    2. 如果小于1024,直接翻倍当前容量,否则
    3. 当前容量按照1.25的速度增长,直到超过期望容量
  1. 进行内存对齐,向上调整到最接近的2的n次幂

slice的拷贝

切片拷贝的方法是slicecopy,我们看看是怎么做的:
执行看看效果

slice遍历的坑

但是我们拷贝有一个需要注意的地方:
输出:
可以看到,我们用range去遍历切片的时候,拿到的value其实是切片里面的值拷贝,所以每次Value的地址都不变。因此我们需要用&slice[index]才能拿到真实的地址
notion image
如果不注意到这点的话,会带来什么问题呢?当我们拷贝的时候可能就会踩坑了:
输出:
可以发现,每次append到to里面的地址都是创建的临时变量value,而value最后的值是40,所以导致最后输出值都是40,且指向的地址就是value的地址。
要避免这个坑,我们要记住for遍历的时候会创建临时变量,避免直接对临时变量操作。
 

一些问题

  • 为什么1024之后的扩展速度变为1.25倍呢?
因为内存空间很宝贵,底层的数组是在内存中连续的空间,申请这样大规模连续的空间是很昂贵的,1024之后我们用1.25的速度扩展已经足够大了,这样相比于翻倍扩展能解决更多的空间
  • 为什么要内存对齐?
因为内存管理会预先申请内存后再进行分配,预先申请的大小都是常用的规格,比如说8,16,32,48,64等,因此我们需要进行内存对齐来返回一个足够大的内存块使用。这样避免任意大小的内存块产生内存碎片,影响空间申请和整理。

总结

简单介绍了一下slice的代码,短小精悍,主要有初始化、扩容、拷贝三种操作。
日常使用的时候,我们最好指定cap的上限,避免扩容带来的开销。同时避免切片遍历的坑。

Ref


© hhmy 2019 - 2024