go切片扩容非简单翻倍,而是分段计算(如cap≥1024时增25%)并向上对齐内存块尺寸;预分配make([]t,0,n)可避免扩容及零值写入,而make([]t,n)会初始化且仍可能扩容。

扩容时的容量计算逻辑不是简单翻倍
很多人以为 append 触发扩容就是“当前 cap × 2”,实际要复杂得多。Go 运行时在 runtime/slice.go 的 growslice 函数里做了分段处理:
- 当
cap ,新 <code>cap=old cap * 2 - 当
cap >= 1024,新cap=old cap + old cap / 4(即约 1.25 倍) - 无论哪种情况,最终新
cap都会向上对齐到 runtime 内存块尺寸(如 8/16/32/48 字节等),所以实际分配可能略大于理论值
这意味着:从 cap=1000 扩到 1001 元素时,不会变成 2000,而是 1250 左右;但从 cap=1023 到 1024,下一次扩容就跳到约 1280,而非 2046——这个拐点容易被忽略。
预分配 make([]T, 0, n) 能彻底绕过扩容路径
只要你知道最终长度上限,用 make([]T, 0, n) 就能避免所有 growslice 调用。它和 make([]T, n) 的关键区别在于:
-
make([]T, 0, n):只调用mallocgc分配底层数组,不写任何零值,len=0,cap=n -
make([]T, n):同样分配数组,但额外执行n次零值写入(如int写0),且后续append仍可能触发扩容(如果n不够大) - pprof 中若看到高频
runtime.makeslice或runtime.growslice,基本就是没预分配或预估过小
底层数组复用的前提是“无外部引用”
即使你预分配了足够 cap,append 仍可能新建底层数组——原因不是容量不够,而是原底层数组被其他变量持有。例如:
data := make([]byte, 0, 1000)
for i := 0; i <p>验证是否复用很简单:<code>fmt.Printf("%p", &data[0])</code>,扩容前后地址变了就说明被复制了。别让一个底层数组同时被多个活跃 <code>slice</code> 持有。</p><h3>已知源数据时,<code>copy</code> 比循环 <code>append</code> 快得多</h3><p>如果你有一整块已知长度的数据(比如另一个 <code>slice</code>、<code>[N]byte</code> 或解析好的 JSON 数组),直接 <code>copy</code> 是最优解:</p>
- 错误写法:
for _, v := range src { dst = append(dst, v) }—— 每次都要检查len/cap、可能触发多次growslice - 正确写法:
dst := make([]T, len(src)); copy(dst, src)—— 一次分配 + 单次memmove汇编优化 - 尤其当
src很长时,runtime.slicecopy在 pprof 里只会出现一次,而循环append会反复调用growslice
真正难的不是记住规则,而是判断“哪些场景你能提前知道长度”。数据库 LIMIT、HTTP 头数量、固定协议包体长度——这些才是预分配落地的关键锚点。











