
2048 → 1280。
你没看错。在 Go 1.17 里,我把一个 cap=1023 的 []byte append 一次,新 cap 是 2048;把同样的代码换成 cap=1024,append 一次,新 cap 反而变成 1280。
old cap 只增加了 1,new cap 直接少了 768。
这是一组很确定的实验结果:我用 Go 1.17.13 扫描 oldcap=900..1400,每个初始 slice 都只 append 1 个 byte,然后记录扩容后的 cap。结果在 1024 这个点断了一下。
[实测 Go 1.17.13 darwin/arm64]
oldcap=1023 -> append 后 newcap=2048
oldcap=1024 -> append 后 newcap=1280
[实测 Go 1.26.2 darwin/arm64]
oldcap=1023 -> append 后 newcap=1536
oldcap=1024 -> append 后 newcap=1536
oldcap=1025 -> append 后 newcap=1536
这组数据比那句老八股更有信息量。

那句老八股是:Go slice 小于 1024 翻倍,大于 1024 增长 1.25 倍。
这句话很多人背过。面试里背,博客里写,code review 时也有人拿它解释 make([]T, 0, 1024)。麻烦在于,它不只是过时;它还盖住了旧策略里的一个真实缺陷:旧扩容策略并不是平滑、单调的增长函数。
单调是什么意思?很简单:如果旧容量更大,append 后得到的新容量不应该更小。oldcap=1024 至少不该比 oldcap=1023 更吃亏。可 Go 1.17 做不到。
所以我对 Go 1.18 这次改动的判断很明确:它不是单纯把参数调得更好看,而是在修旧策略的 bug。
这里的 bug 不是说实现会崩。它是 runtime 策略层面的非预期行为:公式本身、阈值切换、内存分配器的 size class 对齐,三者叠在一起,制造了一段反直觉曲线。
别急着记新公式。
先把旧公式为什么会坏、Go 1.18 怎么把它接回来,看明白。
一、旧策略的三个问题:非单调、硬跳变、断曲线
先把实验方法说清楚,避免把推演当实测。
我写了一个小程序。核心逻辑只有两步:先构造 make([]byte, oldcap, oldcap),再 append 一个 byte,记录新 cap。[]byte 的好处是元素大小为 1,容量和申请字节数之间的关系最直观,适合观察 size class 对结果的影响。
测试环境:
Go 1.17.13 darwin/arm64
Go 1.26.2 darwin/arm64
扫描范围:oldcap=900..1400
操作:每个 oldcap append 1 个 byte
这不是算法复现,是直接跑 runtime 行为。
先看这段数据——1018 到 1023 都是旧策略翻倍区间的尾部:
| oldcap | Go 1.17 append 后 cap | Go 1.26 append 后 cap |
|---|---|---|
| 1018 | 2048 | 1536 |
| 1019 | 2048 | 1536 |
| 1020 | 2048 | 1536 |
| 1021 | 2048 | 1536 |
| 1022 | 2048 | 1536 |
| 1023 | 2048 | 1536 |
| 1024 | 1280 | 1536 |
| 1025 | 1408 | 1536 |
| 1026 | 1408 | 1536 |
| 1030 | 1408 | 1536 |
先看非单调增长。
这四个字听起来像数学课,其实很工程。你可以把 cap 理解成 runtime 给你的缓冲空间。按常识,旧缓冲空间从 1023 增加到 1024 后,再 append 一次,runtime 不该给你更少的新空间。Go 1.17 偏偏给出了这个结果:1023 扩到 2048,1024 扩到 1280。
这不是“多分一点、少分一点”的取舍。
这是曲线掉头。

再看阈值跳变。
旧公式的分界点在 1024。小于 1024,大致按 2 倍扩;到了 1024,直接进入 1.25 倍扩。中间没有缓冲。于是 1023 还在享受翻倍,1024 立刻被切到 1.25 倍。
单看公式,你可能觉得这只是规则不同。小 slice 增长快,大 slice 增长慢,很合理。
但工程里的阈值最怕“硬切”。硬切意味着同一类输入在边界两侧得到完全不同的待遇。1023 和 1024 在业务上没有本质差异,旧策略却把它们分进了两个世界。
最后看整条曲线。它不是逐渐变缓,而是断了一下。
我抽几个点给你看:

