go字符串拼接方式及性能比拼

在golang中字符串的拼接方式有多种,本文将会介绍比较常用的几种方式,并且对各种方式进行压测,以此来得到在不同场景下更适合使用的方案。

文章目录

    • 1、go字符串的几种拼接方式
      • 1.1 `fmt.Sprintf`
      • 1.2 `+运算符拼接`
      • 1.3 `strings.Join`
      • 1.4 `strings.Builder`
      • 1.5 `bytes.Buffer`
    • 2、性能测试
    • 3、源码分析
      • 3.1 +拼接
      • 3.2 strings.Builder
      • 3.3 strings.Join
      • 3.4 bytes.Buffer
      • 3.5 fmt.Sprintf
    • 总结:
    • 压测代码:

1、go字符串的几种拼接方式

比如对于三个字符串,s1、s2、s3,需要将其拼接为一个字符串,有如下的几种方式:

1.1 fmt.Sprintf

s := fmt.Sprintf("%s%s%s", s1, s2, s3)

1.2 +运算符拼接

s := s1 + s2 + s3

1.3 strings.Join

s := strings.Join([]string{s1, s2, s3}, "")

1.4 strings.Builder

builder := strings.Builder{}
builder.WriteString(s1)
builder.WriteString(s2)
builder.WriteString(s3)
s := builder.String()

1.5 bytes.Buffer

buffer := bytes.Buffer{}
buffer.WriteString(s1)
buffer.WriteString(s2)
buffer.WriteString(s3)
s := buffer.String()

 

2、性能测试

上面介绍了5种字符串的拼接方式,那么它们的性能如何呢,接下来将对这五种字符串拼接进行一个性能测试:

go版本:go1.21.0

如下为性能测试的结果,代码将在最后面给出,总共有八种,分别为:

1.fmt.Sprintf

2.+

2.使用for循环和+拼接

4.strings.join

5.strings.Builder

6.strings.Builder(先使用Grow扩容)

7.bytes.Buffer

8.bytes.Buffer(先使用Grow扩容)

 

性能测试的结果如下(仅供参考):

拼接的字符串数量:3, 字符串长度:10, 性能如下

go字符串拼接方式及性能比拼_第1张图片

当字符串数量和长度较小时,性能从高到低:

+拼接 > strings.Builder(先Grow) > strings.Join > bytes.Buffer > bytes.Buffer(先Grow) > strings.Builder > +拼接(使用for循环) > fmt.Sprintf

 

拼接的字符串数量:5, 字符串长度:128, 性能如下

go字符串拼接方式及性能比拼_第2张图片

当字符串数量较多和长度较大时,性能从高到低:

strings.Builder(先Grow) > +拼接 > strings.Join > bytes.Buffer(先Grow) > fmt.Sprintf > strings.Builder > +拼接(使用for循环) > bytes.Buffer

从上面的压测来看,直接使用+拼接字符串和使用strings.Builder(需要先grow)以及使用strings.Join的性能都是不错的。
上面有几个重点需要关注的点:

1. 当字符串数量较少长度较小时,使用+来拼接字符串的效率非常高并且内存分配次数为0(栈内存分配)
2. 当字符串数量较少长度较小时,bytes.Grow使用和不使用区别不大 (bytes.Buffer的最小扩容容量为64)
3. fmt.Sprintf的内存分配次数最多(涉及大量的interface{}操作,导致逃逸)

接下来将从源码的角度来分析它们的性能

3、源码分析

注意:go的版本为1.21.0

3.1 +拼接

       如果从感觉上来讲,我们通常会认为使用+来拼接字符串肯定是最低效的,因为会有多次字符串的拷贝,结果不然,接下来从源码的角度进行分析,看为什么使用+来拼接字符串的效率是非常高的:

源码位于runtime/string.go下:

concatstrings实现了go的字符串+拼接,所有的字符串会被放入一个字符串切片中,并且会传入一个大小为32字节的字符数组。

如果拼接后的字符串长度较小并且不会发生逃逸,那么就会在栈上创建出大小为32字节的字符数组。

