在 Go 中,Slice(切片)是抽象在 Array(数组)之上的特殊类型。为了更好地了解 Slice,第一步需要先对 Array 进行理解。深刻了解 Slice 与 Array 之间的区别后,就能更好的对其底层实现更好地理解。
先通过源码查看一下go的slice底层
1 | type slice struct { |
slice
类型包括三个字段
- array:指向所引用的数组指针(
unsafe.Pointer
可以表示任何可寻址的值的指针) - len:长度,当前引用切片的元素个数
- cap:容量,当前引用切片的容量(底层数组的元素总数)
关于切片底层还是移步这个视频吧,实在不想去画图长篇论述,这个up已经讲得很好了。我这篇文章只是想总结一下切片扩容。
slice扩容
1. 预估扩容后容量
1 | ints := []int{1, 2} // 扩容前 oldCap = 2 |
预估扩容规则
- 如果旧的容量乘以2 小于 所需最小容量,那么容量
newCap
就等于所需最小容量 - 如果不符合上面的情况,也就是说旧的容量乘以2 大于等于 所需最小容量。接下来判断长度
len
。如果旧的长度小于等于1024,那么直接在旧的容量基础上翻倍扩容;如果旧的长度大于等于1024,先在旧的基础上扩1.25倍,循环往复扩充。
源代码
1 | func growslice(et *_type, old slice, cap int) slice { |
2. 匹配到合适的内存规格
所需内存 = 预估容量 * 元素类型大小
比如预估容量是3个,int类型,那么所需内存就是24,但实际分配的时候并不是直接向系统索要24个字节的内存。而是想go本身的内存管理模块申请。
Go语言的内存管理模块将内存分成了大大小小67个级别的span,其中0级代表特殊的大对象,其大小是不固定的。当具体的对象需要分配内存时,并不是直接分配span,而是分配不同级别的span中的元素。因此span的级别也不是以每个span大小为依据,而是以span中元素的大小为依据。
1 | span等级 元素大小 span大小 对象个数 |
申请内存时,内存管理模块会帮我们选足够大且最接近的内存规格,所以上面3个int类型需要24个字节的内存,那么实际分配的是第3个span等级,32个字节大小的内存span。
32个字节的内存span 能存储 大小为8个字节的int类型 共四个。所以真实的扩容容量为 4 而不是预估容量3。
扩容及内存分配部分源码
1 | func growslice(et *_type, old slice, cap int) slice { |
1、获取老 Slice 长度和计算假定扩容后的新 Slice 元素长度、容量大小以及指针地址(用于后续操作内存的一系列操作)
2、确定新 Slice 容量大于老 Sice,并且新容量内存小于指定的最大内存、没有溢出。否则抛出异常
3、若元素类型为 kindNoPointers
,也就是非指针类型。则在老 Slice 后继续扩容
- 第一步:根据先前计算的
capmem
,在老 Slice cap 后继续申请内存空间,其后用于扩容 - 第二步:将 old.array 上的 n 个 bytes(根据 lenmem)拷贝到新的内存空间上
- 第三步:新内存空间(p)加上新 Slice cap 的容量地址。最终得到完整的新 Slice cap 内存地址
add(p, newlenmem)
(ptr) - 第四步:从 ptr 开始重新初始化 n 个 bytes(capmem-newlenmem)
注:那么问题来了,为什么要重新初始化这块内存呢?这是因为 ptr 是未初始化的内存(例如:可重用的内存,一般用于新的内存分配),其可能包含 “垃圾”。因此在这里应当进行 “清理”。便于后面实际使用(扩容)
4、不满足 3 的情况下,重新申请并初始化一块内存给新 Slice 用于存储 Array
5、检测当前是否正在执行 GC,也就是当前是否启用 Write Barrier(写屏障),若启用则通过 typedmemmove
方法,利用指针运算循环拷贝。否则通过 memmove
方法采取整体拷贝的方式将 lenmem 个字节从 old.array 拷贝到 ptr,以此达到更高的效率
注:一般会在 GC 标记阶段启用 Write Barrier,并且 Write Barrier 只针对指针启用。那么在第 5 点中,你就不难理解为什么会有两种截然不同的处理方式了
这里需要注意的是,扩容时的内存管理的选择项,如下:
- 翻新扩展:当前元素为
kindNoPointers
,将在老 Slice cap 的地址后继续申请空间用于扩容 - 举家搬迁:重新申请一块内存地址,整体迁移并扩容
3. 陷阱
1. 同根
Slice 切片指向所引用的 Array。因此在 Slice 上的变更。会直接修改到原始 Array 上(两者所引用的是同一个)
2. 时过境迁
随着 Slice 不断 append,内在的元素越来越多,终于触发了扩容。往 Slice append 元素时,若满足扩容策略,也就是假设插入后,原本数组的容量就超过最大值了,这时候内部就会重新申请一块内存空间,将原本的元素拷贝一份到新的内存空间上。此时其与原本的数组就没有任何关联关系了,再进行修改值也不会变动到原始数组。这是需要注意的
3. 复制
复制不会触发扩容,所以我们必须准备好cap够用的dst slice。
copy 函数支持在不同长度的 Slice 之间进行复制,若出现长度不一致,在复制时会按照最少的 Slice 元素个数进行复制
那么在源码中是如何完成复制这一个行为的呢?我们来一起看看源码的实现,如下:
1 | func slicecopy(to, fm slice, width uintptr) int { |
- 若源 Slice 或目标 Slice 存在长度为 0 的情况,则直接返回 0(因为压根不需要执行复制行为)
- 通过对比两个 Slice,获取最小的 Slice 长度。便于后续操作
- 若 Slice 只有一个元素,则直接利用指针的特性进行转换
- 若 Slice 大于一个元素,则从
fm.array
复制size
个字节到to.array
的地址处(会覆盖原有的值)
4. 奇特的初始化
1 | var nums []int // nil len=0 cap=0 |