关于golang:Go语言几种字符串的拼接方式比较

9次阅读

共计 7491 个字符,预计需要花费 19 分钟才能阅读完成。

背景介绍

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

而在咱们 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 公布!

正文完
 0