步骤如下:

  1. 首先计算拼接后的字符串的长度;
  2. 如果编译器可以确定拼接后的字符串不会发生逃逸,buf就不为nil,如果buf不为nil并且buf可以存放下拼接后的字符串,就使用buf
  3. 如果buf为nil或者大小不足,则会在堆上申请出一片可以存放下拼接后的字符串的空间,然后将字符串一个一个拷贝过去
// The constant is known to the compiler.
// There is no fundamental theory behind this number.
const tmpStringBufSize = 32

type tmpBuf [tmpStringBufSize]byte

// concatstrings implements a Go string concatenation x+y+z+...
func concatstrings(buf *tmpBuf, a []string) string {
	// 首先计算出拼接后的字符串的长度
    idx := 0
	l := 0
	count := 0
	for i, x := range a {
		n := len(x)
		if n == 0 {
			continue
		}
		if l+n < l {
			throw("string concatenation too long")
		}
		l += n
		count++
		idx = i
	}	
	if count == 0 {
		return ""
	}

    // 如果只有一个字符串并且它不在栈上或者我们的结果没有转义调用帧(但是f != nil),那么我们可以直接返回该字符串。
	if count == 1 && (buf != nil || !stringDataOnStack(a[idx])) {
		return a[idx]
	}
    
	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) {
    // 如果buf不为nil而且buf可以存放下拼接后的字符串,就直接使用buf
	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)
	return unsafe.String((*byte)(p), size), unsafe.Slice((*byte)(p), size)
}

通过上面的源码分析,可以得知,使用直接使用+拼接字符串会先申请出一片内存,然后将字符串一个一个拷贝过去,并且字符串有可能分配在栈上,因此效率非常高。

但是在使用for循环来拼接时,由于编译器无法确定最终的内存空间大小,因此会发生多次拷贝,效率很低。

当字符串比较小并且数量是已知的时,使用+拼接字符串的效率很高,并且代码可读性更好。

3.2 strings.Builder

除了使用+来拼接字符串,通常string.Builder使用的也是非常多的,并且它的效率相比也是更高的,接下来看一下Builder的实现

在Builder中有一个字节切片的buf,每次在写入时都会追加到buf中,当buf容量不足时,切片会自动扩容,但是在扩容时会拷贝旧的切片,因此如果预先使用Grow来分配内存,则可以减少扩容时的拷贝开销,从而提高效率。

另一个高效的原因是在使用String()获取字符串时直接共用了切片的底层存储数组,从而减少了一次数据的拷贝。因此Builder的所有api都是只能追加,不能修改的。

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

func (b *Builder) WriteString(s string) (int, error) {
	b.copyCheck()
	b.buf = append(b.buf, s...)
	return len(s), nil
}

func (b *Builder) grow(n int) {
	buf := bytealg.MakeNoZero(2*cap(b.buf) + n)[:len(b.buf)]
	copy(buf, b.buf)
	b.buf = buf
}

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)
	}
}

// 返回的string和buf共用了同一片底层字符数组,减少了数据拷贝
func (b *Builder) String() string {
	return unsafe.String(unsafe.SliceData(b.buf), len(b.buf))
}

strings.Builder在获取字符串时返回的string和buf共用同一片字符数组,因此减少了一次数据拷贝。在使用时,使用grow预先分配内存可以减少切片扩容时的数据拷贝,提高性能,因此建议先使用Grow进行预分配

3.3 strings.Join

在上面的性能测试中,Join的性能也很高,因为strings.join本身使用了strings.Builder,并且在拼接字符串之前使用Grow进行了内存预分配,因此效率也很高。

代码很简单,就不再介绍。

3.4 bytes.Buffer

bytes.Buffer和strings.Builder比较相似,但是通常用于处理字节数据,而不是字符串。一个区别就是在使用String()方法来获取字符串时,有一次切片到字符串的拷贝,因此效率不如strings.Buffer
但是当字符串长度较小时,bytes.Buffer的效率甚至比strings.Buffer要高。是因为,Builder的扩容是按照切片的扩容策略来的,而Buffer的初始最小扩容大小为64,也就是第一次扩容最小大小为64,因此使用Grow和不使用的区别不大。

func (b *Buffer) String() string {
	if b == nil {
		// Special case, useful in debugging.
		return ""
	}
	return string(b.buf[b.off:])
}

const smallBufferSize = 64