| oldcap | Go 1.17 append 后 cap | Go 1.26 append 后 cap | 新旧差异 |
|---|---|---|---|
| 900 | 2048 | 1408 | -640 |
| 960 | 2048 | 1408 | -640 |
| 1000 | 2048 | 1536 | -512 |
| 1023 | 2048 | 1536 | -512 |
| 1024 | 1280 | 1536 | +256 |
| 1025 | 1408 | 1536 | +128 |
| 1100 | 1408 | 1792 | +384 |
| 1200 | 1536 | 1792 | +256 |
| 1300 | 1792 | 2048 | +256 |
| 1400 | 1792 | 2048 | +256 |
Go 1.17 在 900 到 1023 这一段,会直接给到 2048。跨过 1024 后,又掉到 1280、1408、1536,再慢慢爬回来。它不是一条向上但逐渐变缓的曲线,而像一段被折断后重新接上的线。
Go 1.26 的结果不同。900 到 1400 之间,它不是简单翻倍,也不是硬切到 1.25 倍,而是从 1408、1536、1792、2048 这样一路平滑上去。为了避免把“当前版本行为”误写成“1.18 首版行为”,我又补跑了 Go 1.18:在这个扫描窗口里,Go 1.18 与 Go 1.26.2 的 cap 结果一致。
这里要克制一点。不能看到 Go 1.26 的数据更顺,就直接说它所有场景都更好。slice 扩容策略不是单指标优化。它同时牵涉复制成本、内存浪费、分配次数、size class 对齐、GC 压力。
但至少在这个窗口里,旧策略的形状确实难看。
它给了我们一个更好的问题:为什么公式看起来没错,跑出来却会这样?
二、为什么公式会出 bug:真正的决策不止一次
很多 slice 扩容文章容易卡在公式上。
旧公式大概是:小于 1024 翻倍,大于等于 1024 增长 1.25 倍。新公式大概是:阈值改成 256,并用一个平滑公式逐步从 2 倍过渡到 1.25 倍。
这句话只能算半截。
slice 最终 cap 不是扩容公式单独决定的。runtime 至少做了两次决策。
第一次,是 growslice 根据旧 cap、新 len、元素大小,算出一个目标容量。这个目标容量可以理解成“策略层想要多少”。
第二次,是内存分配器根据 size class 做对齐。这个对齐决定“实际能给你多少”。
size class 可以理解成内存分配器预先准备的一组规格。你申请 2046 字节,它可能不会刚好给 2046,而是向上取整到某个规格,比如 2048。这样做不是为了 slice,而是为了整个内存分配系统的效率。
roundupsize 做的事,就是把申请大小向上取整到合适的 size class。它是最终 cap 看起来“不等于公式结果”的常见原因。
旧策略的问题,就出在这两次决策的交界处。
用 1023 和 1024 这两个点推一遍。
第一步,oldcap=1023。旧策略还在“1024 以下翻倍”的区间,所以策略层目标容量是 2046。因为是 []byte,元素大小为 1,目标容量基本对应 2046 字节。内存分配器做 size class 对齐,把它向上取整到 2048。最终 newcap=2048。
第二步,oldcap=1024。旧策略刚好跨过阈值,进入 1.25 倍区间。策略层目标容量变成 1280。这个数字已经是一个合适的分配规格,roundup 后仍然是 1280。最终 newcap=1280。
第三步,把两边放在一起:
oldcap=1023 -> 策略目标 2046 -> roundup -> newcap=2048
oldcap=1024 -> 策略目标 1280 -> roundup -> newcap=1280
根因就在这里:单看任一环节都不算错,问题卡在两者叠加的地方。阈值硬切换 + size class 对齐,让 1023 被翻倍策略和向上取整推到 2048,又让 1024 被 1.25 倍策略压到 1280。于是边界两侧出现倒挂。

这就是我为什么倾向把它叫做 bug。
如果一个策略的目标是“容量随需求增长而增长”,那它至少应该保持基本单调。你可以增长得慢,可以多浪费一点,也可以少浪费一点。但你不能让更大的旧容量在 append 后得到更小的新容量。
Go 官方 commit(2dda92ff)的措辞是 “smoother growth” 和 “monotonically non-decreasing”,没有自称在修 bug。我仍然倾向这么叫,因为它暴露的是策略曲线的方向性错误。
有人可能会反驳:这只是 runtime 实现细节,不影响程序语义。
这句话一半对,一半不够。
对的是,Go 没承诺 slice 扩容公式。你不应该写依赖具体 cap 序列的业务代码。任何把 cap 精确值当 API 契约的写法都站不住。
不够的是,runtime 策略不是随便写的。它虽然不是语言语义的一部分,却会影响性能形态。分配次数、复制量、内存保留、GC 扫描压力,都会被这条曲线影响。一个非单调的增长函数即使不破坏正确性,也会破坏可解释性;而 runtime 这种底层系统,恰恰需要同类输入得到同类结果。
你不要求每个开发者背源码,但你会期待边界附近别突然变脸。
Go 1.17 的旧策略在 1024 附近没做到。
三、1.18 怎么修:它不是换阈值,而是补曲线
Go 1.18 的 slice 扩容变化,最容易被写成两个点:阈值从 oldcap=1024 改成 oldcap=256(元素个数,不是字节数);大容量增长使用新公式。
只写到这里,基本就是变更日志。
关键在后半句:它把“硬切”改成了“平滑过渡”。
旧策略的问题不是 1024 这个数字本身。把 1024 换成 2048,仍然可能在另一个边界出问题。把 1.25 改成 1.3,也只是换一种折法。真正该修的是从 2 倍到 1.25 倍之间的断层。
新策略的思路是:小容量仍然可以增长快,因为小 slice 多分一点空间成本不高,减少频繁扩容更重要;容量变大后,增长率逐步下降,因为大 slice 多分一点就是实实在在的内存浪费。

