本文100%由本人(Haoxiang Ma)原创,如需转载请注明出处。

本文写于2019/02/16,基于Go 1.11
至于其他版本的Go SDK,如有出入请自行查阅其他资料。

Overview

写本文的动机来源于Golang中文社区里一篇有头没尾的帖子《Go语言字符串高效拼接》,里面只提了Golang里面字符串拼接的几种方式,但是在最后却不讲每种方式的性能,也没有给出任何的best practice。本着无聊 + 好奇心,就决定自行写benchmark来测试,再对结果和源码进行分析,试图给出我认为的best practice吧。

性能测试

根据帖子里的内容,在Golang里有5种字符串拼接的方式:

  • 直接+号拼接

    func (strs []string) string {
    s := ""
    for _, str := range strs {
    s += str
    }
    return s
    }
  • fmt.Sprint()拼接

    // fmt拼接
    func ConcatWithFmt(strs []string) string {
    s := fmt.Sprint(strs)
    return s
    }
  • strings.Join()拼接

    // strings.Join拼接
    func ConcatWithJoin(strs []string) string {
    s := strings.Join(strs, "")
    return s
    }
  • Buffer拼接

    // bytes.Buffer拼接
    func ConcatWithBuffer(strs []string) string {
    buf := bytes.Buffer{}
    for _, str := range strs {
    buf.WriteString(str)
    }
    return buf.String()
    }
  • Builder拼接

    // strings.Builder拼接
    func ConcatWithBuilder(strs []string) string {
    builder := strings.Builder{}
    for _, str := range strs {
    builder.WriteString(str)
    }
    return builder.String()
    }

为了测试各自的性能,就用Golang自带test模块的benchmark来进行测试。

在测试中,分3组数据,5组测试,即一共3 * 5 = 15次独立测试。其中3组数据是指:

  • size = 10K的字符串数组,每个元素均为"hello"
  • size = 50K的字符串数组,每个元素均为"hello"
  • size = 100K的字符串数组,每个元素均为"hello"

5组测试是指:

  • 直接+号拼接,要跑10K、50K、100K的数据
  • fmt.Sprint()拼接,要跑10K、50K、100K的数据
  • strings.Join()拼接,要跑10K、50K、100K的数据
  • Buffer拼接,要跑10K、50K、100K的数据
  • Builder拼接,要跑10K、50K、100K的数据

Benchmark代码如下:

package main

import (
"os"
"testing"
) var (
Strs10K []string // 长度为10K的字符串数组
Strs50K []string // 长度为50K的字符串数组
Strs100K []string // 长度为100K的字符串数组
word = "hello" // 待拼接的字符串
) const (
ADD = iota
BUFFER
BUILDER
JOIN
FMT _10K = 10000
_50K = 50000
_100K = 100000
) // preset和teardown
func TestMain(m *testing.M) {
Strs10K = make([]string, 0, _10K)
Strs50K = make([]string, 0, _50K)
Strs100K = make([]string, 0, _100K) for i := 0;i < _100K;i++ {
if (i < _10K) {
Strs10K = append(Strs10K, word)
Strs50K = append(Strs50K, word)
} else if (i < _50K) {
Strs50K = append(Strs50K, word)
}
Strs100K = append(Strs100K, word)
} exitCode := m.Run()
os.Exit(exitCode)
} // 测试直接+号拼接
func BenchmarkConcatWithAdd(b *testing.B) {
b.Run("Concat-10000", GetTestConcat(Strs10K, ADD))
b.Run("Concat-50000", GetTestConcat(Strs50K, ADD))
b.Run("Concat-100000", GetTestConcat(Strs100K, ADD))
} // 测试bytes.Buffer拼接
func BenchmarkConcatWithBuffer(b *testing.B) {
b.Run("Concat-10000", GetTestConcat(Strs10K, BUFFER))
b.Run("Concat-50000", GetTestConcat(Strs50K, BUFFER))
b.Run("Concat-100000", GetTestConcat(Strs100K, BUFFER))
} // 测试strings.Builder拼接
func BenchmarkConcatWithBuilder(b *testing.B) {
b.Run("Concat-10000", GetTestConcat(Strs10K, BUILDER))
b.Run("Concat-50000", GetTestConcat(Strs50K, BUILDER))
b.Run("Concat-100000", GetTestConcat(Strs100K, BUILDER))
} // 测试strings.Join拼接
func BenchmarkConcatWithJoin(b *testing.B) {
b.Run("Concat-10000", GetTestConcat(Strs10K, JOIN))
b.Run("Concat-50000", GetTestConcat(Strs50K, JOIN))
b.Run("Concat-100000", GetTestConcat(Strs100K, JOIN))
} // 测试fmt拼接
func BenchmarkConcatWithFmt(b *testing.B) {
b.Run("Concat-10000", GetTestConcat(Strs10K, FMT))
b.Run("Concat-50000", GetTestConcat(Strs50K, FMT))
b.Run("Concat-100000", GetTestConcat(Strs100K, FMT))
} // 根据拼接类型(testType),返回对应的测试方法
func GetTestConcat(strs []string, testType int) func(b *testing.B) {
concatFunc := func([]string) string {return ""}
switch testType {
case ADD:
concatFunc = ConcatWithAdd
case BUFFER:
concatFunc = ConcatWithBuffer
case BUILDER:
concatFunc = ConcatWithBuilder
case JOIN:
concatFunc = ConcatWithJoin
case FMT:
concatFunc = ConcatWithFmt
} return func(b *testing.B) {
for i := 0;i < b.N;i++ {
concatFunc(strs)
}
}
}