func (b *Buffer) grow(n int) int {
	...
	
	if b.buf == nil && n <= smallBufferSize {
		b.buf = make([]byte, n, smallBufferSize)
		return 0
	}
	...
}

3.5 fmt.Sprintf

fmt.Sprintf的实现较为复杂,并且使用了大量的interface{},会导致内存逃逸,涉及到多次内存分配,效率较低。如果是纯字符串,通常不会使用fmt.Sprintf来进行拼接,fmt.Sprintf可以对多种数据格式进行字符串格式化。

总结:

1.当要拼接的多个字符串是已知并且数量较少时,可以直接使用+来拼接,效率比较高而且可读性更好

2、当要拼接的字符串数量和长度未知时,可以使用strings.Builder来拼接,并且预估字符串的大小使用Grow进行预分配,效率较高

3、当要拼接的字符串数量已知或者在拼接时需要加入分割字符串时,可以使用strings.Join,效率较高,也很方便

4、在进行字节数据处理时可以使用bytes.Buffer

5、当要对包含多种格式的数据进行字符串格式化时使用fmt.Sprintf,更加方便

 
 

压测代码:

package string_concats

import (
	"bytes"
	"fmt"
	"math/rand"
	"strings"
	"testing"
	"time"
)

const dic = "qwertyuioplkjhgfdsazxcvbnmMNBVCXZASDFGHJKLPOIUYTREWQ0123456789"

var defaultRand = rand.New(rand.NewSource(time.Now().UnixNano()))

func RandString(n int) string {
	builder := strings.Builder{}
	builder.Grow(n)
	for i := 0; i < n; i++ {
		n := defaultRand.Intn(len(dic))
		builder.WriteByte(dic[n])
	}
	return builder.String()
}

var (
	strs []string
	N    = 5
	Len  = 128
)

func init() {
	for i := 0; i < N; i++ {
		strs = append(strs, RandString(Len))
	}
}

// fmt.Sprintf
func BenchmarkSprintf(b *testing.B) {
	for i := 0; i < b.N; i++ {
		_ = fmt.Sprintf("%s%s%s%s%s", strs[0], strs[1], strs[2], strs[3], strs[4])
	}
}

// s1 + s2 + s3
func BenchmarkConcat(b *testing.B) {
	for i := 0; i < b.N; i++ {
		_ = strs[0] + strs[1] + strs[2] + strs[3] + strs[4]
	}
}

// for循环+
func BenchmarkForConcat(b *testing.B) {
	for i := 0; i < b.N; i++ {
		var s string
		for i := 0; i < len(strs); i++ {
			s += strs[i]
		}
	}
}

// strings.Join
func BenchmarkJoin(b *testing.B) {
	for i := 0; i < b.N; i++ {
		_ = strings.Join(strs, "")
	}
}

// strings.Builder
func BenchmarkBuilder(b *testing.B) {
	for i := 0; i < b.N; i++ {
		builder := strings.Builder{}
		for i := 0; i < len(strs); i++ {
			builder.WriteString(strs[i])
		}
		_ = builder.String()
	}
}

// strings.Builder
func BenchmarkBuilderGrowFirst(b *testing.B) {
	for i := 0; i < b.N; i++ {
		builder := strings.Builder{}
		n := 0
		for i := 0; i < len(strs); i++ {
			n += len(strs[i])
		}
		builder.Grow(n)
		for i := 0; i < len(strs); i++ {
			builder.WriteString(strs[i])
		}
		_ = builder.String()
	}
}

// bytes.Buffer
func BenchmarkBuffer(b *testing.B) {
	for i := 0; i < b.N; i++ {
		buffer := bytes.Buffer{}
		for i := 0; i < len(strs); i++ {
			buffer.WriteString(strs[i])
		}
		_ = buffer.String()
	}
}

// bytes.Buffer
func BenchmarkBufferGrowFirst(b *testing.B) {
	for i := 0; i < b.N; i++ {
		buffer := bytes.Buffer{}
		n := 0
		for i := 0; i < len(strs); i++ {
			n += len(strs[i])
		}
		buffer.Grow(n)
		for i := 0; i < len(strs); i++ {
			buffer.WriteString(strs[i])
		}
		_ = buffer.String()
	}
}

你可能感兴趣的:(Golang,go,Golang源码探究,golang,go)