Go 的 strings.Builder 实战:拼接性能、Grow 预分配与何时该用 bytes.Buffer

Go 的 strings.Builder 实战:拼接性能、Grow 预分配与何时该用 bytes.Buffer

Go 里拼字符串,新手第一反应是 +=,循环几万次后 CPU 和内存双双爆表。老手会甩一句「用 strings.Builder」,但很少讲清楚它为什么快、Grow 有什么用、什么时候反而该用 bytes.Buffer。这篇用能跑的代码说明白。

为什么 += 在循环里是灾难

Go 的 string 是不可变的。s += x 每次都要新分配一块内存,把旧内容和新内容拷过去,旧的等着被 GC。循环 N 次就是 O(N²) 的拷贝量:

go 复制代码
// 反例:每次 += 都重新分配 + 拷贝,循环越长越慢
func joinBad(parts []string) string {
    s := ""
    for _, p := range parts {
        s += p          // 每轮生成一个新 string
    }
    return s
}

一万个短字符串就能让它比正确写法慢几十倍,而且制造大量临时垃圾。

strings.Builder:一块可扩展的缓冲区

strings.Builder 内部维护一个 []byte,WriteString 直接往后追加,只在容量不够时才扩容,最后 String() 零拷贝地把 buffer 转成 string:

go 复制代码
func joinGood(parts []string) string {
    var b strings.Builder
    for _, p := range parts {
        b.WriteString(p)     // 追加到内部 []byte,不重新分配整串
    }
    return b.String()        // 底层数组直接转 string,不拷贝
}

String() 之所以能零拷贝,是因为 Builder 用了 unsafe 把 []byte 直接转成 string,并且禁止拷贝自身(拷贝会触发 panic,防止两个 Builder 共享同一底层数组)。所以别值传递 Builder,要传就传指针。

Grow:提前知道总长度就一次性分配

即便有 Builder,如果不预分配,它仍会在扩容时多次 realloc。当你能估算最终长度时,用 Grow(n) 一次性把容量备好,彻底消灭中途扩容:

go 复制代码
func joinFast(parts []string) string {
    total := 0
    for _, p := range parts {
        total += len(p)
    }
    var b strings.Builder
    b.Grow(total)            // 一次分配到位,后续 Write 不再扩容
    for _, p := range parts {
        b.WriteString(p)
    }
    return b.String()
}

用 go test -bench 实测,joinFast 通常比不 Grow 的版本少一半以上的内存分配次数(allocs/op)。拼接大量数据时,Grow 是性价比最高的一行优化。

strings.Builder vs bytes.Buffer 怎么选

两者都是可扩展缓冲区,区别在用途:

  • 最终要 string,而且只往里写 → strings.Builder。它的 String() 零拷贝,更省。
  • 需要读回来(实现 io.Reader)、或要反复 Reset 复用、或最终要 \[\]byte → bytes.Buffer。Builder 没有读接口,Bytes() 也没有。
go 复制代码
// bytes.Buffer 能当 io.Reader/Writer 用,还能 Reset 复用
var buf bytes.Buffer
buf.WriteString("hello")
io.Copy(os.Stdout, &buf)   // Builder 做不到:它不是 Reader

一句话:纯拼字符串用 Builder,要参与 IO 流或需要读取用 Buffer。 注意 bytes.Buffer.String() 是有拷贝的,别为了「拼个字符串」平白多一次拷贝。

一个常被忽略的点:Builder 也能 fmt

不想手动拼格式化字符串时,fmt.Fprintf 可以直接往 Builder 写,避免生成中间 string:

go 复制代码
var b strings.Builder
for i, p := range parts {
    fmt.Fprintf(&b, "%d:%s;", i, p)   // 直接写进 Builder,无中间字符串
}

小结

  • 循环里 += 是 O(N²) 拷贝,数据量一大就崩;拼字符串默认上 strings.Builder。
  • String() 零拷贝,但 Builder 禁止值拷贝,传参用指针。
  • 能估算长度就 Grow(total) 一次分配到位,实测能砍掉一半以上的 allocs/op。
  • 选型记忆点:只写、最终要 string 用 Builder;要读回来/复用/输出 \[\]byte 用 bytes.Buffer。
相关推荐
噢,我明白了22 分钟前
JAVA中的一些基础应用知识点
java·开发语言
寂寞的船33 分钟前
【Unity知识速递】第10期 Unity GC 零分配实战:从 UnityEngine.Pool 到手动 GC 控制的全链路优化
jvm·unity·c#·游戏引擎·lucene
花间相见36 分钟前
【计算基础|Redis02】【上手】五大数据类型命令逐条实操
数据库·redis·后端
GISer_Jing42 分钟前
AI Agent秋招面试万字通关指南|架构、ReAct、上下文工程、RAG、评测、生产运维全解
后端·ai·langchain
SL_staff1 小时前
私有化低代码不是妥协,而是企业级数字化的自主可控主航道
java
装不满的克莱因瓶1 小时前
【SpringAI Alibaba】ReactAgent结构化输出OutputType与OutputSchema实战
java·人工智能·ai·springai·springaialibaba
孔令飞1 小时前
把 Agent 搬进飞书:飞书 Agent 机器人演示
云原生·go·ai编程
Moment1 小时前
AI 编程时代,聊聊我的 Mac mini 双屏桌面
前端·后端
秋天的怀念_David2 小时前
算法一千零一夜 · 第二十八夜 注册
java·程序人生·算法
Java码农也是农2 小时前
Spring 事务详解:@Transactional 如何生效、何时回滚、传播行为怎么选
java·spring boot·spring·事务