切片扩容机制

切片定义

slice源代码定义:

1
2
3
4
5
type slice struct {
	array unsafe.Pointer //指向底层数组的指针
	len   int            //切片长度
	cap   int            //切片容量
}

如果仅仅是提供通过下标值来操作元素的类数组操作接口,那么切片也不会在 Go 中占据重要的位置。Go 切片还支持一个重要的高级特性:动态扩容。我们提到过切片类型是部分满足零值可用理念的,即零值切片也可以通过 append 预定义函数进行元素赋值操作:

1
2
var s []byte // s被赋予零值nil
s = append(s, 1)

由于初值为零值,s 这个描述符并没有绑定对应的底层数组。而经过 append 操作后,s 显然已经绑定了属于它的底层数组。

切片扩容案例

为了方便查看切片是如何动态扩容的,我们打印出每次 append 操作后切片 s 的 len 和 cap 值:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
var s [] int  //s 被赋予零值 nil
s = append (s, 11)
fmt.Println (len (s), cap (s)) //1 1
s = append (s, 12)
fmt.Println (len (s), cap (s)) //2 2
s = append (s, 13)
fmt.Println (len (s), cap (s)) //3 4
s = append (s, 14)
fmt.Println (len (s), cap (s)) //4 4
s = append (s, 15)
fmt.Println (len (s), cap (s)) //5 8

我们看到切片 s 的 len 值是线性增长的,但 cap 值却呈现出不规则的变化。通过下图,我们更容易看清楚多次 append 操作究竟是如何让切片进行动态扩容的。

切片的动态扩容

我们看到 append 会根据切片对底层数组容量的需求对底层数组进行动态调整。

1)最初 s 初值为零值(nil),此时 s 没有绑定底层数组。

2)通过 append 操作向切片 s 添加一个元素 11,此时 append 会首先分配底层数组 u1(数组长度 1),然后将 s 内部表示中的 array 指向 u1,并设置 len = 1,cap =1。

3)通过 append 操作向切片 s 再添加一个元素 12,此时 len (s) = 1,cap (s) = 1,append 判断底层数组剩余空间不满足添加新元素的要求,于是创建了一个新的底层数组 u2,长度为 2(u1 数组长度的 2 倍),并将 u1 中的元素复制到 u2 中,最后将 s 内部表示中的 array 指向 u2,并设置 len = 2,cap = 2。

4)通过 append 操作向切片 s 再添加一个元素 13,此时 len (s) = 2,cap (s) = 2,append 判断底层数组剩余空间不满足添加新元素的要求,于是创建了一个新的底层数组 u3,长度为 4(u2 数组长度的 2 倍),并将 u2 中的元素复制到 u3 中,最后将 s 内部表示中的 array 指向 u3,并设置 len = 3,cap 为 u3 数组长度,即 4。

5)通过 append 操作向切片 s 再添加一个元素 14,此时 len (s) = 3,cap (s) = 4,append 判断底层数组剩余空间满足添加新元素的要求,于是将 14 放在下一个元素的位置(数组 u3 末尾),并将 s 内部表示中的 len 加 1,变为 4。

6)通过 append 操作向切片 s 添加最后一个元素 15,此时 len (s) = 4,cap (s) = 4,append 判断底层数组剩余空间不满足添加新元素的要求,于是创建了一个新的底层数组 u4,长度为 8(u3 数组长度的 2 倍),并将 u3 中的元素复制到 u4 中,最后将 s 内部表示中的 array 指向 u4,并设置 len = 5,cap 为 u4 数组长度,即 8。

