Go字符串拼接-源码+Benchmark

想要写这个的目的在于,平时开发业务的时候会用到比较多的字符串拼接,刚好看了一些源码,就想说通过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拷贝,bs的指针指向的是同一个地址。

可以看到的是,+不需要类型转换也就不需要内存拷贝,且只需要一次内存分配。+的实现就是分配一个空间,然后把两个字符串都"移动"过去。因此,也可以预见的就是,每次拼接,每次都要进行一次内存分配。

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:长度相同

sasb都是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:长度超过扩容大小

sasb分别是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

sasb分别是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是当做可读可写的媒介来存储数据的,其他的方法都没有实现读

这个文档也是因为,业务写多了,不想一直局限在写业务上面,所以想说看下这种普通平常的业务逻辑,是不是有其他的实现方式。如果一直写业务,没有去思考,那永远都是在当码农,但是回去提炼,回去把这种普通的东西研究研究,说不定能力就能提升。


本文更多是自己的一些理解,文中有问题的欢迎提出来交流。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值