想要写这个的目的在于,平时开发业务的时候会用到比较多的字符串拼接,刚好看了一些源码,就想说通过benchmark和底层的一些源码来看下常见字符串拼接的一些性能和原理。
常见的字符串拼接方式
+
最普通,最直接的加号
a := "abc" + "bcd"
+
的实现都是在源码runtime/string.go的func concatstrings(buf *tmpBuf, a []string) string
,这个函数的trace可以通过[dlv](GO delve(dlv)调试工具笔记及实操 - 知乎 (zhihu.com))去trace到。runtime/string.go里面还有下面这些方法:
// 2个字符串相加
func concatstring2(buf *tmpBuf, a [2]string) string
// 3个字符串相加
func concatstring3(buf *tmpBuf, a [3]string) string
// 4个字符串相加
func concatstring4(buf *tmpBuf, a [4]string) string
// 5个字符串相加
func concatstring5(buf *tmpBuf, a [5]string) string
这些方法最后调用的都是concatstrings,超过5个字符串相加的时候也是直接调concatstrings。concatstrings里面主要是rawstringtmp -> rawstring,rawstring会进行一次内存分配,饭后返回到concatsstrings,再循环a
拷贝,b
和s
的指针指向的是同一个地址。
可以看到的是,+
不需要类型转换也就不需要内存拷贝,且只需要一次内存分配。+
的实现就是分配一个空间,然后把两个字符串都"移动"过去。因此,也可以预见的就是,每次拼接,每次都要进行一次内存分配。
func concatstrings(buf *tmpBuf. a []string) {
...
s, b := rawstringtmp(buf, l)
for _, x := range a {
copy(b, x)
b = b[len(x):]
}
return s
}
func rawstringtmp(buf *tmpBuf, l int) (s string, b []byte) {
if buf != nil && l <= len(buf) {
b = buf[:l]
s = slicebytetostringtmp(&b[0], len(b))
} else {
s, b = rawstring(l)
}
return
}
func rawstring(size int) (s string, b []byte) {
// 分配内存
p := mallocgc(uintptr(size), nil, false)
// 地址和长度
stringStructOf(&s).str = p
stringStructOf(&s).len = size
*(*slice)(unsafe.Pointer(&b)) = slice{p, size, size}
return
}
fmt.Sprintf
fmt.Sprintf算是之前开发的时候挺经常用到的,用来拼接数字,字符串,或者要转成string的数值类型。
a := fmt.Sprintf("%s%s","abc","bcd")
底层实现是在源码fmt/print.go的func Sprintf(format string, a ...interface{}) string
,里面doPrintf的方法是最后根据不同类型拼接的底层实现方法。这个方法为了对不同类型的数据做拼接,所以逻辑比较复杂,另外还需要一次内存拷贝,从[]byte变为string。但是这个方法在拼接数值类型,slice还是挺香的。
func Sprintf(format string, a ...interface{}) string {
p := newPrinter()
p.doPrintf(format, a)
s := string(p.buf)
p.free()
return s
}
bytes.Buffer
bytes.Buffer底层是一个[]byte的切片,实现了io.Reader和io.Writer,说是拼接,其实就是往里面一直write然后读出来。
源码在bytes/buffer.go,用到的方法就是Write或者WriteString,比较常用,然后再用String()读出来。在Write或者WriteString的时候,因为底层数据结构是[]byte,所以需要考虑扩容的问题。也就是如果写入的字符串的长度超过[]byte的长度,就需要去扩容(根据实际情况),每次扩容就是一次内存分配。最后的String()需要把[]byte转成string,也需要一次内存分配。
type Buffer struct {
buf []byte
off int
lastRead readOp
}
func (b *Buffer) Write(p []byte) (n int, err error) {
b.lastRead = opInvalid
m, ok := b.tryGrowByReslice(len(p))
if !ok {
m = b.grow(len(p))
}
return copy(b.buf[m:], p), nil
}
func (b *Buffer) WriteString(s string) (n int, err error) {
b.lastRead = opInvalid
m, ok := b.tryGrowByReslice(len(s))
if !ok {
m = b.grow(len(s))
}
return copy(b.buf[m:], s), nil
}
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第一次用的时候,还是在刷leecode的时候才见到原来还有这种数据结构。源码在strings/builder.go。它的底层实现逻辑其实和bytes.Buffer差不多,不过只实现io.Writer,扩容问题也会存在。但是比bytes.Buffer好的地方在于String()方法,不用内存拷贝,而是通过指针转换的方法,少了一次内存拷贝。
type Builder struct {
addr *Builder
buf []byte
}
func (b *Builder) Write(p []byte) (int, error) {
b.copyCheck()
b.buf = append(b.buf, p...)
return len(p), nil
}
func (b *Builder) WriteString(s string) (int, error) {
b.copyCheck()
b.buf = append(b.buf, s...)
return len(s), nil
}
func (b *Builder) String() string {
return *(*string)(unsafe.Pointer(&b.buf))
}
strings.Join
和strings.Builder类似,底层实现其实用的就是strings.Builder,只不过多了一个会把分隔符也加进去,并且会先做一次Grow(后面讲为什么)。源码在strings/strings.go,Join方法。因为底层时strings.Builder,所以转成string的时候不需要内存拷贝。
func Join(elems []string, sep string) string {
switch len(elems) {
case 0:
return ""
case 1:
return elems[0]
}
n := len(sep) * (len(elems) - 1)
for i := 0; i < len(elems); i++ {
n += len(elems[i])
}
var b Builder
b.Grow(n) //和strings.Builder的区别
b.WriteString(elems[0])
for _, s := range elems[1:] {
b.WriteString(sep)
b.WriteString(s)
}
return b.String()
}
范例代码
2个字符串拼接
// ByOperationAdd is 根据操作符+
func ByOperationAdd(a, b string) string {
return a + b
}
// ByFmtSprintf is 用fmt包拼接
func ByFmtSprintf(a, b string) string {
return fmt.Sprintf("%s%s", a, b)
}
// ByBytesBuffer is bytes.buffer拼接
func ByBytesBuffer(a, b string) string {
buf := bytes.NewBuffer(nil)
buf.WriteString(a)
buf.WriteString(b)
return buf.String()
}
// ByStringBuilder is 用string builder拼接
func ByStringBuilder(a, b string) string {
builder := strings.Builder{}
builder.WriteString(a)
builder.WriteString(b)
return builder.String()
}
// ByStringJoin is 用切片拼接
func ByStringJoin(a, b string) string {
return strings.Join([]string{a, b}, "")
}
多个字符串拼接
多个字符串拼接,不合适用fmt.Sprintf做拼接了(5个以内我个人还是接受的)
// ByOperationAddMulti is 根据操作符+,加100次
func ByOperationAddMulti(a string) string {
result := ""
for i := 0; i < 100; i++ {
result += a
}
return result
}
// ByBytesBufferMulti is bytes.buffer拼接,写入100次
func ByBytesBufferMulti(a string) string {
buf := bytes.NewBuffer(nil)
for i := 0; i < 100; i++ {
buf.WriteString(a)
}
return buf.String()
}
// ByStringBuilderMulti is 用string builder拼接,写入100次
func ByStringBuilderMulti(a string) string {
builder := strings.Builder{}
for i := 0; i < 100; i++ {
builder.WriteString(a)
}
return builder.String()
}
// ByStringJoinMulti is 用切片拼接,长度为100个
func ByStringJoinMulti(a string) string {
arr := []string{}
for i := 0; i < 100; i++ {
arr = append(arr, a)
}
return strings.Join(arr, "")
}
提前Grow
拼接前一次性分配好内存,这边和2个字符串拼接的比较,而且也只有bytes.Buffer, strings.Builder和strings.Join能比。
// ByBytesBufferWithGrow is bytes.buffer拼接,new的时候给一个指定长度的切片
func ByBytesBufferWithGrow(a, b string) string {
l := len(a) + len(b)
c := make([]byte, l)
buf := bytes.NewBuffer(c)
buf.WriteString(a)
buf.WriteString(b)
return buf.String()
}
// ByStringBuilderWithGrow is 用string builder拼接,并先分配好长度
func ByStringBuilderWithGrow(a, b string) string {
builder := strings.Builder{}
l := len(a) + len(b)
builder.Grow(l)
builder.WriteString(a)
builder.WriteString(b)
return builder.String()
}
Benchmark
跑benchmark主要是这几个维度
- 字符串长度(2个字符串相加,字符串长度变长)
- 字符串数量(一次要拼接多个字符串)
Benchmark-字符串长度变化
func BenchmarkByOperationAdd(b *testing.B) {
for i := 0; i < b.N; i++ {
ByOperationAdd(sa, sb)
}
b.ReportAllocs()
}
func BenchmarkByFmtSprintf(b *testing.B) {
for i := 0; i < b.N; i++ {
ByFmtSprintf(sa, sb)
}
b.ReportAllocs()
}
func BenchmarkByBytesBuffer(b *testing.B) {
for i := 0; i < b.N; i++ {
ByBytesBuffer(sa, sb)
}
b.ReportAllocs()
}
func BenchmarkByStringBuilder(b *testing.B) {
for i := 0; i < b.N; i++ {
ByStringBuilder(sa, sb)
}
b.ReportAllocs()
}
func BenchmarkByStringJoin(b *testing.B) {
for i := 0; i < b.N; i++ {
ByStringJoin(sa, sb)
}
b.ReportAllocs()
}
case1:长度相同
sa
和sb
都是test,长度为4
性能来看,+
> strings.Builder > strings.Join> bytes.Buffer > fmt.Sprintf
从短的字符串来看,直接加是完胜其他方法的。strings.Join和strings.Builder是相同的,是因为在写入第二个string的时候,第一个string写入的时候会扩容2倍,把第二个string的空间也分配好了,所以就不需要再此扩容。
bytes.Buffer虽然看起来也挺快的,不过因为实现写入和读出,所以内存分配会比较多,当然又会比fmt.Sprintf来得快,fmt.Sprintf为了实现不同类型,自然会处理得比较繁琐。
这里可以看到+
是没做内存分配的,看代码上是有,那个内存分配,会根据字符串长度来决定的。如果够小,go的内存分配可以通过内存的cache去分配,这个cache是在程序跑起来的时候就已经分配好的了。
case2:长度超过扩容大小
sa
和sb
分别是test和testt,长度为4和5
这个case主要是为了比较strings.Builder和strings.Join,case1的字符串是等长的,所以内存扩容的时候已经分配好第二个字符串的内存。现在这个例子的第二个string长度是5,因此,strings.Builder就会再扩容一次,所以会有2次alloc。如源码那边讲到的,strings.Join一次性会分配好,内部就做了Grow,所以它还是需要1次alloc。自然的,2次alloc一定比1次alloc来得快(在当下这个例子),性能就比strings.Join来得差了。
+
> strings.Join > strings.Builder > bytes.Buffer > fmt.Sprintf
case3:长度16和17
sa
和sb
分别是testtesttesttest和testtesttesttestt,长度16和17
这个例子主要是测试+
,什么时候会分配空间,是到最后字符串长度超过32的时候,就需要了。不过,虽然需要分配长度,可以看到的是,性能上还是+
来得最好
+
≈ strings.Join > strings.Builder > bytes.Buffer > fmt.Sprintf
case4:拼接多个字符串
拼接多个字符串的做法,就是循环加或者write 100次。
var (
ma = "test"
)
func BenchmarkByOperationAddMulti(b *testing.B) {
for i := 0; i < b.N; i++ {
ByOperationAddMulti(ma)
}
b.ReportAllocs()
}
func BenchmarkByBytesBufferMulti(b *testing.B) {
for i := 0; i < b.N; i++ {
ByBytesBufferMulti(ma)
}
b.ReportAllocs()
}
func BenchmarkByStringBuilderMulti(b *testing.B) {
for i := 0; i < b.N; i++ {
ByStringBuilderMulti(ma)
}
b.ReportAllocs()
}
func BenchmarkByStringJoinMulti(b *testing.B) {
for i := 0; i < b.N; i++ {
ByStringJoinMulti(ma)
}
b.ReportAllocs()
}
可以看到数据发生了巨变,由于这个场景不适合fmt.Sprintf所以就没做了。先看下性能
strings.Builder > bytes.Buffer > strings.Join > +
先注意一下,正常来说strings.Join应该是和strings.Builder差不多的,这里会这样应该是要先生成长度为100的切片,所以会比较慢。
这个case旨在测试长字符串拼接的性能,明显可以看到刚才在短字符称霸的+
,到这里就不行了。每次都要分配内存空间,甚至优于字符串长度的递增,会改变获取内存的方式,让+
在多字符串拼接上明显占下风。strings.Builder在这个例子中就占据优势,虽然需要扩容,但是要知道扩容是扩容2倍(到一定长度后是0.25倍),因此不是每次都要扩容。另外,最后取String()的方法是内存0拷贝,所以性能上会来得更快。
case5: write前先Grow
func BenchmarkByBytesBufferWithGrow(b *testing.B) {
for i := 0; i < b.N; i++ {
ByBytesBufferWithGrow(sa, sb)
}
b.ReportAllocs()
}
func BenchmarkByStringBuilderWithGrow(b *testing.B) {
for i := 0; i < b.N; i++ {
ByStringBuilderWithGrow(sa, sb)
}
b.ReportAllocs()
}
func BenchmarkByStringJoin2(b *testing.B) {
for i := 0; i < b.N; i++ {
ByStringJoin(sa, sb)
}
b.ReportAllocs()
}
这个case主要是针对bytes.Buffer,strings.Builder和strings.Join,这里面只有strings.Join有做Grow,也有可能因为只有它能够知道拼接完后的字符串长度。
跟case2做比较,一次性分配好内存后,性能会不会有所改变。
很明显的是,strings.Builder的性能明显比case2的时候好,也比strings.Join来得好了。bytes.Buffer虽然还是比另外2个慢,但也因为先分配好,性能明显比case2时有提升(数据上显示比case2慢,是因为里面有在创建[]byte)。
总结
少字符串和长字符串拼接 +
> strings.Buider > strings.Join > bytes.Buffer > fmt.Sprintf (当Join空的时候,是 ≈)
多字符串拼接 strings.Builder ≈ strings.Join > bytes.Buffer > +
当然每个数据类型其实有自己的应用场景,我们这便聊的是单纯字符串拼接的,其他数据类型在别的场景下其实很好用:
- fmt.Sprintf在处理不同类型的数据类型转为string的时候是很方便的
- bytes.Buffer是当做可读可写的媒介来存储数据的,其他的方法都没有实现读
这个文档也是因为,业务写多了,不想一直局限在写业务上面,所以想说看下这种普通平常的业务逻辑,是不是有其他的实现方式。如果一直写业务,没有去思考,那永远都是在当码农,但是回去提炼,回去把这种普通的东西研究研究,说不定能力就能提升。
本文更多是自己的一些理解,文中有问题的欢迎提出来交流。