背景介紹
在我們實際開發(fā)過程中弥激,不可避免的要進行一些字符串的拼接工作。比如將一個數(shù)組按照一定的標點符號拼接成一個句子嚷兔、將兩個字段拼接成一句話等垃你。
而在我們Go語言中,對于字符串的拼接處理有許多種方法缭嫡,我們最常見的可能是直接用“+”加號進行拼接缔御,或者使用join處理切片,再或者使用fmt.Sprintf("")去組裝數(shù)據(jù)妇蛀。
那么這就有個問題耕突,我們如何高效的使用字符串的拼接,在線上高并發(fā)的場景下评架,不同的字符串拼接方法對性能的影響又有多大眷茁?
下面我將對Go語言中常見的幾種字符串的拼接方法進行測試,分析每個方法的性能如何纵诞。
0 準備工作
為了測試各個方法的實際效果上祈,本文將采用benchmark來測試,這里僅對benchmark做一個簡單的介紹,后續(xù)將會出一篇文章對benchmark進行詳細的介紹登刺。
benchmark是Go自帶的測試利器籽腕,使用benchmark我們可以方便快捷的測試一個函數(shù)方法在串行和并行環(huán)境下的表現(xiàn),指定一個時間(默認測試1秒)纸俭,看被測方法在達到這個時間上限所能執(zhí)行的次數(shù)和內存分配情況皇耗。
benchmark的常用API有如下幾種:
// 開始計時
b.StartTimer()
// 停止計時
b.StopTimer()
// 重置計時
b.ResetTimer()
b.Run(name string, f func(b *B))
b.RunParallel(body func(*PB))
b.ReportAllocs()
b.SetParallelism(p int)
b.SetBytes(n int64)
testing.Benchmark(f func(b *B)) BenchmarkResult
本文主要用的是以下三種
b.StartTimer() // 開始計時
b.StopTimer() // 停止計時
b.ResetTimer() // 重置計時
在編寫完成測試文件后,執(zhí)行命令go test -bench=. -benchmem 可以執(zhí)行測試文件揍很,并顯示內存
1 構建測試用例
這里我在測試文件里會有一個全局的slice郎楼,用來做拼接的原始數(shù)據(jù)集。
var StrData = []string{"Go語言高效拼接字符串"}
然后使用在init函數(shù)里進行數(shù)據(jù)組裝女轿,把這個全局的slice變大箭启,同時可以控制較大的slice的拼接和較小的slice拼接有什么區(qū)別。
func init() {
for i := 0; i < 200; i++ {
StrData = append(StrData, "Go語言高效拼接字符串")
}
}
1.1 “+”直接拼接
func StringsAdd() string {
var s string
for _, v := range StrData {
s += v
}
return s
}
// 測試方法
func BenchmarkStringsAdd(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
StringsAdd()
}
b.StopTimer()
}
1.2 使用fmt包進行組裝
func StringsFmt() string {
var s string = fmt.Sprint(StrData)
return s
}
// 測試方法
func BenchmarkStringsFmt(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
StringsFmt()
}
b.StopTimer()
}
1.3 使用strings包的join方法
func StringsJoin() string {
var s string = strings.Join(StrData, "")
return s
}
// 測試方法
func BenchmarkStringsJoin(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
StringsJoin()
}
b.StopTimer()
}
1.4 使用bytes.Buffer拼接
func StringsBuffer() string {
var s bytes.Buffer
for _, v := range StrData {
s.WriteString(v)
}
return s.String()
}
// 測試方法
func BenchmarkStringsBuffer(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
StringsBuffer()
}
b.StopTimer()
}
1.5 使用strings.Builder拼接
func StringsBuilder() string {
var b strings.Builder
for _, v := range StrData {
b.WriteString(v)
}
return b.String()
}
// 測試方法
func BenchmarkStringsBuilder(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
StringsBuilder()
}
b.StopTimer()
}
2 測試結果及分析
2.1 使用benchmark運行測試蛉迹,查看結果
接下來執(zhí)行:go test -bench=. -benchmem 命令來獲取benchmark的測試結果傅寡。
從這次的測試結果來看,我們可以初步得出以下結論:
- 我們直接使用“+”號拼接是耗時最多北救,內存消耗最大的操作荐操,b.N周期內的每次迭代,都會進行內存分配珍策;
- 使用Strings.Join方法進行拼接的執(zhí)行的次數(shù)最多托启,代表耗時最小,而內存的分配次數(shù)最少攘宙,每次迭代分配的內存也是最少的屯耸;
- 使用Strings.Builder類型進行拼接,每次迭代都額外分配了13次內存蹭劈,性能并沒有明顯的優(yōu)勢疗绣,可是Go從1.10開始新增了這個類型,并開始逐步使用呢铺韧?
以上結論看起來好像是有那么點意思多矮,但是否正確呢?我們不妨先不著急哈打,從這五種拼接方式里挨著每個參數(shù)解釋看塔逃,是否和預期吻合。
2.2 “+”號拼接的結果分析
從上面的測試用例來看料仗,我們將一個slice循環(huán)了200次湾盗,對其append了200個元素,加上自己本身的一個元素立轧,得到了一個201長度的slice淹仑。而我們將將這個切片循環(huán)拼接字符串的結果就是循環(huán)了201丙挽,從benchmark的最后一列看肺孵,顯示內存“額外”分配了200次匀借,除去初始分配的內存外,正好符合我們平時所熟知的:使用“+”拼接字符串平窘,會重新分配內存吓肋。
而使用+號拼接,平均每次分配的內存和耗時都是最大的瑰艘,因此執(zhí)行總次數(shù)也是最少的是鬼。
那么,我們進行大文本拼接和小文本拼接紫新,又會有什么不同呢均蜜?后面我會進行小文本拼接的測試。
2.3 fmt包進行拼接的結果分析
我們看到fmt包進行拼接的結果是僅次于“+”號拼接芒率,但內存重新分配的次數(shù)卻大于200次囤耳,這就有些奇怪了,是什么情況導致了額外分配內存的次數(shù)偶芍,是不是每次迭代都會分配3次內存呢充择?我們來做個試驗:
我們先將slice的長度改成1,查看是否還會有額外內存分配的情況存在匪蟀,同樣使用benchmark來查看測試結果:
然后我們將slice的長度改成2椎麦,查看benchmark的結果:
最后我們經(jīng)過多次測試,發(fā)現(xiàn)的確是每次迭代都會有3次額外內存的分配情況材彪,那么观挎,這三次的內存分配是出在什么地方呢?
我們將benchmark測試的結果輸出到文件段化,并使用pprof來查看嘁捷,使用如下命令:
# 使用benchmark采集3秒的數(shù)據(jù),并生成文件
go test -bench=. -benchmem -benchtime=3s -memprofile=mem_profile.out
# 查看pprof文件穗泵,指定http方式查看
go tool pprof -http="127.0.0.1:8080" mem_profile.out
執(zhí)行后會使用默認瀏覽器開啟打開一個web界面來查看具體采集的數(shù)據(jù)內容普气,我們依次按照圖示的紅框點擊
得到的最終url是:http://127.0.0.1:8080/ui/top?si=alloc_space
這時,我們看到如圖內容:
我們看到佃延,fmt.Sprint方法會有這三個內存的分配现诀。
2.4 使用strings.Join方法進行拼接的結果分析
從不上面的測試內容來看,使用strings.Join方法是實現(xiàn)效果最好的方法履肃,耗時是最低的仔沿,內存占用也最低,額外內存分配次數(shù)也只有1次尺棋,我們查看strings.Join的方法內部的實現(xiàn)代碼封锉。
// Join concatenates the elements of its first argument to create a single string. The separator
// string sep is placed between elements in the resulting string.
func Join(elems []string, sep string) string {
switch len(elems) {
case 0:
return ""
case 1:
return elems[0]
}
n := len(sep) * (len(elems) - 1)
for i := 0; i < len(elems); i++ {
n += len(elems[i])
}
var b Builder
b.Grow(n)
b.WriteString(elems[0])
for _, s := range elems[1:] {
b.WriteString(sep)
b.WriteString(s)
}
return b.String()
}
從第15行可以看出,Join方法也使用了strings包里的builder類型。后面會單獨對比自己寫的strings.Builder和Join方法內部的效果為什么不一樣成福。
2.5 使用bytes.Buffer方法進行拼接的結果分析
之前從網(wǎng)上多處看到有人推薦使用bytes.Buffer碾局,bytes.buffer是一個緩沖byte類型的緩沖器,這個緩沖器里存放著都是byte奴艾。buffer的結構體定義如下:
// A Buffer is a variable-sized buffer of bytes with Read and Write methods.
// The zero value for Buffer is an empty buffer ready to use.
type Buffer struct {
buf []byte // contents are the bytes buf[off : len(buf)]
off int // read at &buf[off], write at &buf[len(buf)]
lastRead readOp // last read operation, so that Unread* can work correctly.
}
在Go 1.10以前净当,使用buffer無疑是一個較為高效的選擇。使用var b bytes.Buffer 存放最終拼接好的字符串蕴潦,一定程度上避免上面 string 每進行一次拼接操作就重新申請新的內存空間存放中間字符串的問題像啼。但其仍然存在一個[]byte -> string類型轉換和內存拷貝的問題。
2.6 使用strings.Builder方法進行拼接的結果分析
在Go 1.10開始潭苞,Go官方將strings.Builder作為一個feature引入忽冻,其能較大程度的提高字符串拼接的效率,下面貼出來部分代碼:
// A Builder is used to efficiently build a string using Write methods.
// It minimizes memory copying. The zero value is ready to use.
// Do not copy a non-zero Builder.
type Builder struct {
addr *Builder // of receiver, to detect copies by value
buf []byte
}
func (b *Builder) copyCheck() {
if b.addr == nil {
// This hack works around a failing of Go's escape analysis
// that was causing b to escape and be heap allocated.
// See issue 23382.
// TODO: once issue 7921 is fixed, this should be reverted to
// just "b.addr = b".
b.addr = (*Builder)(noescape(unsafe.Pointer(b)))
} else if b.addr != b {
panic("strings: illegal use of non-zero Builder copied by value")
}
}
// String returns the accumulated string.
func (b *Builder) String() string {
return *(*string)(unsafe.Pointer(&b.buf))
}
// WriteString appends the contents of s to b's buffer.
// It returns the length of s and a nil error.
func (b *Builder) WriteString(s string) (int, error) {
b.copyCheck()
b.buf = append(b.buf, s...)
return len(s), nil
}
// grow copies the buffer to a new, larger buffer so that there are at least n
// bytes of capacity beyond len(b.buf).
func (b *Builder) grow(n int) {
buf := make([]byte, len(b.buf), 2*cap(b.buf)+n)
copy(buf, b.buf)
b.buf = buf
}
為了解決bytes.Buffer.String()存在的[]byte -> string類型轉換和內存拷貝問題此疹,這里使用了一個unsafe.Pointer的內存指針轉換操作僧诚,實現(xiàn)了直接將buf []byte轉換為 string類型,同時避免了內存充分配的問題秀菱。而且標準庫還實現(xiàn)了一個copyCheck方法振诬,可以比較hack的代碼來避免buf逃逸到堆上。
前面我們提到衍菱,使用string.Join進行字符串拼接赶么,其底層就是使用的strings.Builder來處理數(shù)據(jù),但為什么benchmark的結果卻相差甚遠,下面將對這兩種方法進行比較脊串。
3 strings.Builder和strings.Join的比較
為了比較這兩種方法的效率辫呻,我再次貼出兩種方法比較代碼。
strings.Join的關鍵代碼:
func Join(elems []string, sep string) string {
switch len(elems) {
case 0:
return ""
case 1:
return elems[0]
}
n := len(sep) * (len(elems) - 1)
for i := 0; i < len(elems); i++ {
n += len(elems[i])
}
var b Builder
b.Grow(n)
b.WriteString(elems[0])
for _, s := range elems[1:] {
b.WriteString(sep)
b.WriteString(s)
}
return b.String()
}
我自己寫的strings.Builder拼接方法:
func StringsBuilder() string {
var b strings.Builder
for _, v := range StrData {
b.WriteString(v)
}
return b.String()
}
這里我發(fā)現(xiàn)在Join方法的第14行有個b.Grow(n)的操作琼锋,這個是進行初步的容量分配放闺,而前面計算的n的長度就是我們要拼接的slice的長度,這時候就嘗試將自己寫的拼接方法也添加一個內存分配的方法進行比較試試缕坎。
func StringsBuilder() string {
n := len("") * (len(StrData) - 1)
for i := 0; i < len(StrData); i++ {
n += len(StrData[i])
}
var b strings.Builder
b.Grow(n)
b.WriteString(StrData[0])
for _, s := range StrData[1:] {
b.WriteString("")
b.WriteString(s)
}
return b.String()
}
再次執(zhí)行benchmark檢查測試結果
突然發(fā)現(xiàn)和最開始預料的好像有些出入怖侦,使用Strings.Builder的更有優(yōu)勢,這又是為何呢谜叹?仔細一想匾寝, strings.Join()方法進行了傳參,而傳參會不會造成這個差距的原因呢荷腊?下面我也給StringsBuilder這個方法進行參數(shù)傳遞艳悔。
func StringsBuilder(strData []string,sep string) string {
n := len(sep) * (len(strData) - 1)
for i := 0; i < len(strData); i++ {
n += len(strData[i])
}
var b strings.Builder
b.Grow(n)
b.WriteString(strData[0])
for _, s := range strData[1:] {
b.WriteString(sep)
b.WriteString(s)
}
return b.String()
}
再次執(zhí)行benchmark檢查測試結果
這時可以看出來。兩次執(zhí)行的情況幾乎就差不多了女仰。
4 構建小字符串測試及分析
上面的測試都是基于較大的slice進行拼接字符串猜年,那如果我們有一個較小的slice需要拼接呢抡锈,使用這五種方法哪個效率更高呢?我選擇一個長度為2的slice進行拼接乔外。
由此可以看出床三,使用strings包進行拼接的效率還是較為明顯的,但和直接“+”拼接的效率就比較相近了袁稽。
5 總結
以上是經(jīng)常用的五種字符串拼接的方式效率的比較勿璃,官方是建議使用strings.Builder的方式,但也不得不說根據(jù)業(yè)務場景的不同推汽,方式就變得較為靈活,如果只是兩個字符串的拼接歧沪,直接使用“+”也未嘗不可歹撒。但對于較多的字符串拼接的話,還是盡量使用strings.Builder方式诊胞。
而在使用strings.Join方法拼接slice的時候由于牽扯到參數(shù)的傳遞暖夭,效率也或多或少有些影響。
因此在較大的字符串拼接時撵孤,五種方式的拼接效率由高到低排序是:
strings.Builder ≈ strings.Join > strings.Buffer > "+" > fmt
本文由博客一文多發(fā)平臺 OpenWrite 發(fā)布迈着!