聊聊 Go 的 slice
date
Nov 20, 2022
slug
slice
status
Published
tags
Go
summary
通过源码分析slice
type
Post
查看slice的源码
查看go的版本
切换到我们的go安装路径,输入
得到输出
我们直接进
slice.go
这个文件看下算上注释也就300行不到的代码,短小精悍,看看定义和函数列表
结构体定义
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倍的速度增长
对于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
至于怎么设置的呢,这里有两个很巧妙的函数
Ctz64
和 roundupsize
然后我们再看这一块代码
首先我们通过
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的扩容步骤有两步
- 调整期望容量
- 如果期望容量是当前容量的两倍,直接更新,否则
- 如果小于1024,直接翻倍当前容量,否则
- 当前容量按照1.25的速度增长,直到超过期望容量
- 进行内存对齐,向上调整到最接近的2的n次幂
slice的拷贝
切片拷贝的方法是slicecopy,我们看看是怎么做的:
执行看看效果
slice遍历的坑
但是我们拷贝有一个需要注意的地方:
输出:
可以看到,我们用range去遍历切片的时候,拿到的value其实是切片里面的值拷贝,所以每次Value的地址都不变。因此我们需要用
&slice[index]
才能拿到真实的地址如果不注意到这点的话,会带来什么问题呢?当我们拷贝的时候可能就会踩坑了:
输出:
可以发现,每次append到to里面的地址都是创建的临时变量value,而value最后的值是40,所以导致最后输出值都是40,且指向的地址就是value的地址。
要避免这个坑,我们要记住for遍历的时候会创建临时变量,避免直接对临时变量操作。
一些问题
- 为什么1024之后的扩展速度变为1.25倍呢?
因为内存空间很宝贵,底层的数组是在内存中连续的空间,申请这样大规模连续的空间是很昂贵的,1024之后我们用1.25的速度扩展已经足够大了,这样相比于翻倍扩展能解决更多的空间
- 为什么要内存对齐?
因为内存管理会预先申请内存后再进行分配,预先申请的大小都是常用的规格,比如说8,16,32,48,64等,因此我们需要进行内存对齐来返回一个足够大的内存块使用。这样避免任意大小的内存块产生内存碎片,影响空间申请和整理。
总结
简单介绍了一下slice的代码,短小精悍,主要有初始化、扩容、拷贝三种操作。
日常使用的时候,我们最好指定cap的上限,避免扩容带来的开销。同时避免切片遍历的坑。