“append 每次扩容 2 倍”是一个过时的说法。这篇把运行时的公式复制进实验,逐个 append 记录真实的容量,看公式与实测的差在哪里;再看不逃逸时的栈缓冲、一次追加多个元素的取整,最后量出预分配容量省多少。
[]int 元素 8 字节,逐个 append 2000 个,容量依次:4 8 16 32 64 128 256 512 848 1280 1792 2560
其中与公式 nextslicecap 不同的(再向上取整到内存 size class):0→4(公式 1)、512→848(公式 832)、848→1280(公式 1252)、1792→2560(公式 2432)
[]byte 元素 1 字节,逐个 append 600 个,容量依次:32 64 128 256 512 896
与公式不同的步骤 2 个,例如:0→32(公式 1)、512→896(公式 832)
[]int32 元素 4 字节,逐个 append 600 个,容量依次:8 16 32 64 128 256 512 864
与公式不同的步骤 2 个,例如:0→8(公式 1)、512→864(公式 832)
[]string 元素 16 字节,逐个 append 600 个,容量依次:2 4 8 16 32 71 143 303 591 1023
与公式不同的步骤 6 个,例如:0→2(公式 1)、32→71(公式 64)、71→143(公式 142)、143→303(公式 286)
[]S24 元素 24 字节,逐个 append 600 个,容量依次:1 2 4 8 16 32 64 128 256 512 853
与公式不同的步骤 1 个,例如:512→853(公式 832)运行时 runtime/slice.go 的 nextslicecap 就是上面的规则:需要的长度超过旧容量 2 倍就直接用需要的长度;旧容量小于 256 翻倍;否则不断加 (newcap + 3*256) / 4 直到够用。算出来的元素个数还要乘元素大小,再向上取整到内存分配器的 size class(这一步由 roundupsize 完成,本文没有展开 size class 表),所以实测容量常比公式值大一点。[]string 里 32 → 71 比公式 64 多出的幅度较大,我没有去查具体是哪一级 size class 或额外开销造成的。
[]int 结果逃逸(赋给全局变量)时,容量依次:1 2 4 8 16 32 64 []string 结果逃逸(赋给全局变量)时,容量依次:1 2 4 8 16 32 71 []S24 结果逃逸(赋给全局变量)时,容量依次:1 2 4 8 16 32 64 追加 1 个 int:结果不逃逸的函数 0 次分配;结果逃逸(返回给调用者)的函数 1 次分配 追加 4 个 int:结果不逃逸的函数 0 次分配;结果逃逸(返回给调用者)的函数 1 次分配 追加 5 个 int:结果不逃逸的函数 1 次分配;结果逃逸(返回给调用者)的函数 1 次分配 追加 8 个 int:结果不逃逸的函数 1 次分配;结果逃逸(返回给调用者)的函数 1 次分配 追加 9 个 int:结果不逃逸的函数 2 次分配;结果逃逸(返回给调用者)的函数 2 次分配 追加 100 个 int:结果不逃逸的函数 5 次分配;结果逃逸(返回给调用者)的函数 5 次分配
实测里 []int 的第一次容量是 4 而不是公式给的 1,[]byte 是 32,[]string 是 2——都是 32 字节除以元素大小。编译器源码 cmd/compile/internal/ssagen/ssa.go 里写得明白:如果 append 的结果不逃逸,并且元素个数够小,就用栈上分配的存储。对照实验证实:结果逃逸时容量序列是 1 2 4 8,追加 4 个 int 要 1 次分配;不逃逸时追加 4 个 int 是 0 次分配。所以“切片第一次扩容到多大”取决于编译器能不能证明它不逃逸。
[]int 旧 len=0 cap=0,追加 1 个:新 cap = 1(公式未取整:1) []int 旧 len=0 cap=0,追加 5 个:新 cap = 6(公式未取整:5) []int 旧 len=0 cap=0,追加 3 个:新 cap = 3(公式未取整:3) []int 旧 len=4 cap=4,追加 1 个:新 cap = 8(公式未取整:8) []int 旧 len=4 cap=4,追加 4 个:新 cap = 8(公式未取整:8) []int 旧 len=4 cap=4,追加 5 个:新 cap = 10(公式未取整:9) []int 旧 len=4 cap=4,追加 9 个:新 cap = 14(公式未取整:13) []int 旧 len=4 cap=4,追加 20 个:新 cap = 24(公式未取整:24) []int 旧 len=300 cap=300,追加 1 个:新 cap = 608(公式未取整:567) []int 旧 len=300 cap=300,追加 400 个:新 cap = 768(公式未取整:700)
一次追加的元素个数超过旧容量的 2 倍时,新容量不再按增长公式而是取需要的长度,再取整到 size class;追加 5 个到空切片得到 6、追加 400 个到 300 容量的切片得到 768,就是这种情形。不超过 2 倍时按公式增长再取整。
var s []int; 逐个 append 中位 4.41 ms(最小 3.61 / 最大 10.65);Mallocs +42;TotalAlloc +39.8 MiB make([]int, 0, N); 逐个 append 中位 0.78 ms(最小 0.68 / 最大 0.85);Mallocs +1;TotalAlloc +7.6 MiB make([]int, N); 按下标赋值 中位 0.52 ms(最小 0.47 / 最大 0.56);Mallocs +1;TotalAlloc +7.6 MiB 逐个 append 100 万个 int 一共扩容(换底层数组)36 次,最终 cap = 1055744(比 N 多 5.6%)
从 nil 逐个 append 100 万个 int,最终只需要 7.6 MiB,但中间换了 36 次底层数组,累计分配了 39.8 MiB——旧数组都是垃圾,要靠 GC 回收。知道大致数量时用 make([]T, 0, n) 能把它降到一次分配。耗时数字抖动较大(最大 10.65 ms),只说明量级:预分配后 append 比不预分配快几倍。