一、前言
相信很多java
程序员都背过或者用过StringBuilder
或者StringBuffer
这种sb
代码。
最近工作需要查看Helm
的源码的时候,注意到一个特别使用的字符串连接的方法strings.Builder
,咋一看特别像java
的StringBuilder
,所以研究了下它,以及了解了下其他连接字符串的方法,结果发现确实strings.Builder
的效果显著。
二、内容
1.首先我放出我了解的几个方法,并给出在我本地电脑跑benchmark
的效果,点击查看代码:
①.传统的+
号连接
1
2
3
4
5
6
7
| func BenchmarkTestStrPlus(b *testing.B) {
var result string
b.ResetTimer()
for i := 0; i < b.N; i++ {
result = strconv.Itoa(i) + result
}
}
|
②.fmt.Sprintf
连接
1
2
3
4
5
6
7
| func BenchmarkTestStrSprintf(b *testing.B) {
var result string
b.ResetTimer()
for i := 0; i < b.N; i++ {
result = fmt.Sprintf("%s%s", result, strconv.Itoa(i))
}
}
|
③. strings.Join
连接
1
2
3
4
5
6
7
| func BenchmarkTestJoin(b *testing.B) {
var result string
b.ResetTimer()
for i := 0; i < b.N; i++ {
result = strings.Join([]string{result, strconv.Itoa(i)}, "")
}
}
|
④.bytes.Buffer
连接
1
2
3
4
5
6
7
8
9
| func BenchmarkTestBuffer(b *testing.B) {
var result bytes.Buffer
b.ResetTimer()
for i := 0; i < b.N; i++ {
if _, err := result.WriteString(strconv.Itoa(i)); err != nil {
panic(err)
}
}
}
|
⑤.最后就是strings.Builder
方法连接
1
2
3
4
5
6
7
| func BenchmarkTestBuilder(b *testing.B) {
var result strings.Builder
b.ResetTimer()
for i := 0; i < b.N; i++ {
result.WriteString(strconv.Itoa(i))
}
}
|
以上就是我了解到的5种字符串拼接的方法,大家可以猜测下他们的效率顺序,大家如果谁对我这种测试方法质疑的,可以留言讨论。csdn链接:https://blog.csdn.net/u010927340/article/details/118120669
我觉得第二种性能应该最差,毕竟它支持的各种类型太多,一般来说兼容性是以性能降低的代价。其次就是第一钟,最原始的拼接的字符串的方式,为什么呢?这个和java
的String
很像,都是immutable
,换言之就是当更改这个string
对象的时候,其实并没有更改该string
,而是新增了一个string
,但是给人的感觉好像把它修改了,为什么这么设计,大家可以自行百度,但是该设计会导致在拼接字符串的时候会产生大量的string
,不仅耗时,还耗内存,更有甚者导致STW
。然后我觉得会是第三种,点开看了下他的Join
方法,竟然里面使用了strings.Builder
,可惜他是直接返回了string
,相当于每个Join
操作也产生了新的string
,所以我把她放到第三位。最后就是第4和第5的PK,还是我上面提到的判断思路:谁更专业肯定效率最好。所以我觉得第5种性能还是要比第4种好。
到此我的运行前的性能判断,从高到低如下:
好的用我的渣渣电脑运行,结果如下(事实上我运行了很多次,性能指标大小顺序都是一致的):
1
2
3
4
5
6
7
8
9
10
11
| cpu: Intel(R) Core(TM) i5-5257U CPU @ 2.70GHz
BenchmarkTestStrPlus
BenchmarkTestStrPlus-4 187838 226381 ns/op
BenchmarkTestStrSprintf
BenchmarkTestStrSprintf-4 130268 306116 ns/op
BenchmarkTestJoin
BenchmarkTestJoin-4 182164 289074 ns/op
BenchmarkTestBuffer
BenchmarkTestBuffer-4 20228474 107.1 ns/op
BenchmarkTestBuilder
BenchmarkTestBuilder-4 10084845 99.34 ns/op
|
事实上的性能从高到低的结果如下:
大型翻车现场,原来原始的方式竟然没想象中那么差,string
的原始拼接在很多语言中都分别在编译期和运行时都有特地优化过,毕竟它的使用频率非常高,优化它就相当于优化了整个语言。这个估计能查资料才能找到具体原因,在这里我不关心到底为什么性能比3还强,我这里只聚焦于strings.Builder
,因为从宏观上来说他肯定比除第4种方法外都强,在这里也不额外关心为什么bytes.Buffer
以微弱的劣势输于strings.Builder
,事实上我发现strings.Builder
的实现和bytes.Buffer
的原理很像,都是操作byte
数组,但是bytes.Buffer
的功能更强。我关心的是这个结论很重要。下面我们一起领略下strings.Builder
的设计吧。
2.strings.Builder
的源码很简单,加上注释也才100多行
我给大家列一下核心代码,那就更少了
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
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
| // A Builder is used to efficiently build a string using Write methods.
// It minimizes memory copying. The zero value is ready to use.
// Do not copy a non-zero Builder.
type Builder struct {
addr *Builder // of receiver, to detect copies by value
buf []byte
}
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")
}
}
func (b *Builder) String() string {
return *(*string)(unsafe.Pointer(&b.buf))
}
// grow copies the buffer to a new, larger buffer so that there are at least n bytes of capacity beyond len(b.buf).
func (b *Builder) grow(n int) {
buf := make([]byte, len(b.buf), 2*cap(b.buf)+n)
copy(buf, b.buf)
b.buf = buf
}
// Grow grows b's capacity, if necessary, to guarantee space for another n bytes. After Grow(n), at least n bytes can be written to b without another allocation. If n is negative, Grow panics.
func (b *Builder) Grow(n int) {
b.copyCheck()
if n < 0 {
panic("strings.Builder.Grow: negative count")
}
if cap(b.buf)-len(b.buf) < n {
b.grow(n)
}
}
func (b *Builder) Write(p []byte) (int, error) {
b.copyCheck()
b.buf = append(b.buf, p...)
return len(p), nil
}
func (b *Builder) WriteByte(c byte) error {
b.copyCheck()
b.buf = append(b.buf, c)
return nil
}
func (b *Builder) WriteRune(r rune) (int, error) {
b.copyCheck()
if r < utf8.RuneSelf {
b.buf = append(b.buf, byte(r))
return 1, nil
}
l := len(b.buf)
if cap(b.buf)-l < utf8.UTFMax {
b.grow(utf8.UTFMax)
}
n := utf8.EncodeRune(b.buf[l:l+utf8.UTFMax], r)
b.buf = b.buf[:l+n]
return n, nil
}
func (b *Builder) WriteString(s string) (int, error) {
b.copyCheck()
b.buf = append(b.buf, s...)
return len(s), nil
}
// Reset resets the Builder to be empty.
func (b *Builder) Reset() {
b.addr = nil
b.buf = nil
}
|
首先实现了io
包中的Writer``StringWriter``ByteWriter
接口,也就是说在其他地方接受这3个接口的地方都能用strings.Builder
替代写入。
开头1~4
行的注释就申明了Builder
的设计宗旨:尽可能避免内存拷贝
,而且还特地提醒了:Builder不能被拷贝
。为什么不能被拷贝呢?Builder
的设计宗旨就是避免内存拷贝,但是如果说再拷贝Builder的话就违背了。
为了做到上面2点,Builder
的结构体就体现了:
1
2
3
4
| type Builder struct {
addr *Builder // of receiver, to detect copies by value
buf []byte
}
|
里面包括一个字节数组,在go中string
底层其实就是字节数组,为什么这里也用了数组呢?底层字节数组方便往数组里面塞东西,自己可以控制扩容,扩多少。这样不会像string
的原始拼接方式那样产生大量的string
对象。还有1个指向自己的Builder
指针,正如注释所说:检测是否有拷贝了整个Builder
对象。如果拷贝了会怎样呢?见copyCheck
代码:
1
2
3
4
5
6
7
8
9
10
11
12
| func (b *Builder) copyCheck() {
if b.addr == nil {
// This hack works around a failing of Go's escape analysis
// that was causing b to escape and be heap allocated.
// See issue 23382.
// TODO: once issue 7921 is fixed, this should be reverted to
// just "b.addr = b".
b.addr = (*Builder)(noescape(unsafe.Pointer(b)))
} else if b.addr != b {
panic("strings: illegal use of non-zero Builder copied by value")
}
}
|
会直接发生panic
,很霸道,直接被Builder
拒绝了。
接下来我写一个拷贝Builder
的代码验证下,代码链接https://play.studygolang.com/p/bDt9lGImCiy:
1
2
3
4
| var sb strings.Builder
sb.WriteString("1")
var sb_copy = *(*strings.Builder)(unsafe.Pointer(&sb))
sb_copy.WriteString("2")
|
如下为panic
的输出:
1
2
3
4
5
6
7
8
9
| panic: strings: illegal use of non-zero Builder copied by value
goroutine 1 [running]:
strings.(*Builder).copyCheck(...)
/usr/local/go-faketime/src/strings/builder.go:42
strings.(*Builder).WriteString(...)
/usr/local/go-faketime/src/strings/builder.go:122
main.main()
/tmp/sandbox194128443/prog.go:12 +0x1ad
|
所有Write*
操作都有copyCheck
的拷贝检测,如果有发生类似的拷贝,都会panic
。但是它强调了Builder
的重用功能,需要Reset
之后才能构造下一个String
。
接下来要说明的是Grow
方法,该方法是提供的一个public
的方法用来给Builder
扩容buf
的数组。为什么要提供这个呢?可以看到除了WriteRune
必要的时候主动扩容了,其他Write*
方法并没有去事先判断扩容的-依赖切片的自动判断扩容。其实Builder
提供这个Grow
方法是给使用者使用,提前能判断好长度,然后扩容,这样后期调用Write*
方法就没有内存拷贝的操作,因为一旦发生扩容的话就会有2个问题:分配内存和拷贝。
代码34行判断如果buf
数组的剩余容量小于需要扩容的长度,那么才去调用私有的grow
去扩容:
1
2
3
4
5
6
7
| // grow copies the buffer to a new, larger buffer so that there are at least n
// bytes of capacity beyond len(b.buf).
func (b *Builder) grow(n int) {
buf := make([]byte, len(b.buf), 2*cap(b.buf)+n)
copy(buf, b.buf)
b.buf = buf
}
|
这几行代码很清晰,就是分配内存,容量是以前的容量再加上需要扩容的数量,这样就确保长度n
的字节能存到Builder
去。
接下来看看WriteRune
方法,因为其他的Write*
方法太简单,就略过了。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| func (b *Builder) WriteRune(r rune) (int, error) {
b.copyCheck()
if r < utf8.RuneSelf {
b.buf = append(b.buf, byte(r))
return 1, nil
}
l := len(b.buf)
if cap(b.buf)-l < utf8.UTFMax {
b.grow(utf8.UTFMax)
}
n := utf8.EncodeRune(b.buf[l:l+utf8.UTFMax], r)
b.buf = b.buf[:l+n]
return n, nil
}
|
rune
在go
中是一个特殊类型,用来表示一个utf-8
的一个字符,utf-8
是一个动态字节,最长有4个字节,细究发现rune
其实是int32
的别名,int32
正好是4个字节,所以刚刚好。也就是WriteRune
方法是用来写入utf-8
的字符。第3行表示这个字符是否是1个字节,如果可以就直接追加到切片。RuneSelf
表示就是一个utf-8
字符最大的单字节大小。
第8行判断剩余的容量是否大于utf-8
的最大字节UTFMax
,也就是4,如果小于则能提前扩容4个字节长度。然后11行开始把rune
字符的字节依次复制到buf
切片里面去。返回的结果n表示复制了多少个字节到切片里面去,因为rune
是1-4个字节。
注意第12行的操作,如果debug下会发现,在执行11行代码之后,buf
并没有变化,这是因为11行的入参b.buf[l:l+utf8.UTFMax]
其实是产生了一个新的切片,切片的结构如下:
1
2
3
4
5
| type slice struct {
array unsafe.Pointer
len int
cap int
}
|
array
是存储切片的真正的值,虽然说2个切片都维护同一个array
,但是len
和cap
其实是切片自己维护的,所以不执行12行的操作的时候,结果就是执行len
和cap
的结果没有变化。执行之后len
和cap
就发生变化,且能打印出来整个array
的值。
当写完了所有的字符串的字符之后,要得到拼接后的字符串需要调用String
方法:
1
2
3
4
| // String returns the accumulated string.
func (b *Builder) String() string {
return *(*string)(unsafe.Pointer(&b.buf))
}
|
b.buf
是个切片,而上面分析了切片的底层其实就是array
数组,string
的结构体如下:
1
2
3
4
| type stringStruct struct {
str unsafe.Pointer
len int
}
|
和上面的slice的结构体相似,故能直接把切片b.buf
转为*string
。
三、总结
上面分析了strings.Builder
的源码,知道了拼接字符串的底层逻辑,所以如果有大量的string
对象需要拼接,那么strings.Builder
非常合适,而且最好知道所有要拼接的string
的长度总和,事先分配好内存,还能进一步提高效率。
而且我发现其实Reset
方法还可以优化的角度,不用把b.buf
设为nil
,这样的话以前申请的buf
的数组就会被回收掉,我觉得可以利用起来,不用下次拼接字符串的时候再申请内存。不知道你怎么看这个问题呢?
- 我的微信公众号: