Go 切片扩容:别背结论,跑一遍看数据
「切片扩容是翻倍」——这句话对一半。
对的是:元素数量小于 256 时确实是翻倍。错的是:超过 256 之后不是,而且最终容量往往比你算出来的还大一点。
与其背结论,不如跑一遍。
实验代码
package main
import "fmt"
func main() {
var s []int64
prev := -1
for i := 0; i < 40; i++ {
s = append(s, 0)
if cap(s) != prev {
fmt.Printf("len=%2d cap=%d\n", len(s), cap(s))
prev = cap(s)
}
}
}
只打印容量发生变化的时刻,因为 append 没触发扩容时 cap 是不变的。
实测结果
我本机(Go 1.26, linux/amd64)跑出来是这样:
len= 1 cap=1
len= 2 cap=2
len= 3 cap=4
len= 5 cap=8
len= 9 cap=16
len= 17 cap=32
len= 33 cap=64
干净利落的翻倍。换成 16 字节的结构体也一样,只是同样的 cap 对应更大的字节数。
再看大容量区间:
cap 256 -> 增长因子 2.000
cap 512 -> 增长因子 1.656
看到 1.656 这个数字,就知道「翻倍」的说法到此为止了。
规则在 runtime 里
runtime/slice.go 的 growslice 里,核心逻辑是这样:
newcap := oldCap
doublecap := newcap + newcap
if newLen > doublecap {
newcap = newLen
} else {
const threshold = 256
if oldCap < threshold {
newcap = doublecap
} else {
for 0 < newcap && newcap < newLen {
newcap += (newcap + 3*threshold) / 4
}
if newcap <= 0 {
newcap = newLen
}
}
}
拆开看:
- 如果需要的长度比翻倍还大,那直接取需要的长度——没有腾挪空间
- 旧容量小于 256,翻倍
- 旧容量大于等于 256,用
newcap += (newcap + 3*256) / 4这个式子增长
那个式子化简一下是 newcap * 1.25 + 192。所以在 256 附近,增长因子接近 2;容量的绝对值越大,192 这个常数项占比越小,因子就越贴近 1.25。
这就是为什么 512 → 848 的因子是 1.656:不是简单的 1.25,也不是 2,而是公式在中间地带的实际取值。
还有一层:内存对齐
上面算出来的 newcap 是元素个数,接下来要乘以元素大小换成字节,再交给分配器。而分配器不会给你任意字节数——它只会从预定义的 size class 里挑一个不小于请求值的规格。
capmem = roundupsize(uintptr(newcap) * et.Size_)
newcap = int(capmem / et.Size_)
回到 512 → 848:按公式算出 newcap = 832,乘 8 字节是 6656 字节。而 size class 里 6656 之后的下一个是 6784,于是实际分配 6784 字节,容量变成 6784 / 8 = 848。
这就是为什么你手算的 cap 总比实际的小一点。 不是你算错了,是分配器对齐全的。
顺带一个实用推论:如果元素类型的大小不能整除 size class,切片的实际容量会进一步偏离。想知道某个 []T 的真实行为,只能自己跑。
这对写代码意味着什么
知道大概要放多少元素,就预分配。
// 需要扩容若干次,每次都要拷贝
var out []Item
for _, x := range src {
out = append(out, transform(x))
}
// 一次分配到位,没有拷贝
out := make([]Item, 0, len(src))
for _, x := range src {
out = append(out, transform(x))
}
扩容的代价不只是分配内存,还要把老数组里的元素整体拷贝过去。摊还下来每次 append 是 O(1),但常数项和总内存占用都会明显变差。
别依赖精确的 cap。 上面已经看到,cap 是被对齐调整过的。任何 if cap(s) == 64 之类的判断都是脆的——换个 Go 版本或者换个元素类型就可能不成立。
append 返回的新切片一定要接住。
s = append(s, x) // 对
append(s, x) // 错:如果发生了扩容,新数组没人引用,元素就丢了
这个错误 go vet 能查出来,值得把它加进 CI。
一句话总结
小于 256 翻倍,大于等于 256 按 1.25x + 192 增长,最后还要过一遍内存对齐。想要确定性,就自己 make 出来。