我们看到 append 会根据切片的需要,在当前底层数组容量无法满足的情况下,动态分配新的数组,新数组长度会按一定算法扩展(参见 $GOROOT/src/runtime/slice.go 中的 growslice 函数)。新数组建立后,append 会把旧数组中的数据复制到新数组中,之后新数组便成为切片的底层数组,旧数组后续会被垃圾回收掉。这样的 append 操作有时会给 Gopher 带来一些困惑,比如通过语法 u [low: high] 形式进行数组切片化而创建的切片,一旦切片 cap 触碰到数组的上界,再对切片进行 append 操作,切片就会和原数组解除绑定:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
func main () {
    u := [] int {11, 12, 13, 14, 15}
    fmt.Println ("array:", u) // [11, 12, 13, 14, 15]
    s := u [1:3]
    fmt.Printf ("slice (len=% d, cap=% d): % v\n", len (s), cap (s), s) // [12, 13]
    s = append (s, 24)
    fmt.Println ("after append 24, array:", u)
    fmt.Printf ("after append 24, slice (len=% d, cap=% d): % v\n", len (s), cap (s), s)
    s = append (s, 25)
    fmt.Println ("after append 25, array:", u)
    fmt.Printf ("after append 25, slice (len=% d, cap=% d): % v\n", len (s), cap (s), s)
    s = append (s, 26)
    fmt.Println ("after append 26, array:", u)
    fmt.Printf ("after append 26, slice (len=% d, cap=% d): % v\n", len (s), cap (s), s)

    s [0] = 22
    fmt.Println ("after reassign 1st elem of slice, array:", u)
    fmt.Printf ("after reassign 1st elem of slice, slice (len=% d, cap=% d): % v\n", len (s), cap (s), s)
}

运行这段代码,得到如下结果:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
$go run slice_unbind_orig_array.go
array: [11 12 13 14 15]
slice (len=2, cap=4): [12 13]
after append 24, array: [11 12 13 24 15]
after append 24, slice (len=3, cap=4): [12 13 24]
after append 25, array: [11 12 13 24 25]
after append 25, slice (len=4, cap=4): [12 13 24 25]
after append 26, array: [11 12 13 24 25]
after append 26, slice (len=5, cap=8): [12 13 24 25 26]
after reassign 1st elem of slice, array: [11 12 13 24 25]
after reassign 1st elem of slice, slice (len=5, cap=8): [22 13 24 25 26]

我们看到在添加元素 25 之后,切片的元素已经触碰到底层数组 u 的边界;此后再添加元素 26,append 发现底层数组已经无法满足添加新元素的要求,于是新创建了一个底层数组(数组长度为 cap (s) 的 2 倍,即 8),并将原切片的元素复制到新数组中。在这之后,即便再修改切片中的元素值,原数组 u 的元素也没有发生任何改变,因为此时切片 s 与数组 u 已经解除了绑定关系,s 已经不再是数组 u 的描述符了。

扩容源代码

growslice函数部分源代码:

(此为1.18版本之后)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
func growslice(oldPtr unsafe.Pointer, newLen, oldCap, num int, et *_type) slice {
	// 前面代码省略...
	newcap := old.cap
	doublecap := newcap + newcap //双倍扩容(原容量的两倍)
	if cap > doublecap {         //如果所需容量大于 两倍扩容,则直接扩容到所需容量
		newcap = cap
	} else {
		const threshold = 256    //这里设置了一个 阈值 -- 256
		if old.cap < threshold { //如果旧容量 小于 256,则两倍扩容
			newcap = doublecap
		} else {
			// 检查 0 < newcap 以检测溢出并防止无限循环。
			for 0 < newcap && newcap < cap { //如果新容量 > 0 并且 原容量 小于 所需容量
				// 从小片的增长2x过渡到大片的增长1.25x。这个公式给出了两者之间的平滑过渡。(这里的系数会随着容量的大小发生变化,从2.0到无线接近1.25)
				newcap += (newcap + 3*threshold) / 4

				//当newcap计算溢出时,将newcap设置为请求的上限。
				if newcap <= 0 { // 如果发生了溢出,将新容量设置为请求的容量大小
					newcap = cap
				}
			}
		}
		// 后面代码省略...
		return slice{p, newLen, newcap}
	}
}

扩容规则如下:

  1. 如果请求容量 大于 两倍现有容量 ,则新容量 直接为请求容量。
  2. 否则(请求容量 小于等于 两倍现有容量)
    • 如果 现有容量 小于 256 ,则新容量是原来的两倍;
    • 否则:新容量 = 1.25 原容量 + 3/4 阈值 “这个公式给出了从1.25倍增长 过渡到2 倍增长,两者之间的平滑过渡。” 在此情况下,如果发生了溢出,将新容量设置为请求的容量大小。

参考

《Go语言精进之路:从新手到高手的编程思想、方法和技巧 1》13.2 切片的高级特性:动态扩容

golang slice (切片) 扩容机制详解(1.18版本后)

0%