[反直觉]Golang 字符串拼接到底怎样最快?

341 阅读5分钟

前言

提问: 字符串拼接是很基本的代码,但是请问,哪种拼接方式最快?

小甲: “我知道, 用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 (如果有小伙伴对结论不太相信,可以自行验证)

(这里简单截一个图证明一下,真的是有写代码验证,数据不是编的)

image转存失败,建议直接上传图片文件

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.Builderbytes.Buffer稍快?

关于两者的源码分析网上有很多,不多说。这里只说结论。

两者实现思路相近,底层都是一个 []byte​, 向里面WriteString()WriteByte​ 等操作都被转化成了对这个底层数组的 append + grow操作。

但是:

  1. bytes.Buffer 的功能比较通用字,还需要支持读取,因此额外处理比较多
  2. 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)​ 了。

当然前提是预先知道总长度。

当然可行,而且这样还更灵活,因为它同时还支持向字符串内拼接 []byterunebyte​。

扩展联想

我相信,对于不少人来说,这个结论还是非常反直觉的。这也告诉我们,还是实践出真知。

既然Golang官方提供了strings.Builder​,并且说它在大量拼接时性能最快,那简单的 "a+b"一定是慢的,我们也往往会有这样的刻板印象。

记得Golang的早期版本里是有明显的STW的,后来连续几个大版本对GC进行优化后,绝大多数场景下已经没什么感知了 (当然了,STW还是存在的,对实时性要求极高的场景还是不太适用这种带GC的语言)。 但“Golang有明显STW”的老黄历就这么流传下来了,甚至 Golang都到1.10版本之后了,我还见到有其它语言的使用者提出过这个结论。

再比如,2023年的今天,国产车出口量都反超日本车了,还有人在翻国产车都是垃圾的上古老黄历。

扯远了,总之,或许我们应该少带一点刻板印象,多一点实践和亲证。