北川广海の梦

北川广海の梦

浅聊字符串拼接

25
2023-12-21

浅说字符串拼接

绝大多数语言,都提供了+这一操作符,支持字符串进行拼接。

并且在绝大多数语言中,字符串都是不可变的。那么在拼接时候,需要不断开辟新的内存,并将原本的内容移动到新的内存区域。

参考以下写法:

func Concat(slice []string) string{
	var result = ""
	for _,str := range slice{
		result += str
	}
	return result
}

这就是不断遍历,然后拼接。每次拼接,都需要开辟一块能包含之前所有的字符串大小的区域。

如果每个字符串大小为 10 byte,总共有 10k 个字符串进行拼接,那么需要申请的内存总次数为 10k次,每一次 申请的开销为 (N+)*10,那么总的内存开销为 10 + 2 10 + 3 10 + ... + 10000 * 10 byte = 500 MB 这对于一个简单函数来说,是一个非常恐怖的开销。

今天的重点不是这个,循环拼接字符串,不能用 + 直接拼接,应该是每个程序员的基本常识。大家都会选择用 stringBuilder 来优化。

但是在日常代码中,很多时候我们的字符串拼接,不是在循环中进行的,而是像这样:

func plusConcat(url, uuid string) string {
	return url + "/api/v1/" + uuid + "/update-stock-level/?from=shop&y=zzi"
}

这种情况下,我们直接用 + 号,性能还会比 stirngBuilder 差很多吗?

下面我们跑一下测试:

func builderConcat(url, uuid string) string {
	var builder strings.Builder
	builder.WriteString(url)
	builder.WriteString("/api/v1/")
	builder.WriteString(uuid)
	builder.WriteString("/update-stock-level/?from=shop&y=zzi")
	return builder.String()
}

func plusConcat(url, uuid string) string {
	return url + "/api/v1/" + uuid + "/update-stock-level/?from=shop&y=zzi"
}

var uid = uuid2.NewString()
var u = "http://127.0.0.1:8080"

func benchmark(b *testing.B, f func(string, string) string) {
	for i := 0; i < b.N; i++ {
		f(u, uid)
	}
}

func BenchmarkPlusConcat(b *testing.B)    { benchmark(b, plusConcat) }
func BenchmarkBuilderConcat(b *testing.B) { benchmark(b, builderConcat) }

可以得到结果:

go test -bench="Concat$" -benchmem .
[][]goos: darwin
goarch: arm64
pkg: Test_go
BenchmarkPlusConcat-8           39033801                30.92 ns/op          112 B/op          1 allocs/op
BenchmarkBuilderConcat-8        13884469                87.85 ns/op          360 B/op          4 allocs/op

发现用加号拼接比stirngBuilder快不少,并且内存分配只有一次。

分析一下原因,这是由于编译器优化,提前计算了需要拼接的字符串的长度,所以直接开辟好了内存,所以使得速度反而快了不少。 而对于在循环中拼接的情况,编译器无法直接计算数组中需要的空间总大小,所以需要不断的重新开辟内存。

那么同理可知道,stringBuilder 由于没有编译器优化,无法提前得知总大小,所以只能以一个最小的容量初始化,等后面容量不够时,会发生扩容。导致产生了4次分配开销,速度自然也慢下来了。

我们可以这样写,来做一点优化:

func builderConcat(url, uuid string) string {
	var builder strings.Builder
	t1 := "/api/v1/"
	t2 := "/update-stock-level/?from=shop&y=zzi"
	builder.Grow(len(url) + len(uuid) + len(t1) + len(t2))

	builder.WriteString(url)
	builder.WriteString(t1)
	builder.WriteString(uuid)
	builder.WriteString(t2)
	return builder.String()
}

这时就可以发现,速度提升了不少:

[][]goos: darwin
goarch: arm64
pkg: Test_go
BenchmarkPlusConcat-8           38879427                32.15 ns/op          112 B/op          1 allocs/op
BenchmarkBuilderConcat-8        37161097                27.85 ns/op          112 B/op          1 allocs/op

这是由于我们提前计算了需要开辟的内存大小,减少了分配次数。

这在内存优化中,也是非常重要的思路。堆空间的内存分配开销,在CPU密集任务中,是不可忽略的,需要尽量优化。

同时,写程序时也不要因为听过 stringBuilder 性能比 加号操作符好,就盲目使用stringBuilder,需要针对实际情况考虑,例如在编译器能够优化的场景下,使用加号操作符,能够大大减小代码量,性能也只弱了几纳秒。如果自己直接用 stirngBuilder 来写,反而性能更差。