切片的容量是怎样增长的
一般都是在向 slice 追加了元素之后,才会引起扩容。追加元素调用的是 append
函数。
先来看看 append
函数的原型:
append 函数的参数长度可变,因此可以追加多个值到 slice 中,还可以用 ...
传入 slice,直接追加一个切片。
append
函数返回值是一个新的slice,Go编译器不允许调用了 append 函数后不使用返回值。
所以上面的用法是错的,不能编译通过。
使用 append 可以向 slice 追加元素,实际上是往底层数组添加元素。但是底层数组的长度是固定的,如果索引 len-1
所指向的元素已经是底层数组的最后一个元素,就没法再添加了。
这时,slice 会迁移到新的内存位置,新底层数组的长度也会增加,这样就可以放置新增的元素。同时,为了应对未来可能再次发生的 append 操作,新的底层数组的长度,也就是新 slice
的容量是留了一定的 buffer
的。否则,每次添加元素的时候,都会发生迁移,成本太高。
新 slice 预留的 buffer
大小是有一定规律的。网上大多数的文章都是这样描述的:
当原 slice 容量小于
1024
的时候,新 slice 容量变成原来的2
倍;原 slice 容量超过1024
,新 slice 容量变成原来的1.25
倍。
我在这里先说结论:以上描述是错误的。
为了说明上面的规律是错误的,我写了一小段玩具代码:
我先创建了一个空的 slice
,然后,在一个循环里不断往里面 append
新的元素。然后记录容量的变化,并且每当容量发生变化的时候,记录下老的容量,以及添加完元素之后的容量,同时记下此时 slice
里的元素。这样,我就可以观察,新老 slice
的容量变化情况,从而找出规律。
运行结果:
在老 slice 容量小于1024的时候,新 slice 的容量的确是老 slice 的2倍。目前还算正确。
但是,当老 slice 容量大于等于 1024
的时候,情况就有变化了。当向 slice 中添加元素 1280
的时候,老 slice 的容量为 1280
,之后变成了 1696
,两者并不是 1.25
倍的关系(1696/1280=1.325)。添加完 1696
后,新的容量 2304
当然也不是 1696
的 1.25
倍。
可见,现在网上各种文章中的扩容策略并不正确。我们直接搬出源码:源码面前,了无秘密。
从前面汇编代码我们也看到了,向 slice 追加元素的时候,若容量不够,会调用 growslice
函数,所以我们直接看它的代码。
看到了吗?如果只看前半部分,现在网上各种文章里说的 newcap
的规律是对的。现实是,后半部分还对 newcap
作了一个内存对齐
,这个和内存分配策略相关。进行内存对齐之后,新 slice 的容量是要 大于等于
老 slice 容量的 2倍
或者1.25倍
。
之后,向 Go 内存管理器申请内存,将老 slice 中的数据复制过去,并且将 append 的元素添加到新的底层数组中。
最后,向 growslice
函数调用者返回一个新的 slice,这个 slice 的长度并没有变化,而容量却增大了。
【引申1】
来看一个例子,来源于这里
代码
切片对应状态
s := []int{5}
s 只有一个元素,[5]
s = append(s, 7)
s 扩容,容量变为2,[5, 7]
s = append(s, 9)
s 扩容,容量变为4,[5, 7, 9]
。注意,这时 s 长度是3,只有3个元素
x := append(s, 11)
由于 s 的底层数组仍然有空间,因此并不会扩容。这样,底层数组就变成了 [5, 7, 9, 11]
。注意,此时 s = [5, 7, 9]
,容量为4;x = [5, 7, 9, 11]
,容量为4。这里 s 不变
y := append(s, 12)
这里还是在 s 元素的尾部追加元素,由于 s 的长度为3,容量为4,所以直接在底层数组索引为3的地方填上12。结果:s = [5, 7, 9]
,y = [5, 7, 9, 12]
,x = [5, 7, 9, 12]
,x,y 的长度均为4,容量也均为4
所以最后程序的执行结果是:
这里要注意的是,append函数执行完后,返回的是一个全新的 slice,并且对传入的 slice 并不影响。
【引申2】
关于 append
,我们最后来看一个例子,来源于 Golang Slice的扩容规则。
运行结果是:
如果按网上各种文章中总结的那样:小于原 slice 长度小于 1024 的时候,容量每次增加 1 倍。添加元素 4 的时候,容量变为4;添加元素 5 的时候不变;添加元素 6 的时候容量增加 1 倍,变成 8。
那上面代码的运行结果就是:
这是错误的!我们来仔细看看,为什么会这样,再次搬出代码:
这个函数的参数依次是 元素的类型,老的 slice,新 slice 最小求的容量
。
例子中 s
原来只有 2 个元素,len
和 cap
都为 2,append
了三个元素后,长度变为 5,容量最小要变成 5,即调用 growslice
函数时,传入的第三个参数应该为 5。即 cap=5
。而一方面,doublecap
是原 slice
容量的 2 倍,等于 4。满足第一个 if
条件,所以 newcap
变成了 5。
接着调用了 roundupsize
函数,传入 40。(代码中ptrSize是指一个指针的大小,在64位机上是8)
我们再看内存对齐,搬出 roundupsize
函数的代码:
很明显,我们最终将返回这个式子的结果:
这是 Go
源码中有关内存分配的两个 slice
。class_to_size
通过 spanClass
获取 span
划分的 object
大小。而 size_to_class8
表示通过 size
获取它的 spanClass
。
我们传进去的 size
等于 40。所以 (size+smallSizeDiv-1)/smallSizeDiv = 5
;获取 size_to_class8
数组中索引为 5
的元素为 4
;获取 class_to_size
中索引为 4
的元素为 48
。
最终,新的 slice 的容量为 6
:
至于,上面的两个魔法数组
的由来,就不展开了。
【引申2】 向一个nil的slice添加元素会发生什么?为什么?
其实 nil slice
或者 empty slice
都是可以通过调用 append 函数来获得底层数组的扩容。最终都是调用 mallocgc
来向 Go 的内存管理器申请到一块内存,然后再赋给原来的nil slice
或 empty slice
,然后摇身一变,成为“真正”的 slice
了。
Last updated