bigywater.tech
Go · 18

append 的扩容策略:容量到底是怎么增长的

“append 每次扩容 2 倍”是一个过时的说法。这篇把运行时的公式复制进实验,逐个 append 记录真实的容量,看公式与实测的差在哪里;再看不逃逸时的栈缓冲、一次追加多个元素的取整,最后量出预分配容量省多少。

256
容量小于 256 时翻倍,之后按 (旧容量 + 768) / 4 增长
4
不逃逸时 []int 的第一次容量(32 字节栈缓冲);逃逸时是 1
39.8 MiB vs 7.6 MiB
100 万个 int 从 nil 逐个 append 累计分配的内存,与预分配的对比

逐个 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 或额外开销造成的。

第一次为什么是 4

实测 · 本机执行输出
  []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 比不预分配快几倍。

分步演示

append 的扩容策略
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Go 1.27.1(brew);用 go run / go build / go test 直接编译运行,需要第三方库时经代理从 proxy.golang.org 下载;计时用 time.Now / testing.B,数字每次运行会有小幅抖动。读过的源码:runtime/slice.go(nextslicecap、growsliceBuf)、cmd/compile/internal/ssagen/ssa.go(append 结果不逃逸时使用栈上存储的注释);公式部分把 nextslicecap 原样复制进实验程序;耗时与分配量来自 runtime.ReadMemStats,5 轮取中位数
    • 没有验证:amd64 上的容量序列、size class 表本身、指针元素(带 malloc header)导致 71 这种取整的具体原因、append 与 GC 的相互作用(runtimefreegc 实验特性)、容量大于 1 << 30 之后的增长、泛型或接口元素的容量序列
    京ICP备2026067502号-1京公网安备11010502063487号