在上一篇关于字符串拼接的文章 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次进行分配,占用内存,所以每次操作时间都比较长,自然性能就低下。

Go语言字符串高效拼接(二)

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性能好很多,这个问题原因主要还是[]bytestring之间的转换,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次内存分配,性能体验越来越差,在后面的测试中,会排除掉。

fmtbufrer已经的性能也没有提升,继续走低。剩下比较坚挺的是JoinBuilder

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个字符串的时候差不多,表现好的还是JoinBuilder。这两个方法的使用侧重点有些不一样, 如果有现成的数组、切片那么可以直接使用Join,但是如果没有,并且追求灵活性拼接,还是选择BuilderJoin还是定位于有现成切片、数组的(毕竟拼接成数组也要时间),并且使用固定方式进行分解的,比如逗号、空格等,局限比较大。

小结

至于10000个字符串拼接我这里就不做测试了,大家可以自己试试,看看是不是大同小异的。

从最近的这两篇文章的分析来看,我们大概可以总结出。

  1. + 连接适用于短小的、常量字符串(明确的,非变量),因为编译器会给我们优化。
  2. Join是比较统一的拼接,不太灵活
  3. fmtbuffer基本上不推荐
  4. builder从性能和灵活性上,都是上佳的选择。

到这里就完了吗?这篇文章是完了,我也该睡觉了。但是字符串高效拼接还没完,以上并不是终极性能,还可以优化,敬请期待第三篇。

本文为原创文章,转载注明出处,「总有烂人抓取文章的时候还去掉我的原创说明」欢迎扫码关注公众号flysnow_org或者网站http://www.flysnow.org/,第一时间看后续精彩文章。「防烂人备注**……&*¥」觉得好的话,顺手分享到朋友圈吧,感谢支持。

扫码关注