Go語言幾種字符串的拼接方式比較

背景介紹

在我們實際開發(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的測試結果傅寡。

file

從這次的測試結果來看,我們可以初步得出以下結論

  • 我們直接使用“+”號拼接是耗時最多北救,內存消耗最大的操作荐操,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來查看測試結果:

file

然后我們將slice的長度改成2椎麦,查看benchmark的結果:


file

最后我們經(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ù)內容普气,我們依次按照圖示的紅框點擊


file

file

得到的最終url是:http://127.0.0.1:8080/ui/top?si=alloc_space

這時,我們看到如圖內容:


file

我們看到佃延,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檢查測試結果

file

突然發(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檢查測試結果


file

這時可以看出來。兩次執(zhí)行的情況幾乎就差不多了女仰。

4 構建小字符串測試及分析

上面的測試都是基于較大的slice進行拼接字符串猜年,那如果我們有一個較小的slice需要拼接呢抡锈,使用這五種方法哪個效率更高呢?我選擇一個長度為2的slice進行拼接乔外。


file

由此可以看出床三,使用strings包進行拼接的效率還是較為明顯的,但和直接“+”拼接的效率就比較相近了袁稽。

5 總結

以上是經(jīng)常用的五種字符串拼接的方式效率的比較勿璃,官方是建議使用strings.Builder的方式,但也不得不說根據(jù)業(yè)務場景的不同推汽,方式就變得較為靈活,如果只是兩個字符串的拼接歧沪,直接使用“+”也未嘗不可歹撒。但對于較多的字符串拼接的話,還是盡量使用strings.Builder方式诊胞。
而在使用strings.Join方法拼接slice的時候由于牽扯到參數(shù)的傳遞暖夭,效率也或多或少有些影響。

因此在較大的字符串拼接時撵孤,五種方式的拼接效率由高到低排序是:

strings.Builder ≈ strings.Join > strings.Buffer > "+" > fmt

本文由博客一文多發(fā)平臺 OpenWrite 發(fā)布迈着!

?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市邪码,隨后出現(xiàn)的幾起案子裕菠,更是在濱河造成了極大的恐慌,老刑警劉巖闭专,帶你破解...
    沈念sama閱讀 206,378評論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件奴潘,死亡現(xiàn)場離奇詭異,居然都是意外死亡影钉,警方通過查閱死者的電腦和手機画髓,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,356評論 2 382
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來平委,“玉大人奈虾,你說我怎么就攤上這事×猓” “怎么了肉微?”我有些...
    開封第一講書人閱讀 152,702評論 0 342
  • 文/不壞的土叔 我叫張陵,是天一觀的道長昂勉。 經(jīng)常有香客問我浪册,道長,這世上最難降的妖魔是什么岗照? 我笑而不...
    開封第一講書人閱讀 55,259評論 1 279
  • 正文 為了忘掉前任村象,我火速辦了婚禮笆环,結果婚禮上,老公的妹妹穿的比我還像新娘厚者。我一直安慰自己躁劣,他們只是感情好,可當我...
    茶點故事閱讀 64,263評論 5 371
  • 文/花漫 我一把揭開白布库菲。 她就那樣靜靜地躺著账忘,像睡著了一般。 火紅的嫁衣襯著肌膚如雪熙宇。 梳的紋絲不亂的頭發(fā)上鳖擒,一...
    開封第一講書人閱讀 49,036評論 1 285
  • 那天,我揣著相機與錄音烫止,去河邊找鬼蒋荚。 笑死,一個胖子當著我的面吹牛馆蠕,可吹牛的內容都是我干的期升。 我是一名探鬼主播,決...
    沈念sama閱讀 38,349評論 3 400
  • 文/蒼蘭香墨 我猛地睜開眼互躬,長吁一口氣:“原來是場噩夢啊……” “哼播赁!你這毒婦竟也來了?” 一聲冷哼從身側響起吼渡,我...
    開封第一講書人閱讀 36,979評論 0 259
  • 序言:老撾萬榮一對情侶失蹤容为,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后诞吱,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體舟奠,經(jīng)...
    沈念sama閱讀 43,469評論 1 300
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 35,938評論 2 323
  • 正文 我和宋清朗相戀三年房维,在試婚紗的時候發(fā)現(xiàn)自己被綠了沼瘫。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 38,059評論 1 333
  • 序言:一個原本活蹦亂跳的男人離奇死亡咙俩,死狀恐怖耿戚,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情阿趁,我是刑警寧澤膜蛔,帶...
    沈念sama閱讀 33,703評論 4 323
  • 正文 年R本政府宣布,位于F島的核電站脖阵,受9級特大地震影響皂股,放射性物質發(fā)生泄漏。R本人自食惡果不足惜命黔,卻給世界環(huán)境...
    茶點故事閱讀 39,257評論 3 307
  • 文/蒙蒙 一呜呐、第九天 我趴在偏房一處隱蔽的房頂上張望就斤。 院中可真熱鬧,春花似錦蘑辑、人聲如沸洋机。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,262評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽绷旗。三九已至,卻和暖如春副砍,著一層夾襖步出監(jiān)牢的瞬間衔肢,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 31,485評論 1 262
  • 我被黑心中介騙來泰國打工址晕, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留膀懈,地道東北人。 一個月前我還...
    沈念sama閱讀 45,501評論 2 354
  • 正文 我出身青樓谨垃,卻偏偏與公主長得像,于是被迫代替她去往敵國和親硼控。 傳聞我的和親對象是個殘疾皇子刘陶,可洞房花燭夜當晚...
    茶點故事閱讀 42,792評論 2 345

推薦閱讀更多精彩內容