Go 的 strings.Builder 实战:拼接性能、Grow 预分配与何时该用 bytes.Buffer
Go 的 strings.Builder 实战:拼接性能、Grow 预分配与何时该用 bytes.BufferGo 里拼字符串,新手第一反应是,循环几万次后 CPU 和内存双双爆表。老手会甩一句「用 strings.Builder」,但很少讲清楚它为什么快、Grow 有什么用、什么时候反而该用 bytes.Buffer。这篇用能跑的代码说明白。为什么 在循环里是灾难Go 的 string 是不可变的。s x每次都要新分配一块内存,把旧内容和新内容拷过去,旧的等着被 GC。循环 N 次就是 O(N²) 的拷贝量:// 反例:每次 都重新分配 拷贝,循环越长越慢funcjoinBad(parts[]string)string{s:for_,p:rangeparts{sp// 每轮生成一个新 string}returns}一万个短字符串就能让它比正确写法慢几十倍,而且制造大量临时垃圾。strings.Builder:一块可扩展的缓冲区strings.Builder内部维护一个[]byte,WriteString直接往后追加,只在容量不够时才扩容,最后String()零拷贝地把 buffer 转成 string:funcjoinGood(parts[]string)string{varb strings.Builderfor_,p:rangeparts{b.WriteString(p)// 追加到内部 []byte,不重新分配整串}returnb.String()// 底层数组直接转 string,不拷贝}String()之所以能零拷贝,是因为 Builder 用了unsafe把[]byte直接转成 string,并且禁止拷贝自身(拷贝会触发 panic,防止两个 Builder 共享同一底层数组)。所以别值传递 Builder,要传就传指针。Grow:提前知道总长度就一次性分配即便有 Builder,如果不预分配,它仍会在扩容时多次 realloc。当你能估算最终长度时,用Grow(n)一次性把容量备好,彻底消灭中途扩容:funcjoinFast(parts[]string)string{total:0for_,p:rangeparts{totallen(p)}varb strings.Builder b.Grow(total)// 一次分配到位,后续 Write 不再扩容for_,p:rangeparts{b.WriteString(p)}returnb.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()也没有。// bytes.Buffer 能当 io.Reader/Writer 用,还能 Reset 复用varbuf bytes.Buffer buf.WriteString(hello)io.Copy(os.Stdout,buf)// Builder 做不到:它不是 Reader一句话:纯拼字符串用 Builder,要参与 IO 流或需要读取用 Buffer。注意bytes.Buffer.String()是有拷贝的,别为了「拼个字符串」平白多一次拷贝。一个常被忽略的点:Builder 也能 fmt不想手动拼格式化字符串时,fmt.Fprintf可以直接往 Builder 写,避免生成中间 string:varb strings.Builderfori,p:rangeparts{fmt.Fprintf(b,%d:%s;,i,p)// 直接写进 Builder,无中间字符串}小结循环里是 O(N²) 拷贝,数据量一大就崩;拼字符串默认上strings.Builder。String()零拷贝,但 Builder禁止值拷贝,传参用指针。能估算长度就Grow(total)一次分配到位,实测能砍掉一半以上的allocs/op。选型记忆点:只写、最终要 string 用 Builder;要读回来/复用/输出 []byte 用 bytes.Buffer。