幾種方式的字符串拼接性能及原理分析


1. 字符串高效拼接

在 Go 語言中,字符串(string) 是不可變的,拼接字符串事實上是創建了一個新的字符串對象。如果代碼中存在大量的字符串拼接,對性能會產生嚴重的影響

1.1 常見的拼接方式

為了避免編譯器優化,我們首先實現一個生成長度為 n 的隨機字符串的函數。

const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"

func randomString(n int) string {
	b := make([]byte, n)
	for i := range b {
		b[i] = letterBytes[rand.Intn(len(letterBytes))]
	}
	return string(b)
}

然后利用這個函數生成字符串 str,然后將 str 拼接 N 次。在 Go 語言中,常見的字符串拼接方式有如下 5 種:

  • 使用+

    func plusConcat(n int, str string) string {
    	s := ""
    	for i := 0; i < n; i++ {
    		s += str
    	}
    	return s
    }
    
  • 使用fmt.Sprintf

    func sprintfConcat(n int, str string) string {
    	s := ""
    	for i := 0; i < n; i++ {
    		s = fmt.Sprintf("%s%s", s, str)
    	}
    	return s
    }
    
  • 使用 strings.Builder

    func builderConcat(n int, str string) string {
    	var builder strings.Builder
    	for i := 0; i < n; i++ {
    		builder.WriteString(str)
    	}
    	return builder.String()
    }
    
  • 使用 bytes.Buffer

    func bufferConcat(n int, s string) string {
    	buf := new(bytes.Buffer)
    	for i := 0; i < n; i++ {
    		buf.WriteString(s)
    	}
    	return buf.String()
    }
    
  • 使用 []byte

    func byteConcat(n int, str string) string {
    	buf := make([]byte, 0)
    	for i := 0; i < n; i++ {
    		buf = append(buf, str...)
    	}
    	return string(buf)
    }
    
  • 如果長度是可預知的,那么創建 []byte 時,我們還可以預分配切片的容量(cap)。

    func preByteConcat(n int, str string) string {
    	buf := make([]byte, 0, n*len(str))
    	for i := 0; i < n; i++ {
    		buf = append(buf, str...)
    	}
    	return string(buf)
    }
    

    make([]byte, 0, n*len(str)) 第二個參數是長度,第三個參數是容量(cap),切片創建時,將預分配 cap 大小的內存。

1.2 benchmark 性能比拼

每個 benchmark 用例中,生成了一個長度為 10 的字符串,並拼接 1w 次。代碼詳見go-test/benchmark/joinstring_test.go

func benchmark(b *testing.B, f func(int, string) string) {
	var str = randomString(10)
	for i := 0; i < b.N; i++ {
		f(10000, str)
	}
}

func BenchmarkPlusConcat(b *testing.B)    { benchmark(b, plusConcat) }
func BenchmarkSprintfConcat(b *testing.B) { benchmark(b, sprintfConcat) }
func BenchmarkBuilderConcat(b *testing.B) { benchmark(b, builderConcat) }
func BenchmarkBufferConcat(b *testing.B)  { benchmark(b, bufferConcat) }
func BenchmarkByteConcat(b *testing.B)    { benchmark(b, byteConcat) }
func BenchmarkPreByteConcat(b *testing.B) { benchmark(b, preByteConcat) }

運行該用例:

$ go test -bench="Concat$" -benchmem .
goos: linux
goarch: amd64
pkg: github.com/go-test/benckmark
BenchmarkPlusConcat-8          19   75084035 ns/op   530997126 B/op     10027 allocs/op
BenchmarkSprintfConcat-8        8  134393800 ns/op   833956760 B/op     37436 allocs/op
BenchmarkBuilderConcat-8     8472     150648 ns/op      522225 B/op        23 allocs/op
BenchmarkBufferConcat-8      7758     162543 ns/op      423537 B/op        13 allocs/op
BenchmarkByteConcat-8        8756     153661 ns/op      628721 B/op        24 allocs/op
BenchmarkPreByteConcat-8    14656      83113 ns/op      212992 B/op         2 allocs/op
PASS
ok      github.com/go-test/benckmark    9.606s

從基准測試的結果來看,使用 +fmt.Sprintf 的效率是最低的,和其余的方式相比,性能相差約 1000 倍,而且消耗了超過 1000 倍的內存。當然 fmt.Sprintf 通常是用來格式化字符串的,一般不會用來拼接字符串。