经过测试(go test -bench=. -benchmem),结果如下:

......
4 BenchmarkConcatWithAdd/Concat-10000-4 20 57050217 ns/op 270493320 B/op 9999 allocs/op
5 BenchmarkConcatWithAdd/Concat-50000-4 2 937660008 ns/op 6435464656 B/op 49999 allocs/op
6 BenchmarkConcatWithAdd/Concat-100000-4 1 3748714961 ns/op 25388918224 B/op 99999 allocs/op
7 BenchmarkConcatWithBuffer/Concat-10000-4 10000 138797 ns/op 209376 B/op 12 allocs/op
8 BenchmarkConcatWithBuffer/Concat-50000-4 3000 481466 ns/op 840160 B/op 14 allocs/op
9 BenchmarkConcatWithBuffer/Concat-100000-4 2000 966963 ns/op 1659360 B/op 15 allocs/op
10 BenchmarkConcatWithBuilder/Concat-10000-4 10000 103924 ns/op 227320 B/op 21 allocs/op
11 BenchmarkConcatWithBuilder/Concat-50000-4 3000 495917 ns/op 1431545 B/op 28 allocs/op
12 BenchmarkConcatWithBuilder/Concat-100000-4 2000 891950 ns/op 2930682 B/op 31 allocs/op
大专栏  [Golang]字符串拼接方式的性能分析/> 13 BenchmarkConcatWithJoin/Concat-10000-4 10000 106288 ns/op 114688 B/op 2 allocs/op
14 BenchmarkConcatWithJoin/Concat-50000-4 3000 505209 ns/op 507904 B/op 2 allocs/op
15 BenchmarkConcatWithJoin/Concat-100000-4 2000 990317 ns/op 1015808 B/op 2 allocs/op
16 BenchmarkConcatWithFmt/Concat-10000-4 1000 1293589 ns/op 227716 B/op 10002 allocs/op
17 BenchmarkConcatWithFmt/Concat-50000-4 200 6260637 ns/op 1131960 B/op 50003 allocs/op
18 BenchmarkConcatWithFmt/Concat-100000-4 100 12005780 ns/op 2499702 B/op 100006 allocs/op
......

可以看出

  • 运行速度上,Builder、Buffer、Join的速度属于同一数量级,绝对值也差不了太多;fmt要比它们一个数量级;直接+号拼接是最慢的。
  • 内存分配上,Join表现最优秀,Buffer次之,Builder第三;而fmt和直接+号拼接最差,要执行很多次内存分配操作。

