Dawn's Blogs

分享技术 记录成长

0%

Go语言高性能编程 (1) 常用数据结构——字符串拼接和slice性能

字符串拼接

常见拼接方式

在 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.Builderbytes.Buffer[]byte 的性能差距不大,而且消耗的内存也十分接近,性能最好且消耗内存最小的是 preByteConcat,这种方式预分配了内存,在字符串拼接的过程中,不需要进行字符串的拷贝,也不需要分配新的内存,因此性能最好,且内存消耗最小。
1
2
3
4
5
6
BenchmarkPlusConcat-8         19      56 ms/op   530 MB/op   10026 allocs/op
BenchmarkSprintfConcat-8 10 112 ms/op 835 MB/op 37435 allocs/op
BenchmarkBuilderConcat-8 8901 0.13 ms/op 0.5 MB/op 23 allocs/op
BenchmarkBufferConcat-8 8130 0.14 ms/op 0.4 MB/op 13 allocs/op
BenchmarkByteConcat-8 8984 0.12 ms/op 0.6 MB/op 24 allocs/op
BenchmarkPreByteConcat-8 17379 0.07 ms/op 0.2 MB/op 2 allocs/op

使用建议

在进行字符串拼接时,推荐使用 strings.Builder 进行拼接字符串,并且 strings.Builder 也提供了预分配内存的方法 Grow

1
2
3
4
5
6
7
8
func builderConcat(n int, str string) string {
var builder strings.Builder
builder.Grow(n * len(str))
for i := 0; i < n; i++ {
builder.WriteString(str)
}
return builder.String()
}

使用 Grow 优化后,和预分配内存的字节切片方式相比更快,因为省去了字节切片和字符串之间的转换,而且内存分配次数还少了一次。

1
2
BenchmarkBuilderConcat-8   16855    0.07 ns/op   0.1 MB/op       1 allocs/op
BenchmarkPreByteConcat-8 17379 0.07 ms/op 0.2 MB/op 2 allocs/op

原理分析

+ 方法

因为 Golang 中,字符串为不可变类型,所以每一次用加号拼接字符串都会重新申请一块新的空间,将拼接后的结果复制到这段空间中。

导致加号拼接字符串效率低,并且内存消耗大

strings.Builder 方法

strings.Builder 底层维护一个字节切片,所以在用 Builder 写入字符串时不用每一次都申请空间,仅当底层字节切片不够用时才会重新申请一片更大的空间。

strings.Builderbytes.Buffer,包括切片 []byte 的内存是以倍数申请的。例如,初始大小为 0,当第一次写入大小为 10 byte 的字符串时,则会申请大小为 16 byte 的内存(恰好大于 10 byte 的 2 的指数),第二次写入 10 byte 时,内存不够,则申请 32 byte 的内存,以此类推。

  • strings.Builder 底层切片长度在超过 2048 之后,以 640 递增
  • 切片长度超过 1024 时,以 1.25 倍的长度进行申请。

strings.Buffer 方法

strings.Builderbytes.Buffer 底层都是 []byte 数组,但 strings.Builder 性能更好。

因为 bytes.Buffer 转化为字符串时重新申请了一块空间,存放生成的字符串变量,而 strings.Builder 直接将底层的 []byte 转换成了字符串类型返回。

  • bytes.Buffer:重新申请一片空间,用来存放字符串。
1
2
3
4
5
6
7
func (b *Buffer) String() string {
if b == nil {
// Special case, useful in debugging.
return "<nil>"
}
return string(b.buf[b.off:])
}
  • strings.Builder:将底层数组直接转为字符串
1
2
3
func (b *Builder) String() string {
return *(*string)(unsafe.Pointer(&b.buf))
}

slice 性能

在使用 slice 时,主要注意它的扩容策略:

  • 当长度小于 1024 时,就加倍容量。
  • 当长度超过 1024 时,则扩容为原来的 1.25 倍。

性能陷阱

在已有切片的基础上进行切片,不会创建新的底层数组。因为原来的底层数组没有发生变化,内存会一直占用,直到没有变量引用该数组。

因此很可能出现这么一种情况,原切片由大量的元素构成,但是我们在原切片的基础上切片,虽然只使用了很小一段,但底层数组在内存中仍然占据了大量空间,得不到释放

如果在切片时只用了原切片的很小一段,可以使用 copy 来代替直接切片操作