背景之字符串拼接

在 Go 语言中,对于字符串的拼接处理有很多种方法,那么那种方法才是效率最高的呢?

1
2
3
4
5
6
str := []string{"aa", "bb", "cc"}
ss := ""
for _, s := range str {
	ss += s
}
fmt.Println(ss)

相信大部分人都会使用+操作符或者fmt.Sprinf进行拼接,但要注意的是,在 Go 语言中字符串是不可变的,也就是说每次修改都会导致字符串创建、销毁、内存分配、数据拷贝等操作,在高并发系统中不得不考虑更优的解决方案。所以一开始我经常使用bytes.Buffer

使用 bytes.Buffer

1
2
3
4
5
6
str := []string{"aa", "bb", "cc"}
var buf bytes.Buffer
for _, s := range str {
	buf.WriteString(s)
}
fmt.Println(buf.String())

bytes.Buffer内部使用[]byte来存储写入的数据(包括stringbyterune类型),从而一定程度避免了每次数据写入都重新分配内存和数据拷贝操作。

但要注意buf.String()方法会进行[]bytestring的类型转换,最终还是会导致一次内存申请和数据拷贝。看一下源码实现:

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出场了。

使用 strings.Builder

为了改进bytes.Buffer拼接的性能,在 Go 1.10 及以后,我们可以使用性能更强的 strings.Builder 完成字符串的拼接操作。

1
2
3
4
5
var builder strings.Builder
for _, s := range str {
	builder.WriteString(s)
}
fmt.Println(builder.String())

Benchmark

这里我们做下以上使用方式的性能对比:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
func BenchmarkPlus(b *testing.B) {
	var s string
	for i := 0; i < b.N; i++ {
		s += "hello world"
		_ = s
	}
}

func BenchmarkFormat(b *testing.B) {
	var s string
	for i := 0; i < b.N; i++ {
		s = fmt.Sprintf("%s%s", s, "hello world")
		_ = s
	}
}
func BenchmarkBuffer(b *testing.B) {
	var buf bytes.Buffer
	for i := 0; i < b.N; i++ {
		buf.WriteString("hello world")
		_ = buf.String()
	}
}

//buf.Bytes()仅作为对比buf.String()
func BenchmarkBufferBytes(b *testing.B) {
	var buf bytes.Buffer
	for i := 0; i < b.N; i++ {
		buf.WriteString("hello world")
		_ = buf.Bytes()
	}
}

func BenchmarkBuilder(b *testing.B) {
	var builder strings.Builder
	for i := 0; i < b.N; i++ {
		builder.WriteString("hello world")
		_ = builder.String()
	}
}

go test -benchmem -run=^$  -bench=. -v -count=1

BenchmarkPlus-4          	  110467	    123486 ns/op	  611592 B/op	       1 allocs/op
BenchmarkFormat-4        	   62427	    158490 ns/op	  692120 B/op	       4 allocs/op
BenchmarkBuffer-4        	   87292	    104293 ns/op	  484132 B/op	       1 allocs/op
BenchmarkBufferBytes-4   	47784844	        26.4 ns/op	      26 B/op	       0 allocs/op
BenchmarkBuilder-4       	59271824	        35.4 ns/op	      66 B/op	       0 allocs/op

从压测结果来看,strings.Builder性能最强,BenchmarkBufferBytesBenchmarkBuffer为啥差别这么大,原因就在于buf.String()会发生一次类型转换,比较耗性能,开发中我们可以使用buf.Bytes()返回字节切片来规避这个问题。

接下来,我们看下strings.Builder底层是如何实现的。

源码阅读

源码文件在github.com/golang/go/src/strings/builder.go

strings.Builder 支持的方法是 bytes.Buffer 的子集,仔细看了一下,它实现了io.Writer接口,而 bytes.Buffer 实现了io.Readerio.Writer两个接口。

strings.Builder 结构体

1
2
3
4
type Builder struct {
	addr *Builder // of receiver, to detect copies by value
	buf  []byte 
}

从结构体可以看出数据是存在[]byte中的,与 bytes.Buffer 思路类似,既然 string 在构建过程中,会不断地被销毁和重建,那么就通过底层使用一个 buf []byte 来存放字符串的内容,从而尽量避免这个问题。

注意里面还有个addr字段,等下会讲。

写入操作方法

提供了四种写入方法:

1
2
3
4
5
6
7
8
func (b *Builder) WriteString(s string) (int, error) {	//写入字符串
	b.copyCheck()
	b.buf = append(b.buf, s...)
	return len(s), nil
}
func (b *Builder) Write(p []byte) (int, error) 		//写入字节切片
func (b *Builder) WriteByte(c byte) error  			//写入字节
func (b *Builder) WriteRune(r rune) (int, error)  	//写入Rune