strings.Builderbytes.Buffer[]byte 的性能差距不大,而且消耗的內存也十分接近,性能最好且消耗內存最小的是 preByteConcat,這種方式預分配了內存,在字符串拼接的過程中,不需要進行字符串的拷貝,也不需要分配新的內存,因此性能最好,且內存消耗最小。

1.3 建議

綜合易用性和性能,一般推薦使用 strings.Builder 來拼接字符串。

這是 Go 官方對 strings.Builder 的解釋:

A Builder is used to efficien'tly build a string using Write methods. It minimizes memory copying

string.Builder 也提供了預分配內存的方式 Grow

func builderConcat(n int, str string) string {
	var builder strings.Builder
	builder.Grow(n * len(str))
	for i := 0; i < n; i++ {
		builder.WriteString(str)
	}
	return builder.String()
}

使用了 Grow 優化后的版本的 benchmark 結果如下:

1  BenchmarkBuilderConcat-8      8472      150648 ns/op    522225 B/op     23 allocs/op
2  BenchmarkBuilderConcat-8     15556       77780 ns/op    106496 B/op      1 allocs/op

與預分配內存的 []byte 相比,因為省去了 []byte 和字符串(string) 之間的轉換,內存分配次數還減少了 1 次,內存消耗減半。

2. 性能背后的原理

2.1 比較 strings.Builder 和 +

strings.Builder+ 性能和內存消耗差距如此巨大,是因為兩者的內存分配方式不一樣。

字符串在 Go 語言中是不可變類型,占用內存大小是固定的,當使用 + 拼接 2 個字符串時,生成一個新的字符串,那么就需要開辟一段新的空間,新空間的大小是原來兩個字符串的大小之和。拼接第三個字符串時,再開辟一段新空間,新空間大小是三個字符串大小之和,以此類推。假設一個字符串大小為 10 byte,拼接 1w 次,需要申請的內存大小為:

10 + 2 * 10 + 3 * 10 + ... + 10000 * 10 byte = 500 MB 

strings.Builderbytes.Buffer,包括切片 []byte 的內存是以倍數申請的。例如,初始大小為 0,當第一次寫入大小為 10 byte 的字符串時,則會申請大小為 16 byte 的內存(恰好大於 10 byte 的 2 的指數),第二次寫入 10 byte 時,內存不夠,則申請 32 byte 的內存,第三次寫入內存足夠,則不申請新的,以此類推。在實際過程中,超過一定大小,比如 2048 byte 后,申請策略上會有些許調整。我們可以通過打印 builder.Cap() 查看字符串拼接過程中,strings.Builder 的內存申請過程。

func TestBuilderConcat(t *testing.T) {
	var str = randomString(10)
	var builder strings.Builder
	cap := 0
	for i := 0; i < 10000; i++ {
		if builder.Cap() != cap {
			fmt.Print(builder.Cap(), " ")
			cap = builder.Cap()
		}
		builder.WriteString(str)
	}
}

運行結果如下:

$ go test -run="TestBuilderConcat" . -v
=== RUN   TestBuilderConcat
16 32 64 128 256 512 1024 2048 2688 3456 4864 6144 8192 10240 13568 18432 24576 32768 40960 57344 73728 98304 122880 --- PASS: TestBuilderConcat (0.00s)
PASS
ok      github.com/go-test/benckmark    0.002s

我們可以看到,2048 以前按倍數申請,2048 之后,以 640 遞增,最后一次遞增 24576 到 122880。總共申請的內存大小約 0.52 MB,約為上一種方式的千分之一。

16 + 32 + 64 + ... + 122880 = 0.52 MB

2.2 比較 strings.Builder 和 bytes.Buffer

strings.Builderbytes.Buffer 底層都是 []byte 數組,但 strings.Builder 性能比 bytes.Buffer 略快約 10% 。一個比較重要的區別在於,bytes.Buffer 轉化為字符串時重新申請了一塊空間,存放生成的字符串變量,而 strings.Builder 直接將底層的 []byte 轉換成了字符串類型返回了回來。

  • bytes.Buffer
// To build strings more efficiently, see the strings.Builder type.
func (b *Buffer) String() string {
	if b == nil {
		// Special case, useful in debugging.
		return "<nil>"
	}
	return string(b.buf[b.off:])
}
  • strings.Builder
// String returns the accumulated string.
func (b *Builder) String() string {
	return *(*string)(unsafe.Pointer(&b.buf))
}

bytes.Buffer 的注釋中還特意提到了:

To build strings more efficiently, see the strings.Builder type.


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM