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

 更新时间:2024年01月09日 11:50:27   作者:CoreDump丶  
在golang中字符串的拼接方式有多种,本文将会介绍比较常用的几种方式,并且对各种方式进行压测,具有一定的参考价值,感兴趣的可以了解一下

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

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.+

3.使用for循环和+拼接

4.strings.join

5.strings.Builder

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

7.bytes.Buffer

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

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

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

在这里插入图片描述

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

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

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

在这里插入图片描述

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

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字节的字符数组。

步骤如下:

  • 首先计算拼接后的字符串的长度;
  • 如果编译器可以确定拼接后的字符串不会发生逃逸,buf就不为nil,如果buf不为nil并且buf可以存放下拼接后的字符串,就使用buf
  • 如果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 "<nil>"
	}
	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()
	}
}

到此这篇关于go字符串拼接方式及性能比拼小结的文章就介绍到这了,更多相关go字符串拼接内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家! 

相关文章

  • Golang中runtime的使用详解

    Golang中runtime的使用详解

    这篇文章主要介绍了Golang中runtime的使用详解,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧
    2019-08-08
  • Golang分布式应用之Redis示例详解

    Golang分布式应用之Redis示例详解

    这篇文章主要为大家介绍了Golang分布式应用之Redis示例详解,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪
    2022-07-07
  • GO开发编辑器安装图文详解

    GO开发编辑器安装图文详解

    这篇文章主要介绍了GO开发编辑器安装,本文给大家介绍的非常详细,对大家的学习或工作具有一定的参考借鉴价值,需要的朋友可以参考下
    2021-01-01
  • Go 语言结构体链表的基本操作

    Go 语言结构体链表的基本操作

    链表是一种物理存储单元上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的,这篇文章主要介绍了Go 语言结构体链表,需要的朋友可以参考下
    2022-04-04
  • 详解Go语言如何实现一个最简化的协程池

    详解Go语言如何实现一个最简化的协程池

    这篇文章主要为大家详细介绍了Go语言如何实现一个最简化的协程池,文中的示例代码讲解详细,具有一定的参考价值,有需要的小伙伴可以了解一下
    2023-10-10
  • Golang优雅保持main函数不退出的办法

    Golang优雅保持main函数不退出的办法

    很多时候我们需要让main函数不退出,让它在后台一直执行,下面这篇文章主要给大家介绍了关于Golang优雅保持main函数不退出的相关资料,文中通过实例代码介绍的非常详细,需要的朋友可以参考下
    2022-07-07
  • Go语言使用net/http实现简单登录验证和文件上传功能

    Go语言使用net/http实现简单登录验证和文件上传功能

    这篇文章主要介绍了Go语言使用net/http实现简单登录验证和文件上传功能,使用net/http模块编写了一个简单的登录验证和文件上传的功能,在此做个简单记录,需要的朋友可以参考下
    2023-07-07
  • Go语言开发代码自测绝佳go fuzzing用法详解

    Go语言开发代码自测绝佳go fuzzing用法详解

    这篇文章主要为大家介绍了Go语言开发代码自测绝佳go fuzzing用法详解,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪
    2022-06-06
  • Go语言实现顺序存储的线性表实例

    Go语言实现顺序存储的线性表实例

    这篇文章主要介绍了Go语言实现顺序存储的线性表的方法,实例分析了Go语言实现线性表的定义、插入、删除元素等的使用技巧,具有一定参考借鉴价值,需要的朋友可以参考下
    2015-03-03
  • Golang对mongodb进行聚合查询详解

    Golang对mongodb进行聚合查询详解

    这篇文章主要为大家详细介绍了Golang对mongodb进行聚合查询的方法,文中的示例代码讲解详细,对我们学习Golang有一点的帮助,需要的可以参考一下
    2023-02-02

最新评论