别再背 slice 扩容公式了:1.18 真正改掉了什么 2048 → 1280。你没看错。在 Go 1.17 里我把一个cap1023的[]byteappend 一次新 cap 是 2048把同样的代码换成cap1024append 一次新 cap 反而变成 1280。old cap 只增加了 1new cap 直接少了 768。这是一组很确定的实验结果我用 Go 1.17.13 扫描oldcap900..1400每个初始 slice 都只 append 1 个 byte然后记录扩容后的 cap。结果在 1024 这个点断了一下。[实测 Go1.17.13 darwin/arm64]oldcap1023-append 后newcap2048oldcap1024-append 后newcap1280[实测 Go1.26.2 darwin/arm64]oldcap1023-append 后newcap1536oldcap1024-append 后newcap1536oldcap1025-append 后newcap1536这组数据比那句老八股更有信息量。那句老八股是Go slice 小于 1024 翻倍大于 1024 增长 1.25 倍。这句话很多人背过。面试里背博客里写code review 时也有人拿它解释make([]T, 0, 1024)。麻烦在于它不只是过时它还盖住了旧策略里的一个真实缺陷旧扩容策略并不是平滑、单调的增长函数。单调是什么意思很简单如果旧容量更大append 后得到的新容量不应该更小。oldcap1024至少不该比oldcap1023更吃亏。可 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 对结果的影响。测试环境Go1.17.13 darwin/arm64 Go1.26.2 darwin/arm64 扫描范围oldcap900..1400操作每个 oldcap append1个 byte这不是算法复现是直接跑 runtime 行为。先看这段数据——1018 到 1023 都是旧策略翻倍区间的尾部oldcapGo 1.17 append 后 capGo 1.26 append 后 cap101820481536101920481536102020481536102120481536102220481536102320481536102412801536102514081536102614081536103014081536先看非单调增长。这四个字听起来像数学课其实很工程。你可以把cap理解成 runtime 给你的缓冲空间。按常识旧缓冲空间从 1023 增加到 1024 后再 append 一次runtime 不该给你更少的新空间。Go 1.17 偏偏给出了这个结果1023 扩到 20481024 扩到 1280。这不是“多分一点、少分一点”的取舍。这是曲线掉头。再看阈值跳变。旧公式的分界点在 1024。小于 1024大致按 2 倍扩到了 1024直接进入 1.25 倍扩。中间没有缓冲。于是 1023 还在享受翻倍1024 立刻被切到 1.25 倍。单看公式你可能觉得这只是规则不同。小 slice 增长快大 slice 增长慢很合理。但工程里的阈值最怕“硬切”。硬切意味着同一类输入在边界两侧得到完全不同的待遇。1023 和 1024 在业务上没有本质差异旧策略却把它们分进了两个世界。最后看整条曲线。它不是逐渐变缓而是断了一下。我抽几个点给你看oldcapGo 1.17 append 后 capGo 1.26 append 后 cap新旧差异90020481408-64096020481408-640100020481536-512102320481536-512102412801536256102514081536128110014081792384120015361792256130017922048256140017922048256Go 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 这两个点推一遍。第一步oldcap1023。旧策略还在“1024 以下翻倍”的区间所以策略层目标容量是 2046。因为是[]byte元素大小为 1目标容量基本对应 2046 字节。内存分配器做 size class 对齐把它向上取整到 2048。最终 newcap2048。第二步oldcap1024。旧策略刚好跨过阈值进入 1.25 倍区间。策略层目标容量变成 1280。这个数字已经是一个合适的分配规格roundup 后仍然是 1280。最终 newcap1280。第三步把两边放在一起oldcap1023-策略目标2046-roundup -newcap2048oldcap1024-策略目标1280-roundup -newcap1280根因就在这里单看任一环节都不算错问题卡在两者叠加的地方。阈值硬切换 size class 对齐让 1023 被翻倍策略和向上取整推到 2048又让 1024 被 1.25 倍策略压到 1280。于是边界两侧出现倒挂。这就是我为什么倾向把它叫做 bug。如果一个策略的目标是“容量随需求增长而增长”那它至少应该保持基本单调。你可以增长得慢可以多浪费一点也可以少浪费一点。但你不能让更大的旧容量在 append 后得到更小的新容量。Go 官方 commit2dda92ff的措辞是 “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 扩容变化最容易被写成两个点阈值从 oldcap1024 改成 oldcap256元素个数不是字节数大容量增长使用新公式。只写到这里基本就是变更日志。关键在后半句它把“硬切”改成了“平滑过渡”。旧策略的问题不是 1024 这个数字本身。把 1024 换成 2048仍然可能在另一个边界出问题。把 1.25 改成 1.3也只是换一种折法。真正该修的是从 2 倍到 1.25 倍之间的断层。新策略的思路是小容量仍然可以增长快因为小 slice 多分一点空间成本不高减少频繁扩容更重要容量变大后增长率逐步下降因为大 slice 多分一点就是实实在在的内存浪费。这才是“平滑”的实际含义增长率可以下降但别断崖式换挡。Go 1.18 的源码里这段逻辑直接写在runtime/slice.go的growslice中threshold256平滑公式是newcap (newcap 3*threshold) / 4。官方提交说明也很直白不再用“小于 1024 翻倍、大于等于 1024 增长 1.25 倍”的硬切方式而是让增长因子更平滑并且让增长保持单调。公式本身不用背。看它的效果更直接。在 Go 1.26.2 的实测里1023、1024、1025 这三个点 append 后都得到 1536oldcapGo 1.17 newcapGo 1.26 newcap102320481536102412801536102514081536这张表的重点不是 1536 这个数字本身而是它把相邻三个点稳住了。旧策略在 1023 过度扩张在 1024 又突然收缩。新策略没有维持“1024 以下一定翻倍”的老规则而是在这段区间给出一个中间值。1536 的意义不在数字本身——它让曲线从 1023 到 1025 不再断。它不再让 1023 获得 2048、1024 只获得 1280。这就是修复。再看 Go 1.26 在 1000 到 1400 的抽样oldcapGo 1.26 append 后 cap单调性10001536✓ 单调10231536✓ 单调10241536✓ 单调10251536✓ 单调11001792✓ 单调14002048✓ 单调新策略更像一条坡道。它仍然受 size class 影响所以结果不是每个 oldcap 都连续增加而是按 1536、1792、2048 这些规格跳。但跳法没有倒挂。平滑是边界附近不出现方向性错误——可以平台阶但不要下楼梯。这个区别很细但很重要。如果只看单个 oldcap你可能会说 Go 1.17 给 2048Go 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从cap256append 到 4096。From1024To4096从lencap1024append 到 4096。每个场景用 Go 1.17.13 和 Go 1.26.2 执行go test -bench. -benchmem -count5取中位数记录ns/op、B/op、allocs/op。这里的解读顺序很重要先看 allocs/op再看 B/op最后才看 ns/op。如果 allocs/op 没变只拿 ns/op 说 runtime 策略更好很容易把 CPU、编译器、机器状态的差异混进去。本文这组数据里allocs/op 变了所以可以更稳地说至少在这些场景下新策略减少了一次分配。跑完后结果长这样。先看分配行为benchmarkGo 1.17 allocs/opGo 1.26 allocs/opGo 1.17 B/opGo 1.26 B/opFrom1024To4096541254411008NoPrealloc_4K13121458412536Prealloc256_4K761408012032ns/op 仅作辅助参考不直接归因于扩容策略benchmarkGo 1.17 ns/opGo 1.26 ns/opFrom1024To409618571514NoPrealloc_4K24542160Prealloc256_4K23971972这组数据里最该先看的不是时间而是分配次数。三个场景中Go 1.26.2 都比 Go 1.17.13 少 1 次allocs/op。这比 ns/op 更有说服力。分配次数少一次说明扩容路径确实变了不只是跑分噪声。尤其是From1024To4096它正好踩在旧策略最尴尬的阈值区域Go 1.17 是 5 次分配Go 1.26 是 4 次。B/op也跟着下降。From1024To409612544 → 11008下降约 12.2%。NoPrealloc_4K14584 → 12536下降约 14.0%。Prealloc256_4K14080 → 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 后的阈值是 oldcap256指元素个数不是字节数。拿机制校准判断比拿公式压人有效。回到开头Go 1.17 里oldcap1023 扩到 2048oldcap1024 反而掉到 1280。它不是数字算错而是硬阈值和 size class 叠出的断曲线。Go 1.18 修掉的就是这段毛刺。所以别再背 slice 扩容公式了。公式会变更值得看的是成熟系统怎样把边界毛刺一点点磨掉。这不是调参。这是修正错误。附录实验代码和原始数据本文 5 组自造证据和 1 组官方源码快照已开源GitHubzhiyulab-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