这才是“平滑”的实际含义:增长率可以下降,但别断崖式换挡。
Go 1.18 的源码里,这段逻辑直接写在 runtime/slice.go 的 growslice 中:threshold=256,平滑公式是 newcap += (newcap + 3*threshold) / 4。官方提交说明也很直白:不再用“小于 1024 翻倍、大于等于 1024 增长 1.25 倍”的硬切方式,而是让增长因子更平滑,并且让增长保持单调。
公式本身不用背。看它的效果更直接。
在 Go 1.26.2 的实测里,1023、1024、1025 这三个点 append 后都得到 1536:
| oldcap | Go 1.17 newcap | Go 1.26 newcap |
|---|---|---|
| 1023 | 2048 | 1536 |
| 1024 | 1280 | 1536 |
| 1025 | 1408 | 1536 |
这张表的重点不是 1536 这个数字本身,而是它把相邻三个点稳住了。
旧策略在 1023 过度扩张,在 1024 又突然收缩。新策略没有维持“1024 以下一定翻倍”的老规则,而是在这段区间给出一个中间值。
1536 的意义不在数字本身——它让曲线从 1023 到 1025 不再断。它不再让 1023 获得 2048、1024 只获得 1280。这就是修复。
再看 Go 1.26 在 1000 到 1400 的抽样:
| oldcap | Go 1.26 append 后 cap | 单调性 |
|---|---|---|
| 1000 | 1536 | ✓ 单调 |
| 1023 | 1536 | ✓ 单调 |
| 1024 | 1536 | ✓ 单调 |
| 1025 | 1536 | ✓ 单调 |
| 1100 | 1792 | ✓ 单调 |
| 1400 | 2048 | ✓ 单调 |
新策略更像一条坡道。它仍然受 size class 影响,所以结果不是每个 oldcap 都连续增加,而是按 1536、1792、2048 这些规格跳。但跳法没有倒挂。
平滑是边界附近不出现方向性错误——可以平台阶,但不要下楼梯。
这个区别很细,但很重要。如果只看单个 oldcap,你可能会说 Go 1.17 给 2048,Go 1.26 给 1536,旧版本似乎更慷慨。把相邻点连起来,结论才会反过来:旧版本看着慷慨,实际是不稳定——在 1023 过度扩张,到 1024 又突然收缩。新策略给得少一些,但把容量增长放回了一条可解释的轨道上。

所以,Go 1.18 改掉的不是一个常数。
它改掉的是旧策略的形状。
这也是为什么“阈值从 1024 改到 256”不是重点。阈值变小,只是让平滑过渡更早开始。核心设计选择是:不要等 slice 已经很大了才从 2 倍突然切到 1.25 倍;应该更早地、连续地降低增长率。
从工程角度看,这比“增长更省内存”更准确。
省内存只是结果之一。更深的变化是:runtime 不再把 slice 分成两个粗暴区间,而是承认容量增长是一个连续问题。
四、自己验证一下:少背一句,多跑一组 benchmark
到这里,非单调和根因已经能说明问题。但只看 cap 序列还不够。runtime 策略最终要落到分配行为上。
所以我又跑了一组 benchmark。
测试代码不复杂。三个场景:
NoPrealloc_4K:从 nil slice append 到 4096。Prealloc256_4K:从cap=256append 到 4096。From1024To4096:从len=cap=1024append 到 4096。
每个场景用 Go 1.17.13 和 Go 1.26.2 执行 go test -bench=. -benchmem -count=5,取中位数,记录 ns/op、B/op、allocs/op。这里的解读顺序很重要:先看 allocs/op,再看 B/op,最后才看 ns/op。
如果 allocs/op 没变,只拿 ns/op 说 runtime 策略更好,很容易把 CPU、编译器、机器状态的差异混进去。本文这组数据里,allocs/op 变了,所以可以更稳地说:至少在这些场景下,新策略减少了一次分配。
跑完后,结果长这样。

