字符串拼接
常见拼接方式
在 Golang 中,字符串拼接有以下几种拼接方式:
- +:使用 + 直接对两个字符串进行拼接操作。
- fmt.Sprintf:使用
fmt.Sprintf(""%v%v", s1, s2)
进行字符串的拼接。 - strings.Builder:使用 strings.Builder.WriteString 方法进行拼接。
- bytes.Buffer:使用 bytes.Bufer.WriteString 方法进行拼接。
- []byte:使用 []byte,利用 append 函数进行拼接(可以预先分配空间)。
性能比较
利用 benchmark 基准测试进行性能比较,结果如下:
+
和fmt.Sprintf
的效率是最低的,和其余的方式相比,性能相差约 1000 倍,而且消耗了超过 1000 倍的内存。strings.Builder
、bytes.Buffer
和[]byte
的性能差距不大,而且消耗的内存也十分接近,性能最好且消耗内存最小的是preByteConcat
,这种方式预分配了内存,在字符串拼接的过程中,不需要进行字符串的拷贝,也不需要分配新的内存,因此性能最好,且内存消耗最小。
1 | BenchmarkPlusConcat-8 19 56 ms/op 530 MB/op 10026 allocs/op |
使用建议
在进行字符串拼接时,推荐使用 strings.Builder 进行拼接字符串,并且 strings.Builder 也提供了预分配内存的方法 Grow:
1 | func builderConcat(n int, str string) string { |
使用 Grow 优化后,和预分配内存的字节切片方式相比更快,因为省去了字节切片和字符串之间的转换,而且内存分配次数还少了一次。
1 | BenchmarkBuilderConcat-8 16855 0.07 ns/op 0.1 MB/op 1 allocs/op |
原理分析
+ 方法
因为 Golang 中,字符串为不可变类型,所以每一次用加号拼接字符串都会重新申请一块新的空间,将拼接后的结果复制到这段空间中。
导致加号拼接字符串效率低,并且内存消耗大。
strings.Builder 方法
strings.Builder 底层维护一个字节切片,所以在用 Builder 写入字符串时不用每一次都申请空间,仅当底层字节切片不够用时才会重新申请一片更大的空间。
而 strings.Builder
,bytes.Buffer
,包括切片 []byte
的内存是以倍数申请的。例如,初始大小为 0,当第一次写入大小为 10 byte 的字符串时,则会申请大小为 16 byte 的内存(恰好大于 10 byte 的 2 的指数),第二次写入 10 byte 时,内存不够,则申请 32 byte 的内存,以此类推。
- strings.Builder 底层切片长度在超过 2048 之后,以 640 递增。
- 切片长度超过 1024 时,以 1.25 倍的长度进行申请。
strings.Buffer 方法
strings.Builder
和 bytes.Buffer
底层都是 []byte
数组,但 strings.Builder
性能更好。
因为 bytes.Buffer
转化为字符串时重新申请了一块空间,存放生成的字符串变量,而 strings.Builder
直接将底层的 []byte
转换成了字符串类型返回。
- bytes.Buffer:重新申请一片空间,用来存放字符串。
1 | func (b *Buffer) String() string { |
- strings.Builder:将底层数组直接转为字符串。
1 | func (b *Builder) String() string { |
slice 性能
在使用 slice 时,主要注意它的扩容策略:
- 当长度小于 1024 时,就加倍容量。
- 当长度超过 1024 时,则扩容为原来的 1.25 倍。
性能陷阱
在已有切片的基础上进行切片,不会创建新的底层数组。因为原来的底层数组没有发生变化,内存会一直占用,直到没有变量引用该数组。
因此很可能出现这么一种情况,原切片由大量的元素构成,但是我们在原切片的基础上切片,虽然只使用了很小一段,但底层数组在内存中仍然占据了大量空间,得不到释放。
如果在切片时只用了原切片的很小一段,可以使用 copy 来代替直接切片操作。