Go 字符串拼接

Go 字符串拼接的几种方式

在 Go 语言中,因为字符串(string)是不可变的,拼接字符串实际上是创建了一个新的字符串对象。如果代码中存在大量的字符串拼接,可能会对性能产生严重的影响。

常见的拼接方式

  1. 使用 +
  2. 使用 fmt.Sprintf
  3. 使用 strings.Builder
  4. 使用 bytes.Buffer
  5. 使用 []byte

现在我们来分析一下几种拼接放式的性能。

我们创建一个长度为 n 的随机字符串的函数,然后

const s = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"

func randomString(n int) string {
  b := make([]byte, n)
  for i := range b {
    b[i] = s[rand.Intn(len(s))]
  }
  return string(b)
}

使用 +

func plusConcat(n int, str string) string {
  s := ""
  for i := 0; i < n; i++ {
    s += str
  } 
  return s
}

使用 fmt.Sprintf

func sprintfConcat(n int, str string) string {
	s := ""
	for i := 0; i < n; i++ {
		s = fmt.Sprintf("%s%s", s, str)
	}
	return s
}

使用 strings.Builder

func builderConcat(n int, str string) string {
	var builder strings.Builder
	for i := 0; i < n; i++ {
		builder.WriteString(str)
	}
	return builder.String()
}

使用 bytes.Buffer

func bufferConcat(n int, s string) string {
	buf := new(bytes.Buffer)
	for i := 0; i < n; i++ {
		buf.WriteString(s)
	}
	return buf.String()
}

使用 []byte

func byteConcat(n int, str string) string {
	buf := make([]byte, 0)
	for i := 0; i < n; i++ {
		buf = append(buf, str...)
	}
	return string(buf)
}

需要注意的是,如果长度是可以预知的,创建 []byte 时,可以提前分配切片的容量(cap)。

func preByteConcat(n int, str string) string {
	buf := make([]byte, 0, n*len(str))
	for i := 0; i < n; i++ {
		buf = append(buf, str...)
	}
	return string(buf)
}

benchmark 性能测试

在 benchmark 用例,生成了一个长度的 10 的字符串,并拼接 10000 次。

func benchmark(b *testing.B, f func(int, string) string) {
	var str = randomString(10)
	for i := 0; i < b.N; i++ {
		f(10000, str)
	}
}

func BenchmarkPlusConcat(b *testing.B)    { benchmark(b, plusConcat) }
func BenchmarkSprintfConcat(b *testing.B) { benchmark(b, sprintfConcat) }
func BenchmarkBuilderConcat(b *testing.B) { benchmark(b, builderConcat) }
func BenchmarkBufferConcat(b *testing.B)  { benchmark(b, bufferConcat) }
func BenchmarkByteConcat(b *testing.B)    { benchmark(b, byteConcat) }
func BenchmarkPreByteConcat(b *testing.B) { benchmark(b, preByteConcat) }

运行结果:

$ go test -bench=. -benchmem .
goos: darwin
goarch: arm64
pkg: awesomeProject/newcoder
BenchmarkPlusConcat-8                 34          33644359 ns/op        530997835 B/op     10024 allocs/op
BenchmarkSprintfConcat-8              20          56354983 ns/op        832814022 B/op     34093 allocs/op
BenchmarkBuilderConcat-8           18481             65482 ns/op          514801 B/op         23 allocs/op
BenchmarkBufferConcat-8            24768             48599 ns/op          368580 B/op         13 allocs/op
BenchmarkByteConcat-8              21955             60855 ns/op          621298 B/op         24 allocs/op
BenchmarkPreByteConcat-8           41266             29780 ns/op          212992 B/op          2 allocs/op
PASS
ok      awesomeProject/newcoder 11.509s

从基准测试的结果来看,使用 +fmt.Sprintf 的效率是最低的,和其余的方式相比,性能相差约 1000 倍,而且消耗了超过 1000 倍的内存。当然 fmt.Sprintf 通常是用来格式化字符串的,一般不会用来拼接字符串。

strings.Builderbytes.Buffer[]byte 的性能差距不大,而且消耗的内存也十分接近,性能最好且消耗内存最小的是 preByteConcat,这种方式预分配了内存,在字符串拼接的过程中,不需要进行字符串的拷贝,也不需要分配新的内存,因此性能最好,且内存消耗最小。

建议

综合易用性和性能,一般推荐 strings.Builder 来拼接字符串。

另外,strings.Builder 也提供了预分配内存的方法 Grow:

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 优化的版本的 benchmark 结果如下:

BenchmarkBuilder1Concat-8   	 30687	     36176 ns/op	  106496 B/op	       1 allocs/op

相比预分配内存的 []byte , 省去了 []byte 转字符串 string 的操作,内存分配还减少了一次,内存消耗减少一半。

底层原理

strings.Bulider+ 性能和内存消耗如此之大,是因为两者的内存分配方式不一样。

字符串在 Go 语言中是不可变类型,占用内存大小是固定的,当使用 + 拼接 2 个字符串时,生成一个新的字符串,那么就需要开辟一段新的空间,新空间的大小是原来两个字符串的大小之和。拼接第三个字符串时,再开辟一段新空间,新空间大小是三个字符串大小之和,以此类推。假设一个字符串大小为 10 byte,拼接 1w 次,需要申请的内存大小为:

10 + 2 * 10 + 3 * 10 + ... + 10000 * 10 byte = 500 MB 

strings.Builderbytes.Buffer,包括切片 []byte 的内存是以倍数申请的。例如,初始大小为 0,当第一次写入大小为 10 byte 的字符串时,则会申请大小为 16 byte 的内存(恰好大于 10 byte 的 2 的指数),第二次写入 10 byte 时,内存不够,则申请 32 byte 的内存,第三次写入内存足够,则不申请新的,以此类推。在实际过程中,超过一定大小,申请策略上会有些许调整。

我们可以通过打印 builder.Cap() 查看字符串拼接过程中,strings.Builder 的内存申请过程。

=== RUN   TestBuilderConcat
16 32 64 128 256 512 896 1408 2048 3072 4096 5376 6912 9472 12288 16384 21760 28672 40960 57344 73728 98304 131072
--- PASS: TestBuilderConcat (0.00s)
PASS
ok      awesomeProject/newcoder 0.233s

比较 strings.Builder 和 bytes.Buffer

strings.Builderbytes.Buffer 底层都是 []byte 数组,但 strings.Builder 性能比 bytes.Buffer 略快约 10% 。一个比较重要的区别在于,bytes.Buffer 转化为字符串时重新申请了一块空间,存放生成的字符串变量,而 strings.Builder 直接将底层的 []byte 转换成了字符串类型返回了回来。

  • bytes.Buffer
// To build strings more efficiently, see the strings.Builder type.
func (b *Buffer) String() string {
	if b == nil {
		// Special case, useful in debugging.
		return "<nil>"
	}
	return string(b.buf[b.off:])
}
  • strings.Builder
// String returns the accumulated string.
func (b *Builder) String() string {
	return *(*string)(unsafe.Pointer(&b.buf))
}