问题导出
最近学习Golang,对slice的扩容策略很感兴趣。
首先看下面的一段代码
var arr1, arr2, arr3, arr4 []int64
var arr5, arr6, arr7, arr8 []int32
for i := 0; i < 25; i++ {
arr1 = append(arr1, int64(i))
arr5 = append(arr5, int32(i))
}
arr3 = append(arr3, arr1...)
arr7 = append(arr7, arr5...)
for i := 0; i < 513; i++ {
arr2 = append(arr2, int64(i))
arr6 = append(arr6, int32(i))
}
arr4 = append(arr4, arr2...)
arr8 = append(arr8, arr6...)
fmt.Printf("arr1 cap=%d\t", cap(arr1))
fmt.Printf("arr2 cap=%d\t", cap(arr2))
fmt.Printf("arr3 cap=%d\t", cap(arr3))
fmt.Printf("arr4 cap=%d\n", cap(arr4))
fmt.Printf("arr5 cap=%d\t", cap(arr5))
fmt.Printf("arr6 cap=%d\t", cap(arr6))
fmt.Printf("arr7 cap=%d\t", cap(arr7))
fmt.Printf("arr8 cap=%d\n", cap(arr8))
上面的这段程序运行的结果如下所示(Golang版本为1.21.1)
arr1 cap=32 arr2 cap=848 arr3 cap=26 arr4 cap=608
arr5 cap=32 arr6 cap=864 arr7 cap=28 arr8 cap=576
对于arr1和arr5的结果看上去应该很好理解,在插入第17个元素的时候,发生扩容,容量变为2倍,似乎就是这样。但是,对于arr2和arr6的结果就一脸疑惑,还有就是对于arr3、arr4、arr7和arr8的结果也无从下手分析。为什么会这样?通过对比arr2和arr6的容量,似乎扩容之后的容量还与单个元素所占内存相关,是这样吗?接下来我们一起来探究。
sizeclasses.go文件
在Golang中,有一个sizeclasses.go,存在runtime包下,该文件的部分代码如下所示:
// class bytes/obj bytes/span objects tail waste max waste min align
// 1 8 8192 1024 0 87.50% 8
// 2 16 8192 512 0 43.75% 16
// 3 24 8192 341 8 29.24% 8
// 4 32 8192 256 0 21.88% 32
// 5 48 8192 170 32 31.52% 16
// 6 64 8192 128 0 23.44% 64
// 7 80 8192 102 32 19.07% 16
// 8 96 8192 85 32 15.95% 32
// 9 112 8192 73 16 13.56% 16
// 10 128 8192 64 0 11.72% 128
// 11 144 8192 56 128 11.82% 16
// 12 160 8192 51 32 9.73% 32
// 13 176 8192 46 96 9.59% 16
// 14 192 8192 42 128 9.25% 64
// 15 208 8192 39 80 8.12% 16
// 16 224 8192 36 128 8.15% 32
// 17 240 8192 34 32 6.62% 16
// 18 256 8192 32 0 5.86% 256
// 19 288 8192 28 128 12.16% 32
// 20 320 8192 25 192 11.80% 64
// 21 352 8192 23 96 9.88% 32
// 22 384 8192 21 128 9.51% 128
// 23 416 8192 19 288 10.71% 32
// 24 448 8192 18 128 8.37% 64
// 25 480 8192 17 32 6.82% 32
// 26 512 8192 16 0 6.05% 512
// 27 576 8192 14 128 12.33% 64
// 28 640 8192 12 512 15.48% 128
// 29 704 8192 11 448 13.93% 64
// 30 768 8192 10 512 13.94% 256
// 31 896 8192 9 128 15.52% 128
// 32 1024 8192 8 0 12.40% 1024
// 33 1152 8192 7 128 12.41% 128
// 34 1280 8192 6 512 15.55% 256
// 35 1408 16384 11 896 14.00% 128
// 36 1536 8192 5 512 14.00% 512
// 37 1792 16384 9 256 15.57% 256
// 38 2048 8192 4 0 12.45% 2048
// 39 2304 16384 7 256 12.46% 256
// 40 2688 8192 3 128 15.59% 128
// 41 3072 24576 8 0 12.47% 1024
// 42 3200 16384 5 384 6.22% 128
// 43 3456 24576 7 384 8.83% 128
// 44 4096 8192 2 0 15.60% 4096
// 45 4864 24576 5 256 16.65% 256
// 46 5376 16384 3 256 10.92% 256
// 47 6144 24576 4 0 12.48% 2048
// 48 6528 32768 5 128 6.23% 128
// 49 6784 40960 6 256 4.36% 128
// 50 6912 49152 7 768 3.37% 256
// 51 8192 8192 1 0 15.61% 8192
// 52 9472 57344 6 512 14.28% 256
// 53 9728 49152 5 512 3.64% 512
// 54 10240 40960 4 0 4.99% 2048
// 55 10880 32768 3 128 6.24% 128
// 56 12288 24576 2 0 11.45% 4096
// 57 13568 40960 3 256 9.99% 256
// 58 14336 57344 4 0 5.35% 2048
// 59 16384 16384 1 0 12.49% 8192
// 60 18432 73728 4 0 11.11% 2048
// 61 19072 57344 3 128 3.57% 128
// 62 20480 40960 2 0 6.87% 4096
// 63 21760 65536 3 256 6.25% 256
// 64 24576 24576 1 0 11.45% 8192
// 65 27264 81920 3 128 10.00% 128
// 66 28672 57344 2 0 4.91% 4096
// 67 32768 32768 1 0 12.50% 8192
为什么要说这个文件,因为后面slice扩容策略所需要的对比文件。需要关注bytes/obj列
单个元素插入导致的扩容策略
容量小于256
对应上述例子中的arr1、arr5,计算slice的容量大小的公式如下所示:
newSlice = oldSlice * 2 * 单个元素的所占内存 -> 查看sizeclasses中对应的表格,得到离这个数字最近的大于它的数
newSlice = 得到之后的数 / 单个元素的所占内存
上述的方法得到的就是最终slice扩容后的容量大小,需要注意的是,我们所查的表是bytes/obj列
为了验证上述方法是否正确,我们将例子中的arr5进行手动的计算:
最开始的arr1的容量为0,插入第一个元素的时候,发生了扩容,按照计算公式
newSlice = 0 * 2 * 4 得到0,通过查看表格,最近的是8,然后newSlice = 8 / 4得到2
这个就是为什么int32的slice插入第一个元素的时候容量是2,这个时候又有一个有趣的事情
var a []int32
a2 := []int32{1}
a = append(a, int32(3))
fmt.Println(cap(a), cap(a2))
上述的代码得到的结果是
2 1
其实这个是非常容易理解的,一个是发生了扩容,一个是初始化。具体就不展开。
回到对arr5的扩容分析。插入第一个元素之后,目前的容量变为了2,当第三个元素插入的时候 ,再次发生扩容,通过计算2 * 2 * 4 = 16,查表正好有,那么最后的结果为16 / 4 = 4,所以插入第三个元素的时候,容量扩容为4。同样的道理,在插入第5个元素的时候,发生扩容,4 * 2 * 4 = 32,查看表格也有,那么得到32 / 4 = 8。通过同样的方法可以得到arr5最后的容量为什么是32。
对于arr1的分析和arr5是一样的步骤,只不过是单个元素的内存为8字节。
容量大于256
容量大于256,对应例子中的arr2和arr6。计算扩容后的方法如下所示:
newSlice = ((old + (oldSize + 3*256)/4) * 单个元素所占字节数) ->查看表格获得最近的大于它的值
获得的值 / 单个元素所占字节数 就是最终扩容的大小
上述的方法就是展示容量大于256之后的计算方法
同样的,我们以arr2为例子,分析其扩容的结果。
首先在arr2插入第257个元素的时候,发生扩容:(256 + (256 + 3 * 256) / 4) * 8 = 4096,通过查看表,得到离它最近的大于它的值正好是4096,然后4096 / 8 = 512。接着,在插入第513个元素的时候,再次发生扩容,带入公式:(512+ (512+ 3 * 256) / 4) * 8 = 6656,通过查表可以得到,符合要求的值为6784, 6784 / 8 = 848。这个就是arr2在大于256之后的扩容计算流程。arr6也可以通过同样的方法计算得到其最终的结果,只不过int32的单个内存为4字节。
上述就是所有单个元素插入slice,发生扩容时候的容量计算方法。
批量插入元素导致扩容
批量插入元素导致扩容,对应例子中的arr3, arr4, arr7和arr8。
此时介绍slice.go文件中slice扩容的关键代码。
newcap := oldCap
doublecap := newcap + newcap
if newLen > doublecap {
newcap = newLen
} else {
const threshold = 256
if oldCap < threshold {
newcap = doublecap
} else {
// Check 0 < newcap to detect overflow
// and prevent an infinite loop.
for 0 < newcap && newcap < newLen {
// Transition from growing 2x for small slices
// to growing 1.25x for large slices. This formula
// gives a smooth-ish transition between the two.
newcap += (newcap + 3*threshold) / 4
}
// Set newcap to the requested cap when
// the newcap calculation overflowed.
if newcap <= 0 {
newcap = newLen
}
}
}
上述的代码解决了单个插入扩容和批量插入扩容的问题,需要注意的是其中得到的newCap数值并不是真正的cap结果。需要将newCap * 单个元素的字节数,然后还是通过查表得到最后的结果。
在这一节中,不分析最开始的例子中的计算流程,而是看下面的一段代码:
var arr9 []int32
for i := 0; i < 32; i++ {
arr9 = append(arr9, int32(i))
}
fmt.Println("arr9 cap=", cap(arr9))
arr9 = append(arr9, arr6...)
fmt.Println("arr9 cap=", cap(arr9), " len=", len(arr9))
arr9 = append(arr9, arr6...)
fmt.Println("arr9 cap=", cap(arr9), " len=", len(arr9))
上面代码中的arr6是开头例子中的arr6。其运行结果如下所示
arr9 cap= 32
arr9 cap= 576 len= 545
arr9 cap= 1344 len= 1058
在这个例子中是在有原数据的情况下插入大量的数据导致扩容。分析一下输出的结果。
首先,第一个输出很简单,和最开始的例子一样。对于第二个输出,批量插入了513个元素,此时发生了扩容,回到slice.go的源文件中的扩容部分代码,很明显 newLen > doublecap,所以newCap就是32 + 513 = 545,然后545 * 4 = 2180,通过查表,得到2304,然后2304 / 4 = 576。对于第三个输出,newLen = 1058, newLen < doublecap,然后就进入那个循环中,576 + (576 + 3 * 256) / 4 = 912 。因为912 < 1058, 再次循环 912 + (912+ 3 * 256) / 4 = 1332, 此时1322 > 1058,那么newCap就是1322。最后1322 * 4 = 5288,通过查表,得到5376, 5376 / 4 = 1344。
总结
想要理解slice扩容, slice.go文件和sizeclasses.go文件是最为重要的。通过结合slice.go和sizeclasses.go文件可以熟练掌握slice扩容的本质。