Go语言几种字符串的拼接方式比较

1,133 阅读3分钟

背景介绍

在我们实际开发过程中,不可避免的要进行一些字符串的拼接工作。比如将一个数组按照一定的标点符号拼接成一个句子、将两个字段拼接成一句话等。

而在我们Go语言中,对于字符串的拼接处理有许多种方法,我们最常见的可能是直接用“+”加号进行拼接,或者使用join处理切片,再或者使用fmt.Sprintf("")去组装数据。

那么这就有个问题,我们如何高效的使用字符串的拼接,在线上高并发的场景下,不同的字符串拼接方法对性能的影响又有多大?

下面我将对Go语言中常见的几种字符串的拼接方法进行测试,分析每个方法的性能如何。

0 准备工作

为了测试各个方法的实际效果,本文将采用benchmark来测试,这里仅对benchmark做一个简单的介绍,后续将会出一篇文章对benchmark进行详细的介绍。

benchmark是Go自带的测试利器,使用benchmark我们可以方便快捷的测试一个函数方法在串行和并行环境下的表现,指定一个时间(默认测试1秒),看被测方法在达到这个时间上限所能执行的次数和内存分配情况。

benchmark的常用API有如下几种:

// 开始计时
b.StartTimer() 
// 停止计时
b.StopTimer() 
// 重置计时
b.ResetTimer()
b.Run(name string, f func(b *B))
b.RunParallel(body func(*PB))
b.ReportAllocs()
b.SetParallelism(p int)
b.SetBytes(n int64)
testing.Benchmark(f func(b *B)) BenchmarkResult

本文主要用的是以下三种

b.StartTimer()   // 开始计时   
b.StopTimer()    // 停止计时   
b.ResetTimer()   // 重置计时   

在编写完成测试文件后,执行命令go test -bench=. -benchmem 可以执行测试文件,并显示内存

1 构建测试用例

这里我在测试文件里会有一个全局的slice,用来做拼接的原始数据集。

var StrData = []string{"Go语言高效拼接字符串"}

然后使用在init函数里进行数据组装,把这个全局的slice变大,同时可以控制较大的slice的拼接和较小的slice拼接有什么区别。

func init() {
    for i := 0; i < 200; i++ {
        StrData = append(StrData, "Go语言高效拼接字符串")
    }
}

1.1 “+”直接拼接

func StringsAdd() string {
    var s string
    for _, v := range StrData {
        s += v
    }
    return s
}
// 测试方法
func BenchmarkStringsAdd(b *testing.B) {
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        StringsAdd()
    }
    b.StopTimer()
}

1.2 使用fmt包进行组装

func StringsFmt() string {
    var s string = fmt.Sprint(StrData)
    return s
}

// 测试方法
func BenchmarkStringsFmt(b *testing.B) {
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        StringsFmt()
    }
    b.StopTimer()
}

1.3 使用strings包的join方法

func StringsJoin() string {
    var s string = strings.Join(StrData, "")
    return s
}

// 测试方法
func BenchmarkStringsJoin(b *testing.B) {
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        StringsJoin()
    }
    b.StopTimer()
}

1.4 使用bytes.Buffer拼接

func StringsBuffer() string {
    var s bytes.Buffer
    for _, v := range StrData {
        s.WriteString(v)
    }
    return s.String()
}
// 测试方法
func BenchmarkStringsBuffer(b *testing.B) {
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        StringsBuffer()
    }
    b.StopTimer()
}

1.5 使用strings.Builder拼接

func StringsBuilder() string {
    var b strings.Builder
    for _, v := range StrData {
        b.WriteString(v)
    }
    return b.String()
}
// 测试方法
func BenchmarkStringsBuilder(b *testing.B) {
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        StringsBuilder()
    }
    b.StopTimer()
}

2 测试结果及分析

2.1 使用benchmark运行测试,查看结果

接下来执行:go test -bench=. -benchmem 命令来获取benchmark的测试结果。

file

