[Golang]字符串拼接方式的性能分析
本文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 ( |
经过测试(go test -bench=. -benchmem),结果如下:
...... |
可以看出
- 运行速度上,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]字符串拼接方式的性能分析的更多相关文章
- Lua大量字符串拼接方式效率对比及原因分析
Lua大量字符串拼接方式效率对比及原因分析_AaronChan的博客-CSDN博客_lua字符串拼接消耗 https://blog.csdn.net/qq_26958473/article/detai ...
- JS几种变量交换方式以及性能分析对比
前言 "两个变量之间的值得交换",这是一个经典的话题,现在也有了很多的成熟解决方案,本文主要是列举几种常用的方案,进行大量计算并分析对比. 起由 最近做某个项目时,其中有一个需求是 ...
- JS几种数组遍历方式以及性能分析对比
前言 这一篇与上一篇 JS几种变量交换方式以及性能分析对比 属于同一个系列,本文继续分析JS中几种常用的数组遍历方式以及各自的性能对比 起由 在上一次分析了JS几种常用变量交换方式以及各自性能后,觉得 ...
- Java中测试StringBuilder、StringBuffer、String在字符串拼接上的性能
应一个大量字符串拼接的任务 测试一下StringBuilder.StringBuffer.String在操作字符串拼接时候的性能 性能上理论是StringBuilder > StringBu ...
- 【转】Java 5种字符串拼接方式性能比较。
最近写一个东东,可能会考虑到字符串拼接,想了几种方法,但对性能未知,于是用Junit写了个单元测试. 代码如下: import java.util.ArrayList; import java.uti ...
- Java 5种字符串拼接方式性能比较。
最近写一个东东,可能会考虑到字符串拼接,想了几种方法,但对性能未知,于是用Junit写了个单元测试. 代码如下: import java.util.ArrayList; import java.uti ...
- Java 5种字符串拼接方式性能比较
http://blog.csdn.net/kimsoft/article/details/3353849 import java.util.ArrayList; import java.util.Li ...
- golang字符串拼接性能对比
对比 +(运算符).strings.Join.sprintf.bytes.Buffer对字符串拼接的性能 package main import ( "bytes" "f ...
- Java 字符串拼接方式
import java.util.ArrayList; import java.util.List; import org.apache.commons.lang.StringUtils; impor ...
随机推荐
- swoole使用异步redis
1.lnmp安装redis拓展 wget http://download.redis.io/releases/redis-4.0.9.tar.gz chmod 755 redis-4.0.9.tar. ...
- iOS 中UITableView的深理解
例如下图:首先分析一下需求:1.根据模型的不同状态显示不同高度的cell,和cell的UI界面. 2.点击cell的取消按钮时,对应的cell首先要把取消按钮隐藏掉,然后改变cell的高度. 根据需求 ...
- liblinear中的信赖域算法
求方程 \(H s = -g\), H是hessian矩阵, g 为梯度, 残量 \(r = -g -Hs\). s的初值为0,理论上,共轭梯度每步迭代使得\(\|s\|\) 单调增加,共轭梯度的迭代 ...
- chromosome interaction mapping|cis- and trans-regulation|de novo|SRS|LRS|Haplotype blocks|linkage disequilibrium
Dissecting evolution and disease using comparative vertebrate genomics-The sequencing revolution s ...
- Monkey通过安装包获取包名
在monkey命令中,包名常作为一个参数.但我们经常知道apk文件,却不知道包名. 如何获取包名呢? 方法一:AAPT 在SDK的build-tools目录下,aapt工具可以查看,创建,更新zip格 ...
- Matlab高级教程_第二篇:一个简单的混编例子
1. 常用的混编是MATLAB和VS两个编辑器之间的混编方式. 2. 因为MATLAB的核是C型语言,因此常见的混编方式是MATLAB和C型语言的混编. 3. 这里介绍一个简单的MATLAB语言混编成 ...
- Java分层架构的使用规则
原文章引用地址:http://blog.csdn.net/ygzk123/article/details/7816511 三层结构的程序不是说把项目分成DAL, BLL, WebUI三个模块就叫三层了 ...
- mui弹出输入法遮住input表单元素
转自https://www.cnblogs.com/devilyouwei/p/6293190.html mui弹出输入法遮住input表单元素 问题如下:当我用mui开发app时,在mui-sc ...
- day22- hashlib模块-摘要算法(哈希算法)
# python的hashlib提供了常见的摘要算法,如md5(md5算法),sha1等等.摘要:digest # 摘要算法又称哈希算法.散列算法. # 它通过一个函数,把任意长度的数据(明文)转换为 ...
- python学习笔记(8)迭代器和生成器
迭代器 迭代是Python最强大的功能之一,是访问集合元素的一种方式. 迭代器是一个可以记住遍历的位置的对象. 迭代器对象从集合的第一个元素开始访问,直到所有的元素被访问完结束.迭代器只能往前不会后退 ...