背景介绍

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

而在咱们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的测试后果。

从这次的测试后果来看,咱们能够初步得出以下论断

  • 咱们间接应用“+”号拼接是耗时最多,内存耗费最大的操作,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来查看测试后果:

而后咱们将slice的长度改成2,查看benchmark的后果:

最初咱们通过屡次测试,发现确实是每次迭代都会有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界面来查看具体采集的数据内容,咱们顺次依照图示的红框点击

失去的最终url是:http://127.0.0.1:8080/ui/top?...

这时,咱们看到如图内容:

咱们看到,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查看测试后果

忽然发现和最开始意料的如同有些出入,应用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查看测试后果

这时能够看进去。两次执行的状况简直就差不多了。

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

下面的测试都是基于较大的slice进行拼接字符串,那如果咱们有一个较小的slice须要拼接呢,应用这五种办法哪个效率更高呢?我抉择一个长度为2的slice进行拼接。

由此能够看出,应用strings包进行拼接的效率还是较为显著的,但和间接“+”拼接的效率就比拟相近了。

5 总结

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

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

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

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