strings.Builder源码阅读与分析
背景之字符串拼接
在 Go 语言中,对于字符串的拼接处理有很多种方法,那么那种方法才是效率最高的呢?
str := []string{"aa", "bb", "cc"}
ss := ""
for _, s := range str {
ss += s
}
fmt.Println(ss)
相信大部分人都会使用+
操作符或者fmt.Sprinf
进行拼接,但要注意的是,在 Go 语言中字符串是不可变的,也就是说每次修改都会导致字符串创建、销毁、内存分配、数据拷贝等操作,在高并发系统中不得不考虑更优的解决方案。所以一开始我经常使用bytes.Buffer
。
使用 bytes.Buffer
str := []string{"aa", "bb", "cc"}
var buf bytes.Buffer
for _, s := range str {
buf.WriteString(s)
}
fmt.Println(buf.String())
bytes.Buffer
内部使用[]byte
来存储写入的数据(包括string
、byte
、rune
类型),从而一定程度避免了每次数据写入都重新分配内存和数据拷贝操作。
但要注意buf.String()
方法会进行[]byte
到string
的类型转换,最终还是会导致一次内存申请和数据拷贝。看一下源码实现:
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
完成字符串的拼接操作。
var builder strings.Builder
for _, s := range str {
builder.WriteString(s)
}
fmt.Println(builder.String())
Benchmark
这里我们做下以上使用方式的性能对比:
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
性能最强,BenchmarkBufferBytes
和BenchmarkBuffer
为啥差别这么大,原因就在于buf.String()
会发生一次类型转换,比较耗性能,开发中我们可以使用buf.Bytes()
返回字节切片来规避这个问题。
接下来,我们看下strings.Builder
底层是如何实现的。
源码阅读
源码文件在github.com/golang/go/src/strings/builder.go
。
strings.Builder
支持的方法是 bytes.Buffer
的子集,仔细看了一下,它实现了io.Writer
接口,而 bytes.Buffer
实现了io.Reader
和io.Writer
两个接口。
strings.Builder 结构体
type Builder struct {
addr *Builder // of receiver, to detect copies by value
buf []byte
}
从结构体可以看出数据是存在[]byte
中的,与 bytes.Buffer
思路类似,既然 string
在构建过程中,会不断地被销毁和重建,那么就通过底层使用一个 buf []byte
来存放字符串的内容,从而尽量避免这个问题。
注意里面还有个addr
字段,等下会讲。
写入操作方法
提供了四种写入方法:
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
会先判断空间是否够用,不够的话会进行扩容:
buf := make([]byte, len(b.buf), 2*cap(b.buf)+n)
copy(buf, b.buf)
b.buf = buf
String() 黑科技
strings.Builder
之所以性能高,原因就在这了,其他的和bytes.Buffer
并无太大差别。
// String returns the accumulated string.
func (b *Builder) String() string {
return *(*string)(unsafe.Pointer(&b.buf))
}
解决 bytes.Buffer
存在的 []byte
到 string
类型转换和内存拷贝问题,这里使用了一个 unsafe.Pointer
的指针转换操作,实现了直接将 buf []byte
转换为 string
类型,同时避免了内存申请、分配和销毁的问题。
当然我们也可以进行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))
}
Reset()
strings.Builder
同样提供了Reset方法
,但和bytes.Buffer()
实现的方式不同:
// Reset resets the Builder to be empty.
func (b *Builder) Reset() {
b.addr = nil
b.buf = nil
}
看下bytes.Buffer()
的实现:
// 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()
方法的实现:
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 标准库中使用的方式都是会逐步被推广的,成为某些场景下的最佳实践方式。
// 在不进行内存分配的情况下,将 []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.Buffer
和strings.Builder
使用哪种都可以。
推荐使用bytes.Buffer
优点:
- 支持方法更全面,实现了
io.Reader
和io.Writer
接口 - 可以对内存进行复用
缺点:
String()
会进行一次类型转换,当然也可以使用Bytes()
方法来规避
推荐使用strings.Builder
优点:
- 实现原理和
bytes.Buffer
类似 String()
零申请和拷贝类型转换,强能强悍
缺点:
- 支持方法少,只实现了
io.Writer
接口 - 无法复用申请的内存