从这次的测试结果来看,我们可以初步得出以下结论

  • 我们直接使用“+”号拼接是耗时最多,内存消耗最大的操作,b.N周期内的每次迭代,都会进行内存分配;
  • 使用Strings.Join方法进行拼接的执行的次数最多,代表耗时最小,而内存的分配次数最少,每次迭代分配的内存也是最少的;
  • 使用Strings.Builder类型进行拼接,每次迭代都额外分配了13次内存,性能并没有明显的优势,可是Go从1.10开始新增了这个类型,并开始逐步使用呢?

以上结论看起来好像是有那么点意思,但是否正确呢?我们不妨先不着急,从这五种拼接方式里挨着每个参数解释看,是否和预期吻合。

2.2 “+”号拼接的结果分析

从上面的测试用例来看,我们将一个slice循环了200次,对其append了200个元素,加上自己本身的一个元素,得到了一个201长度的slice。而我们将将这个切片循环拼接字符串的结果就是循环了201,从benchmark的最后一列看,显示内存“额外”分配了200次,除去初始分配的内存外,正好符合我们平时所熟知的:使用“+”拼接字符串,会重新分配内存。

而使用+号拼接,平均每次分配的内存和耗时都是最大的,因此执行总次数也是最少的。

那么,我们进行大文本拼接和小文本拼接,又会有什么不同呢?后面我会进行小文本拼接的测试。

2.3 fmt包进行拼接的结果分析

我们看到fmt包进行拼接的结果是仅次于“+”号拼接,但内存重新分配的次数却大于200次,这就有些奇怪了,是什么情况导致了额外分配内存的次数,是不是每次迭代都会分配3次内存呢?我们来做个试验:

我们先将slice的长度改成1,查看是否还会有额外内存分配的情况存在,同样使用benchmark来查看测试结果:

file

然后我们将slice的长度改成2,查看benchmark的结果: file

最后我们经过多次测试,发现的确是每次迭代都会有3次额外内存的分配情况,那么,这三次的内存分配是出在什么地方呢?

我们将benchmark测试的结果输出到文件,并使用pprof来查看,使用如下命令:

# 使用benchmark采集3秒的数据,并生成文件
go test -bench=. -benchmem  -benchtime=3s -memprofile=mem_profile.out
# 查看pprof文件,指定http方式查看
go tool pprof -http="127.0.0.1:8080" mem_profile.out

执行后会使用默认浏览器开启打开一个web界面来查看具体采集的数据内容,我们依次按照图示的红框点击 file file

得到的最终url是:http://127.0.0.1:8080/ui/top?si=alloc_space

这时,我们看到如图内容: file

我们看到,fmt.Sprint方法会有这三个内存的分配。

2.4 使用strings.Join方法进行拼接的结果分析

从不上面的测试内容来看,使用strings.Join方法是实现效果最好的方法,耗时是最低的,内存占用也最低,额外内存分配次数也只有1次,我们查看strings.Join的方法内部的实现代码。

// Join concatenates the elements of its first argument to create a single string. The separator
// string sep is placed between elements in the resulting 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)
    b.WriteString(elems[0])
    for _, s := range elems[1:] {
        b.WriteString(sep)
        b.WriteString(s)
    }
    return b.String()
}

从第15行可以看出,Join方法也使用了strings包里的builder类型。后面会单独对比自己写的strings.Builder和Join方法内部的效果为什么不一样。

2.5 使用bytes.Buffer方法进行拼接的结果分析

之前从网上多处看到有人推荐使用bytes.Buffer,bytes.buffer是一个缓冲byte类型的缓冲器,这个缓冲器里存放着都是byte。buffer的结构体定义如下:

// A Buffer is a variable-sized buffer of bytes with Read and Write methods.
// The zero value for Buffer is an empty buffer ready to use.
type Buffer struct {
    buf      []byte // contents are the bytes buf[off : len(buf)]
    off      int    // read at &buf[off], write at &buf[len(buf)]
    lastRead readOp // last read operation, so that Unread* can work correctly.
}

在Go 1.10以前,使用buffer无疑是一个较为高效的选择。使用var b bytes.Buffer 存放最终拼接好的字符串,一定程度上避免上面 string 每进行一次拼接操作就重新申请新的内存空间存放中间字符串的问题。但其仍然存在一个[]byte -> string类型转换和内存拷贝的问题。

