strings.builder 比 bytes.buffer 快主因是 string() 零拷贝且免 utf-8 检查,内存更紧凑、无接口调用开销;bytes.buffer 必用于需 io.writer/reader 或二进制操作的场景。

strings.Builder 为什么比 bytes.Buffer 快
核心差异不在“能不能拼”,而在“怎么转成 string”。strings.Builder.String() 是零拷贝:它用 unsafe.String() 直接把底层数组头映射为字符串,不复制字节;而 bytes.Buffer.String() 每次都遍历整个底层数组检查 UTF-8 合法性——哪怕你只拼 ASCII,这步也绕不开。
实测 10 万次单字节写入:strings.Builder 比 bytes.Buffer 快约 25%,其中约 1/3 差距就来自这个检查。另外,strings.Builder 不维护读写偏移量(off 字段),内存更紧凑;bytes.Buffer 多一个字段、多一次接口调用开销(WriteString 走 io.Writer 接口)。
常见错误现象:pprof 显示大量 runtime.checkptr 或 utf8.validate 调用,说明你在高频调用 bytes.Buffer.String()。
什么时候必须用 bytes.Buffer
当你需要对接 Go 标准库的 I/O 接口时,strings.Builder 就无能为力了——它不实现 io.Reader 或 io.Writer。
- 后续要传给
json.NewEncoder(w).Encode(v)、http.ServeContent、template.Execute等接受io.Writer的函数 → 必须用bytes.Buffer - 边写边读,比如拼一段 JSON 后立刻用
json.Unmarshal解析 →bytes.Buffer支持Bytes()和Read() - 要写二进制数据(如
buf.Write([]byte{0xff, 0xfe}))→strings.Builder根本没Write方法,编译报错
别为了省一次 .String() 就硬把 bytes.Buffer 当 strings.Builder 用:接口调用 + UTF-8 检查 + off 字段三重开销白扔。
Grow() 不是可选项,是必填项
strings.Builder.Grow(n) 不是“设容量为 n”,而是“确保还能写入至少 n 字节”。不调它,性能优势基本归零。
默认初始容量是 0,第一次 WriteString 触发分配,cap 变成 8;接着写 100 字节,触发扩容 → cap 变成 24;再写 200 字节,又扩……频繁底层数组复制抵消所有优化。
实操建议:
- 能预估总长(比如拼 5 个字段,已知各字段长度)→
b.Grow(len(a)+len(b)+len(c)+16),+16 是留点分隔符余量 - 完全无法预估但知道量级(比如日志行大概 2KB)→
b.Grow(2048),宁高估不低估 - 循环里拼 N 次固定内容(如
"id="+strconv.Itoa(i)+"&")→ 在循环外一次性Grow(N * avgLen),别在循环里反复调 - 传 0 或负数无害但无效,别写
b.Grow(0)白费力气
复用 Builder 实例反而拖慢性能
strings.Builder.Reset() 只把 len 归零,不释放底层数组。如果上次拼了 1MB,这次只拼 10 字节,复用实例会保留 1MB 底层数组——浪费内存、增加 GC 压力,且下次写入若超当前 cap 还得扩容。
典型反模式:
- 在 HTTP handler 里声明全局
var b strings.Builder,每次请求b.Reset()后重用 → 并发下可能 panic(strings.Builder不是并发安全的),且内存浪费严重 - 循环中
for i := range items { b.Reset(); b.WriteString(...); ... }→ 比每次新建strings.Builder{}更慢
真正该复用的,是那些生命周期明确、容量稳定、且确实拼接量大的场景(比如模板引擎内部缓冲区),否则直接声明新实例更安全、更高效。
最易被忽略的一点:预分配容量比选类型更重要。哪怕你选对了 strings.Builder,忘了 Grow(),它和 += 的性能差距也会被抹平。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











