bigywater.tech
Go · 98

strings.Builder 与 bytes.Buffer:拼接字符串的成本

循环里 s += x 会很慢是常识,但慢多少、Builder 和 Buffer、Join 又有多大差别?这篇把几种拼接方式在 100 到 5 万段上各量一遍,并看 String() 是否复制、容量怎么增长、为什么不能复制 Builder。

1447 ms vs 0.17 ms
拼接 5 万段 16 字节:s += 与 Builder + Grow
20.2 GB
5 万段 s += 一次拼接累计分配的内存
0 vs 1
Builder.String() 与 bytes.Buffer.String() 每次的分配次数

s += 是平方级

实测 · 本机执行输出
  拼接    100 段(每段 16 字节,结果    1600 字节):s += piece              10717 ns/次      84848 B/次     99 次分配
  拼接    100 段(每段 16 字节,结果    1600 字节):strings.Builder           777 ns/次       5360 B/次      9 次分配
  拼接    100 段(每段 16 字节,结果    1600 字节):Builder + Grow            348 ns/次       1792 B/次      1 次分配
  拼接    100 段(每段 16 字节,结果    1600 字节):bytes.Buffer              985 ns/次       5824 B/次      7 次分配
  拼接    100 段(每段 16 字节,结果    1600 字节):strings.Join             1074 ns/次       3584 B/次      2 次分配
  拼接    100 段(每段 16 字节,结果    1600 字节):Sprintf("%s%s")         15218 ns/次      86518 B/次    199 次分配
  拼接   1000 段(每段 16 字节,结果   16000 字节):s += piece            1088907 ns/次    8470466 B/次    999 次分配
  拼接   1000 段(每段 16 字节,结果   16000 字节):strings.Builder          9330 ns/次      62960 B/次     16 次分配
  拼接   1000 段(每段 16 字节,结果   16000 字节):Builder + Grow           3050 ns/次      16384 B/次      1 次分配
  拼接   1000 段(每段 16 字节,结果   16000 字节):bytes.Buffer             7138 ns/次      49088 B/次     10 次分配
  拼接   1000 段(每段 16 字节,结果   16000 字节):strings.Join            12237 ns/次      32768 B/次      2 次分配
  拼接   1000 段(每段 16 字节,结果   16000 字节):Sprintf("%s%s")       1023500 ns/次    8509957 B/次   2007 次分配
  拼接  10000 段(每段 16 字节,结果  160000 字节):s += piece           60250200 ns/次  834891065 B/次  10048 次分配
  拼接  10000 段(每段 16 字节,结果  160000 字节):strings.Builder         72262 ns/次     686834 B/次     24 次分配
  拼接  10000 段(每段 16 字节,结果  160000 字节):Builder + Grow          28452 ns/次     163841 B/次      1 次分配
  拼接  10000 段(每段 16 字节,结果  160000 字节):bytes.Buffer            73393 ns/次     688066 B/次     14 次分配
  拼接  10000 段(每段 16 字节,结果  160000 字节):strings.Join           104039 ns/次     327680 B/次      2 次分配
  拼接  10000 段(每段 16 字节,结果  160000 字节):Sprintf("%s%s")     122835412 ns/次 1528501646 B/次  27124 次分配
  拼接  50000 段(每段 16 字节,结果  800000 字节):s += piece         1446626958 ns/次 20198789680 B/次  51011 次分配
  拼接  50000 段(每段 16 字节,结果  800000 字节):strings.Builder        493997 ns/次    4127530 B/次     31 次分配
  拼接  50000 段(每段 16 字节,结果  800000 字节):Builder + Grow         170837 ns/次     802823 B/次      1 次分配
  拼接  50000 段(每段 16 字节,结果  800000 字节):bytes.Buffer           359572 ns/次    2899929 B/次     16 次分配
  拼接  50000 段(每段 16 字节,结果  800000 字节):strings.Join           540718 ns/次    1605645 B/次      2 次分配

段数每增加 10 倍,s += 的耗时增加约 50–70 倍(10717 ns → 1.09 ms → 60 ms → 1.45 s),分配的内存更夸张:5 万段累计分配 20.2 GB,因为每次都把整个字符串复制一遍。Sprintf("%s%s") 更慢。

Builder 接近线性

实测 · 本机执行输出
  strings.Builder.String():每次 0 次分配;bytes.Buffer.String():每次 1 次分配(复制了一份)

strings.Builder 在 100、1000、1 万、5 万段下分别是 777 ns、9.3 µs、72 µs、494 µs,每段数量级×10,耗时约×8;如果知道大小就 Grow,只有 1 次分配,再快 2–3 倍。bytes.Buffer 与 Builder 同一量级,strings.Join 在这个测试里略慢(它还要先构造 parts 切片)。

String() 与容量

实测 · 本机执行输出
  Builder 连续写 2000 个 16 字节段(共 32000 字节),容量变化依次为:16 → 32 → 64 → 128 → 256 → 512 → 896 → 1408 → 2048 → 3072 → 4096 → 5376 → 6912 → 9472 → 12288 → 16384 → 21760 → 28672 → 40960

Builder.String() 不复制(0 次分配),bytes.Buffer.String() 每次复制一份。Builder 的容量起步按 2 倍增长,超过约 512 字节后增长因子变小,32000 字节最终是 40960 的容量。

不能复制

实测 · 本机执行输出
  复制一个已写入的 Builder 后再写:panic: strings: illegal use of non-zero Builder copied by value

Builder 记着自己的地址,复制一个已经写过的再继续写会 panic。把它放在结构体里时,传指针。

文档

实测 · 本机执行输出
  // Has unexported fields. } A Builder is used to efficiently build a string using Builder.Write methods. It minimizes memory copying. The zero value is ready to use. Do not copy a non-zero Builder.

文档只用几句话概括:用 Write 方法高效构造字符串,最小化复制,零值可用,不要复制非零值的 Builder。

分步演示

字符串拼接
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Go 1.27.1(brew);用 go run / go build / go test 直接编译运行,需要第三方库时经代理从 proxy.golang.org 下载;计时用 time.Now / testing.B,数字每次运行会有小幅抖动。基准用 testing.Benchmark(ReportAllocs);每种拼接 100、1000、1 万、5 万段(s += 与 Sprintf 在最大规模处省略,Sprintf 最大测到 1 万段);容量变化通过 Builder.Cap() 记录;读过的文档:go doc strings.Builder
    • 没有验证:Linux 上的结果、更大的段(几 KB)下的差别、strings.Builder 与 bytes.Buffer 的内部扩容公式(没有读源码)、字符串常量折叠对小规模 += 的影响、fmt.Fprintf 写入 Builder 的成本
    京ICP备2026067502号-1京公网安备11010502063487号