
字符串是一個常見的數(shù)據(jù)類型,在 Go 語言在內(nèi)的很多語言中,為了安全,都把字符串設計為不可變。每生成一個字符串都是在創(chuàng)建一個新的字符串,而不是在原有字符串的基礎(chǔ)上修改。
在 Go 中,字符串拼接的方式很多,可以直接使用 +,也可以使用 fmt.SPrintf,還可以使用 strings.Builder 和 bytes.Buffer。
在這篇文章中,來討論一下在代碼中如何做字符串拼接效率最好。
1. 做一個基準測試
在開始分析每種拼接方法的優(yōu)劣之前,先跑一個簡單的基準測試,來看一下每種字符串拼接方法的性能。
Go 中提供了基準測試框架,測試文件需要以 test 結(jié)尾,然后每個測試方法以 Benchmark 開頭,這次對加號、fmt.SPrintf、和 strings.Builder 三種方式進行基準測試,代碼如下:
func BenchmarkPlus(b *testing.B) {
str := "this is just a string"
for i := 0; i < b.N; i++ {
stringPlus(str)
}
}
func BenchmarkSPrintf(b *testing.B) {
str := "this is just a string"
for i := 0; i < b.N; i++ {
stringSprintf(str)
}
}
func BenchmarkStringBuilder(b *testing.B) {
str := "this is just a string"
for i := 0; i < b.N; i++ {
stringBuilder(str)
}
}
func stringPlus(str string) string {
s := ""
for i := 0; i < 10000; i++ {
s += str
}
return s
}
func stringSprintf(str string) string {
s := ""
for i :=0; i < 10000; i++ {
s += str
}
return s
}
func stringBuilder(str string) string {
builder := strings.Builder{}
for i := 0; i < 100000; i++ {
builder.WriteString(str)
}
return builder.String()
}
基準測試需要使用 *testing.B ,其中 b.N 不是一個固定的值,這個值的大小由框架自己來決定。
在這里,我們分別測試用不同的方式拼接一個固定的字符串 10000 次,然后統(tǒng)計平均的代碼執(zhí)行時間,內(nèi)存消耗情況。使用如下的命令運行基準測試:
go test -bench=. -benchmem
-bench=. 參數(shù)運行當前包中所有基準測試,-benchmem 表示對測試的內(nèi)存使用情況進行統(tǒng)計。運行上面的命令之后,輸出結(jié)果如下:
goos: darwin
goarch: amd64
pkg: zxin.com/zx-demo/string_benchmark
BenchmarkPlus-12 12 96586447 ns/op 1086401355 B/op 10057 allocs/op
BenchmarkSPrintf-12 12 97037216 ns/op 1086402698 B/op 10065 allocs/op
BenchmarkStringBuilder-12 655 1713353 ns/op 11671537 B/op 35 allocs/op
PASS
ok zxin.com/zx-demo/string_benchmark 6.186s
第一列表示基準測試的方法名稱和所用的 GOMAXPROCS 的值,第二列表示這次測試循環(huán)的次數(shù),第三列表示平均每次測試所用的時間,單位為納秒,第四列表示平均每次運行所分配的內(nèi)存,第五列表示每次運行所分配內(nèi)存的次數(shù)。
通過上面的測試,可以發(fā)現(xiàn) strings.Builder 的表現(xiàn)是最好的,比直接使用加號來拼接字符串的內(nèi)存消耗要小 100 倍。
2. 為什么性能的差異這么大
通過上面的基準測試可以發(fā)現(xiàn),使用不同的方式來拼接字符串,性能差異很大。
Go 的字符串是不可變的,如果使用加號的方式來拼接字符串,那么每次拼接都需要重新分配內(nèi)存。而 strings.Builder 會對內(nèi)存預分配,在字符串不斷寫入的過程中,會自動擴容長度。
strings.Builder 的底層存儲使用的是 []byte,初始的長度分配是 32,然后每次擴容時都會翻一倍。
type Builder struct {
addr *Builder
buf []byte
}
當長度到大 2048 時,再擴容就不會直接翻倍,而是每次增加 640 的倍數(shù),第一次增加 640,第二次增加 1280,以此類推。
在大量拼接字符串的時候 strings.Builder 會比直接拼接的效率更高。
bytes.Buffer 是另一個類似的庫,與 strings.Builder 性能相當,但如果是對于純拼接字符串的場景,還是推薦使用 strings.Builder。
3. 拼字符串的最佳實踐
雖然 strings.Builder 的性能很高,但并不是所有的場景都是合這個。如果只是一次簡單的字符串拼接,直接使用加號就夠了。
如果涉及到一些字符串的格式化,那么使用 fmt.Sprintf 就更合適了。
那么在大量拼接字符串的場景,直接使用 strings.Builder 就完事了么,其實還可以繼續(xù)優(yōu)化一下。在使用 strings.Builder 時,如果字符串在不斷的增加,底層的存儲還是要不斷的擴容。如果可以預估字符串的長度,就可以提前分配好內(nèi)存。減少擴容的次數(shù)。
增加一個測試用例:
func BenchmarkStringBuilderPre(b *testing.B) {
str := "this is just a string"
for i := 0; i < b.N; i++ {
stringBuilderPre(str)
}
}
func stringBuilderPre(str string) string {
builder := strings.Builder{}
builder.Grow(1000000)
for i := 0; i < 100000; i++ {
builder.WriteString(str)
}
return builder.String()
}
下面是基準測試的結(jié)果:
pkg: zxin.com/zx-demo/string_benchmark
BenchmarkPlus-12 12 96676019 ns/op 1086401676 B/op 10057 allocs/op
BenchmarkSPrintf-12 12 96693407 ns/op 1086402022 B/op 10058 allocs/op
BenchmarkStringBuilder-12 607 1822282 ns/op 11671543 B/op 35 allocs/op
BenchmarkStringBuilderPre-12 860 1393689 ns/op 8257539 B/op 5 allocs/op
可以看到,在提前指定長度的情況下,性能又提升了不少,內(nèi)存的占用量和分配次數(shù)下降了不少,運行時間也有所提升。
文 / Rayjun