2.6 使用strings.Builder方法进行拼接的结果分析

在Go 1.10开始,Go官方将strings.Builder作为一个feature引入,其能较大程度的提高字符串拼接的效率,下面贴出来部分代码:

// 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 {
        // 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")
    }
}

// String returns the accumulated string.
func (b *Builder) String() string {
    return *(*string)(unsafe.Pointer(&b.buf))
}


// WriteString appends the contents of s to b's buffer.
// It returns the length of s and a nil error.
func (b *Builder) WriteString(s string) (int, error) {
    b.copyCheck()
    b.buf = append(b.buf, s...)
    return len(s), nil
}

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

为了解决bytes.Buffer.String()存在的[]byte -> string类型转换和内存拷贝问题,这里使用了一个unsafe.Pointer的内存指针转换操作,实现了直接将buf []byte转换为 string类型,同时避免了内存充分配的问题。而且标准库还实现了一个copyCheck方法,可以比较hack的代码来避免buf逃逸到堆上。

前面我们提到,使用string.Join进行字符串拼接,其底层就是使用的strings.Builder来处理数据,但为什么benchmark的结果却相差甚远,下面将对这两种方法进行比较。

3 strings.Builder和strings.Join的比较

为了比较这两种方法的效率,我再次贴出两种方法比较代码。

strings.Join的关键代码:

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)
    b.WriteString(elems[0])
    for _, s := range elems[1:] {
        b.WriteString(sep)
        b.WriteString(s)
    }
    return b.String()
}

我自己写的strings.Builder拼接方法:

func StringsBuilder() string {

    var b strings.Builder
    for _, v := range StrData {
        b.WriteString(v)
    }

    return b.String()
}

这里我发现在Join方法的第14行有个b.Grow(n)的操作,这个是进行初步的容量分配,而前面计算的n的长度就是我们要拼接的slice的长度,这时候就尝试将自己写的拼接方法也添加一个内存分配的方法进行比较试试。

func StringsBuilder() string {
    
    n := len("") * (len(StrData) - 1)
    for i := 0; i < len(StrData); i++ {
        n += len(StrData[i])
    }

    var b strings.Builder
    b.Grow(n)
    b.WriteString(StrData[0])
    for _, s := range StrData[1:] {
        b.WriteString("")
        b.WriteString(s)
    }
    return b.String()
}

再次执行benchmark检查测试结果

file

突然发现和最开始预料的好像有些出入,使用Strings.Builder的更有优势,这又是为何呢?仔细一想, strings.Join()方法进行了传参,而传参会不会造成这个差距的原因呢?下面我也给StringsBuilder这个方法进行参数传递。

func StringsBuilder(strData []string,sep string) string {
    
    n := len(sep) * (len(strData) - 1)
    for i := 0; i < len(strData); i++ {
        n += len(strData[i])
    }

    var b strings.Builder
    b.Grow(n)
    b.WriteString(strData[0])
    for _, s := range strData[1:] {
        b.WriteString(sep)
        b.WriteString(s)
    }
    return b.String()
}

再次执行benchmark检查测试结果 file 这时可以看出来。两次执行的情况几乎就差不多了。

4 构建小字符串测试及分析

上面的测试都是基于较大的slice进行拼接字符串,那如果我们有一个较小的slice需要拼接呢,使用这五种方法哪个效率更高呢?我选择一个长度为2的slice进行拼接。 file 由此可以看出,使用strings包进行拼接的效率还是较为明显的,但和直接“+”拼接的效率就比较相近了。

5 总结

以上是经常用的五种字符串拼接的方式效率的比较,官方是建议使用strings.Builder的方式,但也不得不说根据业务场景的不同,方式就变得较为灵活,如果只是两个字符串的拼接,直接使用“+”也未尝不可。但对于较多的字符串拼接的话,还是尽量使用strings.Builder方式。 而在使用strings.Join方法拼接slice的时候由于牵扯到参数的传递,效率也或多或少有些影响。

因此在较大的字符串拼接时,五种方式的拼接效率由高到低排序是:

strings.Builder ≈ strings.Join > strings.Buffer > "+" > fmt

本文由博客一文多发平台 OpenWrite 发布!