先看分配行为:
| benchmark | Go 1.17 allocs/op | Go 1.26 allocs/op | Go 1.17 B/op | Go 1.26 B/op |
|---|---|---|---|---|
| From1024To4096 | 5 | 4 | 12544 | 11008 |
| NoPrealloc_4K | 13 | 12 | 14584 | 12536 |
| Prealloc256_4K | 7 | 6 | 14080 | 12032 |
ns/op 仅作辅助参考,不直接归因于扩容策略:
| benchmark | Go 1.17 ns/op | Go 1.26 ns/op |
|---|---|---|
| From1024To4096 | 1857 | 1514 |
| NoPrealloc_4K | 2454 | 2160 |
| Prealloc256_4K | 2397 | 1972 |
这组数据里,最该先看的不是时间,而是分配次数。
三个场景中,Go 1.26.2 都比 Go 1.17.13 少 1 次 allocs/op。
这比 ns/op 更有说服力。分配次数少一次,说明扩容路径确实变了,不只是跑分噪声。尤其是 From1024To4096,它正好踩在旧策略最尴尬的阈值区域:Go 1.17 是 5 次分配,Go 1.26 是 4 次。
B/op 也跟着下降。
From1024To4096:12544 → 11008,下降约 12.2%。NoPrealloc_4K:14584 → 12536,下降约 14.0%。Prealloc256_4K:14080 → 12032,下降约 14.5%。
我没有采用外部文章里常见的“提升 17%-40%”这类说法。不是它一定错,而是本文的结论不需要它。自己测出来的 12%-14.5% 已经足够支撑判断:新策略在这些 append 到 4K 的 []byte 场景下,确实减少了分配和分配字节数。
至于 ns/op,我只把它当辅助信号。
Go 1.26.2 的 ns/op 也更低。例如 From1024To4096 从 1857 ns/op 到 1514 ns/op。但这不能简单归因于 slice 扩容策略。Go 1.17 到 Go 1.26 之间,编译器、runtime、标准库都有大量变化。和扩容策略更直接相关的,还是 allocs/op 和 B/op。
这也是技术文章最容易失真的地方。
你测到了一个更快的数字,然后急着把它塞进标题。读者看到的是“性能提升 X%”,实际支撑却可能只是一次本地跑分。更稳的写法,是把数据链拆开:方法是什么,版本是什么,指标是什么,哪些指标能支撑结论,哪些只能作为旁证。
所以这篇文章不需要更大的口号。它只需要守住这三条:
- Go 1.17 旧策略在 1024 附近存在非单调增长。
- Go 1.18+ 的平滑策略消除了本文实测窗口里的断崖。
- 在三个 append 到 4K 的
[]bytebenchmark 里,当前 Go 比 Go 1.17 少 1 次分配,B/op 降低约 12.2%-14.5%。
这就够了。
不需要把它包装成“Go 1.18 让 slice 性能暴涨”。那样反而把问题讲浅了。
跑分只是旁证。
更重要的是,扩容策略终于不像一条背诵口诀,而像一个能解释得通的工程系统。
五、别再拿旧公式做判断
在 code review 里看到这行代码:
buf := make([]byte, 0, 1024)
别急着拿旧公式解释。先问:初始容量从哪来?上界能不能估?有没有真实数据?上界已知就直接 make 到位;不可预估,就让 runtime 去管。
本文实测基于 []byte。其他类型的对齐边界不同,但根因一样:阈值硬切 + size class。Go 1.18 后的阈值是 oldcap=256,指元素个数,不是字节数。
拿机制校准判断,比拿公式压人有效。

回到开头:Go 1.17 里,oldcap=1023 扩到 2048,oldcap=1024 反而掉到 1280。它不是数字算错,而是硬阈值和 size class 叠出的断曲线。Go 1.18 修掉的,就是这段毛刺。
所以,别再背 slice 扩容公式了。公式会变;更值得看的,是成熟系统怎样把边界毛刺一点点磨掉。
这不是调参。
这是修正错误。
附录:实验代码和原始数据
本文 5 组自造证据和 1 组官方源码快照已开源:
GitHub:zhiyulab-evidence/slice-growth-go118
包含内容:
code/slice-growth-compare/:容量扫描、增长轨迹和 benchmark 的 Go 代码。output/slice-growth-compare/:Go 1.17.13、Go 1.18、Go 1.26.2 的原始 CSV 和 benchmark 输出。data/slice-growth-summary.md:核心实验结论汇总。snapshots/:Go 1.17.13、Go 1.18、Go 1.26.2 的runtime/slice.go快照,以及 commit 2dda92ff 说明。scenarios/interview-code-review.md:面试和 code review 场景模拟。
每个子目录都保留了复现说明或原始输出。二进制编译产物不入库,跑实验前自己 go build。
原文发布于 止语Lab