前言
提问: 字符串拼接是很基本的代码,但是请问,哪种拼接方式最快?
小甲: “我知道, 用byte.Buffer, 先buf.WriteString(),最后 s := buf.String()”。
小乙:“不对,Golang中早就出了专门用来拼接的 strings.Builder, 自然是这个最快了”
小丙: “你们在说什么?我从来只用fmt.Sprintf”
...
方案列举
假设现在有 s1, s2, s3三个字符串,需要将他们拼接成一个,中间使用逗号连接,请问怎样最快呢?
我们简单一点,就假设 var s1, s2, s3 = "aaa", "bbb", "ccc"
方案一:直接拼接
func Method1Direct(s1, s2, s3 string) string {
return s1 + "," + s2 + "," + s3
}
方案二:bytes.Buffer
func Method2BytesBuffer(s1, s2, s3 string) string {
buf := bytes.Buffer{}
buf.WriteString(s1)
buf.WriteByte(',')
buf.WriteString(s2)
buf.WriteByte(',')
buf.WriteString(s3)
return buf.String()
}
方案三: strings.Builder
func Method3StringsBuilder(s1, s2, s3 string) string {
buf := strings.Builder{}
buf.WriteString(s1)
buf.WriteByte(',')
buf.WriteString(s2)
buf.WriteByte(',')
buf.WriteString(s3)
return buf.String()
}
方案四: fmt.Sprintf
(纯属是来凑数的吧)
func Method4fmtSprintf(s1, s2, s3 string) string {
return fmt.Sprintf("%s,%s,%s", s1, s2, s3)
}
下面公布Benchmark结果
BenchmarkMethod1Direct
BenchmarkMethod1Direct-8 53716510 22.29 ns/op
BenchmarkMethod2BytesBuffer
BenchmarkMethod2BytesBuffer-8 27384000 41.82 ns/op
BenchmarkMethod3StringsBuilder
BenchmarkMethod3StringsBuilder-8 32346974 37.59 ns/op
BenchmarkMethod4fmtSprintf
BenchmarkMethod4fmtSprintf-8 10320807 117.0 ns/op
是不是大吃一惊? 最简单粗暴的 a+b+c 居然是最快的?
我们的方案四fmt.Sprintf倒是毫无悬疑地断层倒数第一。
也许你会说, bytes.Buffer和strings.Builder 慢在需要初始化结构体上了,他们的性能要在大量字符串拼接时才能体现出来。
好的,接下来,我将对100个字符串"aaa"进行拼接,代码太啰嗦这里就不贴了,用来搞笑的方案四也被淘汰了, 下面直接上Benchmark (如果有小伙伴对结论不太相信,可以自行验证)
(这里简单截一个图证明一下,真的是有写代码验证,数据不是编的)

BenchmarkMethod1 (+拼接)
BenchmarkMethod1-8 3189768 370.8 ns/op
BenchmarkMethod2 (bytes.Buffer)
BenchmarkMethod2-8 2500243 482.6 ns/op
BenchmarkMethod3 (strings.Builder)
BenchmarkMethod3-8 2590202 449.2 ns/op
怎么回事?还是方案一最快?下面我们来解析。
原理解析
为什么a+b+c的拼接方式最快呢?
这是因为Golang中对这种语法做了特殊优化。一个 s := "caa" + "bbb" + "ccc" 的代码,在Golang底层中等效于如下代码:
b1, b2, b3 := []byte("aaa"), []byte("bbb"),[]byte("ccc") // str的底层数据就是[]byte
b := make([]byte, 9, 9)
copy(b[0:3], b1)
copy(b[3:6], b2)
copy(b[6:9], b3)
也就是说: Golang在遇到这种代码时,会预先计算最终字符串的长度,按对应长度进行初始化,再把字符串拼接操作转化成对[]byte的复制操作。
而对于bytes.Buffer和strings.Builder, 实际是将字符串的拼接转化成了向一个数组中不断append的操作。除了前面提到的对结构体的初始化外,还会产生比较多的slice.grow(),即扩容操作。
了解 过grow()操作的都知道,它还是相当耗性能的。因为有可能会需要把整个数组复制到新的地址上去。
为什么strings.Builder比bytes.Buffer稍快?
关于两者的源码分析网上有很多,不多说。这里只说结论。
两者实现思路相近,底层都是一个 []byte, 向里面WriteString() WriteByte 等操作都被转化成了对这个底层数组的 append + grow操作。
但是:
- bytes.Buffer 的功能比较通用字,还需要支持读取,因此额外处理比较多
- bytes.Buffer的扩容操作更复杂
这两个细微的差别导致了它在只进行字符串拼接时,性能稍差于strings.Builder
但请注意直接拼接法在 for 循环下,性能还是会很差!
var s string
for _, e := range ss {
s += e
}
对于如上代码,Golang目前还无法进行前面所说的优化,只能原封不动执行N次普通的字符串拼接。
因此性能会很差。
在这种情况下strings.Builder就变成最优解了。
PS:网上很多相关文章里说 + 拼接性能差, 一种是因为使用了for循环导致没被优化,一种是压根没实验,拍脑袋凭空编造。
对strings.Builder进行预扩容可行吗?
有小伙伴对strings.Builder和bytes.Buffer比较熟悉, 知道它们都有个 Grow(n int)函数, 可以预先给底层的数组分配长度, 就相当于 make([]byte, n, n) 了。
当然前提是预先知道总长度。
当然可行,而且这样还更灵活,因为它同时还支持向字符串内拼接 []byte rune byte。
扩展联想
我相信,对于不少人来说,这个结论还是非常反直觉的。这也告诉我们,还是实践出真知。
既然Golang官方提供了strings.Builder,并且说它在大量拼接时性能最快,那简单的 "a+b"一定是慢的,我们也往往会有这样的刻板印象。
记得Golang的早期版本里是有明显的STW的,后来连续几个大版本对GC进行优化后,绝大多数场景下已经没什么感知了 (当然了,STW还是存在的,对实时性要求极高的场景还是不太适用这种带GC的语言)。 但“Golang有明显STW”的老黄历就这么流传下来了,甚至 Golang都到1.10版本之后了,我还见到有其它语言的使用者提出过这个结论。
再比如,2023年的今天,国产车出口量都反超日本车了,还有人在翻国产车都是垃圾的上古老黄历。
扯远了,总之,或许我们应该少带一点刻板印象,多一点实践和亲证。