对于写操作,其实就是简单的把数据追加到buf []byte中,利用append来进行底层的自动扩容。

注意这里的每个写入方法开头都调用了copyCheck,和上面的addr字段是一回事,我们等会讲。

Grow 扩容

bytes.Buffer类似,strings.Builder也提供了Grow方法,可以让我们手动进行底层空间的扩容。当然,Grow会先判断空间是否够用,不够的话会进行扩容:

1
2
3
buf := make([]byte, len(b.buf), 2*cap(b.buf)+n)
copy(buf, b.buf)
b.buf = buf

String() 黑科技

strings.Builder之所以性能高,原因就在这了,其他的和bytes.Buffer并无太大差别。

1
2
3
4
// String returns the accumulated string.
func (b *Builder) String() string {
	return *(*string)(unsafe.Pointer(&b.buf))
}

解决 bytes.Buffer 存在的 []bytestring 类型转换和内存拷贝问题,这里使用了一个 unsafe.Pointer 的指针转换操作,实现了直接将 buf []byte 转换为 string 类型,同时避免了内存申请、分配和销毁的问题。

当然我们也可以进行string[]byte的零内存拷贝和申请转换:

1
2
3
4
5
func StringToBytes(str string) []byte {
	s := (*[2]uintptr)(unsafe.Pointer(&str))
	h := [3]uintptr{s[0], s[1], s[1]}
	return *(*[]byte)(unsafe.Pointer(&h))
}

Reset()

strings.Builder同样提供了Reset方法,但和bytes.Buffer()实现的方式不同:

1
2
3
4
5
// Reset resets the Builder to be empty.
func (b *Builder) Reset() {
	b.addr = nil
	b.buf = nil
}

看下bytes.Buffer()的实现:

1
2
3
4
5
6
7
8
// Reset resets the buffer to be empty,
// but it retains the underlying storage for use by future writes.
// Reset is the same as Truncate(0).
func (b *Buffer) Reset() {
	b.buf = b.buf[:0]
	b.off = 0
	b.lastRead = opInvalid
}

所以,我们没办法对strings.Builder申请的内存进行复用。

不允许复制

开头我们提到了结构体里面的addr字段,这里我们详细说下,首先看下copyCheck()方法的实现:

1
2
3
4
5
6
7
func (b *Builder) copyCheck() {
	if b.addr == nil {
		b.addr = (*Builder)(noescape(unsafe.Pointer(b)))
	} else if b.addr != b {
		panic("strings: illegal use of non-zero Builder copied by value")
	}
}

上面我们说到,程序在每次进行写入操作时,都会调用copyCheck()来检查:

  • 当第一次调用时,会把当前strings.Builder实例的指针存入addr
  • 后续每次调用,都会检查当前实例的指针是否和addr相等,不相等会发生panic

为什么要做这个限制? 我的理解是和String()方法的实现是分不开的,String()底层调用了unsafe.Pointer()使用指针直接操作内存,从而规避了内存申请和拷贝,但同时也是有风险的。由于在Go中字符串是不可修改的,所以通过指针进行底层转换后,string[]byte共享了底层数据,这时如果另一个实例对[]byte数据进行了修改,可能会发生panic

当然,从源码来看,在调用任何写入方法之前是可以进行copy的,此时还未进行copyCheck检查。

最佳实践

一般 Go 标准库中使用的方式都是会逐步被推广的,成为某些场景下的最佳实践方式。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// 在不进行内存分配的情况下,将 []byte 转换为 string
func BytesToString(b []byte) string {
	return *(*string)(unsafe.Pointer(&b))
}

// 在不进行内存分配的情况下,将 string 转换为 []byte
func StringToBytes(str string) []byte {
	s := (*[2]uintptr)(unsafe.Pointer(&str))
	h := [3]uintptr{s[0], s[1], s[1]}
	return *(*[]byte)(unsafe.Pointer(&h))
}

小结

本文通过在日常开发中使用到的字符接拼接方式进行了性能对比测试,抛出各个使用方式的问题点,从而引出从Go1.10官方发布的高性能strings.Builder,最后对strings.Builder源码和底层实现进行了解析。

关于究竟使用哪种方式呢?各有利弊,我认为:bytes.Bufferstrings.Builder使用哪种都可以

推荐使用bytes.Buffer

优点:

  • 支持方法更全面,实现了io.Readerio.Writer接口
  • 可以对内存进行复用

缺点:

  • String()会进行一次类型转换,当然也可以使用Bytes()方法来规避
推荐使用strings.Builder

优点:

  • 实现原理和bytes.Buffer类似
  • String()零申请和拷贝类型转换,强能强悍

缺点:

  • 支持方法少,只实现了io.Writer接口
  • 无法复用申请的内存

参考