源码分析

  • 速度&内存分配都很优秀的strings.Join()

    func Join(a []string, sep string) string {
    // 专门为短数组拼接做的优化
    // 详情查阅golang.org/issue/6714
    switch len(a) {
    case 0:
    return ""
    case 1:
    return a[0]
    case 2:
    return a[0] + sep + a[1]
    case 3:
    return a[0] + sep + a[1] + sep + a[2]
    } // 计算总共要插入多长的分隔符,n = 分隔符总长
    n := len(sep) * (len(a) - 1) // 遍历待拼接的数组,逐个叠加字符串的长度
    // 最后n = 分隔符总长 + 所有字符串的总长 = 拼接结果的总长
    for i := 0; i < len(a); i++ {
    n += len(a[i])
    } // 一次性分配n byte的内存空间,并且把第一个字符串拷贝到slice的头部
    b := make([]byte, n)
    bp := copy(b, a[0]) // 从下标为1开始,调用原生的copy函数
    // 逐个把分隔符&字符串拷贝到slice里对应的位置
    for _, s := range a[1:] {
    bp += copy(b[bp:], sep)
    bp += copy(b[bp:], s)
    } // 最后将byte slice强转为string,返回
    return string(b)
    }

    可以看出strings.Join()为什么表现如此优秀,主要原因是只有1次的显式内存分配(b := make([]byte, n))和1次隐式内存分配(return string(b),不需要在拼接过程中反复多次分配内存,挪动内存里的数据,减少了很多内存管理的消耗。

  • 略差一筹的bytes.Buffer.WriteString()

    // 尝试扩容n个单位
    func (b *Buffer) tryGrowByReslice(n int) (int, bool) {
    // 如果底层slice的剩余空间 >= n个单位,就不需要重新分配内存
    // 而是reslice,把底层slice的cap限定在l + n
    if l := len(b.buf); n <= cap(b.buf)-l {
    b.buf = b.buf[:l+n]
    return l, true
    } // 如果底层slice的剩余空间不足n个单位,放弃reslice
    // 说明需要重新分配内存,而不是reslice那么简单了
    return 0, false
    } // 扩容n个单位
    func (b *Buffer) grow(n int) int {
    m := b.Len() // 边界情况,空slice,先把一些属性reset掉
    if m == 0 && b.off != 0 {
    b.Reset()
    } // 先试试不真正分配空间,通过reslice来“扩容”
    if i, ok := b.tryGrowByReslice(n); ok {
    return i
    } // bootstrap是一个长度为64的slice,在buffer对象初始化时,
    // bootstrap就已经分配好了,如果n小于bootstrap长度,
    // 可以利用bootstrap slice来reslice,不需要重新分配内存空间
    if b.buf == nil && n <= len(b.bootstrap) {
    b.buf = b.bootstrap[:n]
    return 0
    } // 上述几种情况都无法满足
    c := cap(b.buf)
    if n <= c/2-m {
    // 理解为m + n <= c/2比较好
    // 如果扩容后的长度(m + n)比c/2要小,说明当前还有一大堆可用的空间
    // 直接reslice,以b.off打头
    copy(b.buf, b.buf[b.off:])
    } else if c > maxInt-c-n {
    // c + c + n > maxInt,申请扩容n个单位太多了,不可接受
    panic(ErrTooLarge)
    } else {
    // 当前剩余的空间不太够了,重新分配内存,长度为c + c + n
    buf := makeSlice(2*c + n)
    copy(buf, b.buf[b.off:])
    b.buf = buf
    }
    // Restore b.off and len(b.buf).
    b.off = 0
    b.buf = b.buf[:m+n]
    return m
    } // 拼接的方法
    func (b *Buffer) WriteString(s string) (n int, err error) {
    b.lastRead = opInvalid // 先尝试reslice得到len(s)个单位的空间
    m, ok := b.tryGrowByReslice(len(s))
    if !ok {
    // 无法通过reslice得到空间,直接粗暴地申请grow
    m = b.grow(len(s))
    }
    return copy(b.buf[m:], s), nil
    }

    为什么bytes.Buffer.WriteString()性能比Join差呢,其实也是内存分配策略惹的祸。在Join里只有两次内存空间申请的操作,而Buffer里可能会有很多次。具体来说就是buf := makeSlice(2*c + n)这一句,每次重申请只申请2 * c + n的空间,用完了就要再申请2 * c + n。当拼接的数据项很多,每次申请的空间也就2 * c + n,很快就用完了,又要再重新申请,所以造成了性能不是很高。

  • 略差一筹的strings.Builder()

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

    代码很简洁,就是最直白的slice append,一时append一时爽,一直append一直爽。所以当底层slice的可用空间不足,就会在append里一直申请新的内存空间。跟bytes.Buffer不同的是,这里并没有自己管理“扩容”的逻辑,而是交由原生的append函数去管理。

  • 最差劲的fmt.Sprint()

    type buffer []byte
    
    type pp struct {
    buf buffer
    ......
    } func Sprint(a ...interface{}) string {
    p := newPrinter()
    p.doPrint(a)
    s := string(p.buf)
    p.free()
    return s
    }

    printer里的核心数据结构就是buf,而buf其实就是一个[]byte,所以给buf不停地拼接字符串,空间不够了又继续开辟新的内存空间,所以性能低下。

总结

实际上,只有当拼接的字符串非常非常多的时候,才需要纠结性能。像本文里动辄拼接10K、50K、100K个字符串的情况在实际业务中应该是很少很少的。

如果实在要纠结性能,参考以下几点

  • Join的速度最好,但是不至于完爆Builder和Buffer。三者的速度属于同一数量级。fmt和直接+号拼接速度最慢。
  • Join的内存分配策略最好,内存分配次数最少;Builder和Buffer的内存分配策略还算可以,类似于线性增长;fmt和直接+号拼接的内存分配策略最差。

[Golang]字符串拼接方式的性能分析的更多相关文章

  1. Lua大量字符串拼接方式效率对比及原因分析

    Lua大量字符串拼接方式效率对比及原因分析_AaronChan的博客-CSDN博客_lua字符串拼接消耗 https://blog.csdn.net/qq_26958473/article/detai ...

  2. JS几种变量交换方式以及性能分析对比

    前言 "两个变量之间的值得交换",这是一个经典的话题,现在也有了很多的成熟解决方案,本文主要是列举几种常用的方案,进行大量计算并分析对比. 起由 最近做某个项目时,其中有一个需求是 ...

  3. JS几种数组遍历方式以及性能分析对比

    前言 这一篇与上一篇 JS几种变量交换方式以及性能分析对比 属于同一个系列,本文继续分析JS中几种常用的数组遍历方式以及各自的性能对比 起由 在上一次分析了JS几种常用变量交换方式以及各自性能后,觉得 ...

  4. Java中测试StringBuilder、StringBuffer、String在字符串拼接上的性能

    应一个大量字符串拼接的任务 测试一下StringBuilder.StringBuffer.String在操作字符串拼接时候的性能 性能上理论是StringBuilder  >  StringBu ...

  5. 【转】Java 5种字符串拼接方式性能比较。

    最近写一个东东,可能会考虑到字符串拼接,想了几种方法,但对性能未知,于是用Junit写了个单元测试. 代码如下: import java.util.ArrayList; import java.uti ...

  6. Java 5种字符串拼接方式性能比较。

    最近写一个东东,可能会考虑到字符串拼接,想了几种方法,但对性能未知,于是用Junit写了个单元测试. 代码如下: import java.util.ArrayList; import java.uti ...

  7. Java 5种字符串拼接方式性能比较

    http://blog.csdn.net/kimsoft/article/details/3353849 import java.util.ArrayList; import java.util.Li ...

  8. golang字符串拼接性能对比

    对比 +(运算符).strings.Join.sprintf.bytes.Buffer对字符串拼接的性能 package main import ( "bytes" "f ...

  9. Java 字符串拼接方式

    import java.util.ArrayList; import java.util.List; import org.apache.commons.lang.StringUtils; impor ...

随机推荐

  1. views层回顾

    目录 views层回顾 jsonResponse 2 大文件上传 3. cbv和fbv源码分析 4settings.py源码分析 5模板传值{{}} {%%} 6. 过滤器和标签和自定义 7模板的继承 ...

  2. Canvas 橡皮擦效果

    引子 解决了第一个问题图像灰度处理之后,接着就是做擦除的效果. Origin My GitHub 思路 一开始想到 Canvas 的画布可以相互覆盖的特性,彩色原图作为背景,灰度图渲染到 Canvas ...

  3. sphinx转pdf显示中文

    在conf.py中 修改, 加入 ctex包 latex_elements = { # The paper size ('letterpaper' or 'a4paper'). #'papersize ...

  4. JS实现遮罩层

    /* * 显示loading遮罩层 */ function loading() { var mask_bg = document.createElement("div"); mas ...

  5. ofo小黄车做信息流!这到底算怎么回事?

    不得不说,现在ofo绝对处于商业处境和舆论的风口浪尖上.近段时间以来,ofo各种大动作实在是让业界和大众都"看不懂".但毋庸置疑的是,ofo的种种举措都是为了"自救&qu ...

  6. oBike退出新加坡、ofo取消免押金服务,全球共享单车都怎么了?

    浪潮退去后,才知道谁在裸泳.这句已经被说烂的"至理名言",往往被用在一波接一波的互联网热潮中.团购.O2O.共享单车.共享打车.无人货柜--几乎每一波热潮在退去后会暴露出存在的问题 ...

  7. python-jenkins 操作

    Python-Jenkins Doc:http://python-jenkins.readthedocs.io/en/latest/index.html 实例代码: import jenkins je ...

  8. USB Reverse Tether (a dirty solution)

    Tether your android phone to your PC using USB cable could share your 3g Internet connection with PC ...

  9. MRP运算报错-清除预留

    MRP运算报错-清除预留

  10. 基础篇九:模块介绍(--with-http_stub_status_module)

    下面--with 即为编译安装的模块 下面我们来介绍--with-http_stub_status_module此模块 vim  /etc/nginx/conf.d/default.conf 然后检查 ...