在上一篇关于字符串拼接的文章 Go语言字符串高效拼接(一) 中,我们演示的多种字符串拼接的方式,并且使用一个例子来测试了他们的性能,通过对比发现,我们觉得性能高的Builder
并未发挥出其应该的性能,反而+
号拼接,甚至strings.Join
方法的性能更优越,那么这到底是什么原因呢?今天我们开始解开他们神秘的面纱,解开谜底。
在开始前给大家送个福利。阿里云双11拼团活动,战队已达数百人,有资格瓜分百万奖金,赶紧加入 。现在加入即可享受最低1折,1年99元的云主机,还可以参与瓜分百万奖金,先邀请再购买,赶紧上车,老司机开车。
拼接函数改造
在上一篇的文章的末尾,我已经提出了2个可能性:拼接字符串的数量和拼接字符串的大小,现在我们就开始证明这两种情况,为了演示方便,我们把原来的拼接函数修改一下,可以接受一个[]string
类型的参数,这样我们就可以对切片数组进行字符串拼接,这里直接给出所有的拼接方法的改造后实现。
func StringPlus(p []string) string{
var s string
l:=len(p)
for i:=0;i<l;i++{
s+=p[i]
}
return s
}
func StringFmt(p []interface{}) string{
return fmt.Sprint(p...)
}
func StringJoin(p []string) string{
return strings.Join(p,"")
}
func StringBuffer(p []string) string {
var b bytes.Buffer
l:=len(p)
for i:=0;i<l;i++{
b.WriteString(p[i])
}
return b.String()
}
func StringBuilder(p []string) string {
var b strings.Builder
l:=len(p)
for i:=0;i<l;i++{
b.WriteString(p[i])
}
return b.String()
}
以上实现中的for
循环我并没有使用for range
,为了提高性能,具体原因请参考我的 Go语言性能优化- For Range 性能研究 。
测试用例
以上的字符串拼接函数修改后,我们就可以构造不同大小的切片进行字符串拼接测试了。为了模拟上次的效果,我们先用10个切片大小的字符串进行拼接测试,和上一篇的测试情形差不多(也是大概10个字符串拼接)。
const BLOG = "http://www.flysnow.org/"
func initStrings(N int) []string{
s:=make([]string,N)
for i:=0;i<N;i++{
s[i]=BLOG
}
return s;
}
func initStringi(N int) []interface{}{
s:=make([]interface{},N)
for i:=0;i<N;i++{
s[i]=BLOG
}
return s;
}
这是两个构建测试用力切片数组的函数,可以生成N个大小的切片。第二个initStringi
函数返回的是[]interface{}
,这是专门为StringFmt(p []interface{})
拼接函数准备的,减少类型之间的转换。
有了这两个生成测试用例的函数,我们就可以构建我们的Go语言性能测试了,我们先测试10个大小的切片。
func BenchmarkStringPlus10(b *testing.B) {
p:= initStrings(10)
b.ResetTimer()
for i:=0;i<b.N;i++{
StringPlus(p)
}
}
func BenchmarkStringFmt10(b *testing.B) {
p:= initStringi(10)
b.ResetTimer()
for i:=0;i<b.N;i++{
StringFmt(p)
}
}
func BenchmarkStringJoin10(b *testing.B) {
p:= initStrings(10)
b.ResetTimer()
for i:=0;i<b.N;i++{
StringJoin(p)
}
}
func BenchmarkStringBuffer10(b *testing.B) {
p:= initStrings(10)
b.ResetTimer()
for i:=0;i<b.N;i++{
StringBuffer(p)
}
}
func BenchmarkStringBuilder10(b *testing.B) {
p:= initStrings(10)
b.ResetTimer()
for i:=0;i<b.N;i++{
StringBuilder(p)
}
}
在每个性能测试函数中,我们都会调用b.ResetTimer()
,这是为了避免测试用例准备时间不同,带来的性能测试效果偏差问题,具体可以参考我的一篇文章 Go语言实战笔记(二十二)| Go 基准测试 。
我们运行go test -bench=. -run=NONE -benchmem
查看结果。
BenchmarkStringPlus10-8 3000000 593 ns/op 1312 B/op 9 allocs/op
BenchmarkStringFmt10-8 5000000 335 ns/op 240 B/op 1 allocs/op
BenchmarkStringJoin10-8 10000000 200 ns/op 480 B/op 2 allocs/op
BenchmarkStringBuffer10-8 3000000 452 ns/op 864 B/op 4 allocs/op
BenchmarkStringBuilder10-8 10000000 231 ns/op 480 B/op 4 allocs/op
通过这次我们可以看到,+
号拼接不再具有优势,因为string
是不可变的,每次拼接都会生成一个新的string
,也就是会进行一次内存分配,我们现在是10个大小的切片,每次操作要进行9次进行分配,占用内存,所以每次操作时间都比较长,自然性能就低下。
http://www.flysnow.org/2018/11/05/golang-concat-strings-performance-analysis.html
可能有读者记得,我们上一篇文章 Go语言字符串高效拼接(一) 中,+
加号拼接的性能测试中显示的只有2次内存分配,但是我们用了好多个+
的。
func StringPlus() string{
var s string
s+="昵称"+":"+"飞雪无情"+"\n"
s+="博客"+":"+"http://www.flysnow.org/"+"\n"
s+="微信公众号"+":"+"flysnow_org"
return s
}
再来回顾下这段代码,的确是有很多+
的,但是只有2次内存分配,我们可以大胆猜测,是3次s+=
导致的,正常和我们今天测试的10个长度的切片,只有9次内存分配一样。下面我们通过运行如下命令看下Go编译器对这段代码的优化:go build -gcflags="-m -m" main.go
,输出中有如下内容:
can inline StringPlus as: func() string { var s string; s = <N>; s += "昵称:飞雪无情\n"; s += "博客:http://www.flysnow.org/\n"; s += "微信公众号:flysnow_org"; return s }
现在一目了然了,其实是编译器帮我们把字符串做了优化,只剩下3个s+=
这次,采用长度为10个切片进行测试,也很明显测试出了Builder
要比Buffer
性能好很多,这个问题原因主要还是[]byte
和string
之间的转换,Builder
恰恰解决了这个问题。
func (b *Builder) String() string {
return *(*string)(unsafe.Pointer(&b.buf))
}
很高效的解决方案。
100个字符串
现在我们测试下100个字符串拼接的情况,对于我们上面的代码,要改造非常容易,这里直接给出测试代码。
func BenchmarkStringPlus100(b *testing.B) {
p:= initStrings(100)
b.ResetTimer()
for i:=0;i<b.N;i++{
StringPlus(p)
}
}
func BenchmarkStringFmt100(b *testing.B) {
p:= initStringi(100)
b.ResetTimer()
for i:=0;i<b.N;i++{
StringFmt(p)
}
}
func BenchmarkStringJoin100(b *testing.B) {
p:= initStrings(100)
b.ResetTimer()
for i:=0;i<b.N;i++{
StringJoin(p)
}
}
func BenchmarkStringBuffer100(b *testing.B) {
p:= initStrings(100)
b.ResetTimer()
for i:=0;i<b.N;i++{
StringBuffer(p)
}
}
func BenchmarkStringBuilder100(b *testing.B) {
p:= initStrings(100)
b.ResetTimer()
for i:=0;i<b.N;i++{
StringBuilder(p)
}
}
现在运行性能测试,看看100个字符串连接的性能怎么样,哪个函数最高效。
BenchmarkStringPlus100-8 100000 19711 ns/op 123168 B/op 99 allocs/op
BenchmarkStringFmt100-8 500000 2615 ns/op 2304 B/op 1 allocs/op
BenchmarkStringJoin100-8 1000000 1516 ns/op 4608 B/op 2 allocs/op
BenchmarkStringBuffer100-8 500000 2333 ns/op 8112 B/op 7 allocs/op
BenchmarkStringBuilder100-8 1000000 1714 ns/op 6752 B/op 8 allocs/op
+
号和我们上面分析得一样,这次是99次内存分配,性能体验越来越差,在后面的测试中,会排除掉。
fmt
和bufrer
已经的性能也没有提升,继续走低。剩下比较坚挺的是Join
和Builder
。
1000 个字符串。
测试用力和上面章节的大同小异,所以我们直接看测试结果。
BenchmarkStringPlus1000-8 1000 1611985 ns/op 12136228 B/op 999 allocs/op
BenchmarkStringFmt1000-8 50000 28510 ns/op 24590 B/op 1 allocs/op
BenchmarkStringJoin1000-8 100000 15050 ns/op 49152 B/op 2 allocs/op
BenchmarkStringBuffer1000-8 100000 23534 ns/op 122544 B/op 11 allocs/op
BenchmarkStringBuilder1000-8 100000 17996 ns/op 96224 B/op 16 allocs/op
整体和100个字符串的时候差不多,表现好的还是Join
和Builder
。这两个方法的使用侧重点有些不一样,
如果有现成的数组、切片那么可以直接使用Join
,但是如果没有,并且追求灵活性拼接,还是选择Builder
。
Join
还是定位于有现成切片、数组的(毕竟拼接成数组也要时间),并且使用固定方式进行分解的,比如逗号、空格等,局限比较大。
小结
至于10000个字符串拼接我这里就不做测试了,大家可以自己试试,看看是不是大同小异的。
从最近的这两篇文章的分析来看,我们大概可以总结出。
+
连接适用于短小的、常量字符串(明确的,非变量),因为编译器会给我们优化。Join
是比较统一的拼接,不太灵活fmt
和buffer
基本上不推荐builder
从性能和灵活性上,都是上佳的选择。
到这里就完了吗?这篇文章是完了,我也该睡觉了。但是字符串高效拼接还没完,以上并不是终极性能,还可以优化,敬请期待第三篇。
本文为原创文章,转载注明出处,「总有烂人抓取文章的时候还去掉我的原创说明」欢迎扫码关注公众号
flysnow_org
或者网站http://www.flysnow.org/,第一时间看后续精彩文章。「防烂人备注**……&*¥」觉得好的话,顺手分享到朋友圈吧,感谢支持。
有疑问加站长微信联系(非本文作者)