Golang基础数据结构—字符串

410 阅读12分钟

1. 字符串简介

由于Go语言的源代码要求是UTF8编码,导致Go源代码中出现的字符串面值常量一般也是UTF8编码的。源代码中的文本字符串通常被解释为采用UTF8编码的Unicode码点(rune)序列。因为字节序列对应的是只读的字节序列,因此字符串可以包含任意的数据,包括byte0。我们也可以用字符串表示GBK等非UTF8编码的数据,不过这种时候将字符串看作是一个只读的二进制数组更准确,因为for range等语法并不能支持非UTF8编码的字符串的遍历。

Go中的切片一样,字符串在运行时也有自身的底层结构,如下,包含指向字节数组的指针和数组的大小。

// StringHeader is the runtime representation of a string.
// It cannot be used safely or portably and its representation may
// change in a later release.
// Moreover, the Data field is not sufficient to guarantee the data
// it references will not be garbage collected, so programs must keep
// a separate, correctly typed pointer to the underlying data.
type StringHeader struct {
   Data uintptr
   Len  int
}

1.2 for-range和len

需要注意的是,Go中的for-range循环遍历直接遍历UTF8解码后的Unicode码点值。譬如以下:

func TestStringForRange(t *testing.T) {
   str := "hello, 世界!"
   for i, s := range str {
      fmt.Printf("idx: %d, value: %c\n", i, s)
   }
}

打印:

idx: 0, value: h
idx: 1, value: e
idx: 2, value: l
idx: 3, value: l
idx: 4, value: o
idx: 5, value: ,
idx: 6, value:  
idx: 7, value: 
idx: 10, value: 
idx: 13, value: 

可以看到,当轮询到的是ASCII的字符时,下标是以1为步长递增的,而在以汉字的轮询中,则其下标步长扩展到了3位,这也和UTF8的编码规则有关。

而字符串的长度取值,取的是其字节长度,而不是UTF8的字符数。

2. 字符串内存结构

和切片一样,字符串的底层也是数组,和切片不同的是,其元素是不可修改的,所以其底层是一个制度的字节数组。每个字符串的长度虽然是固定的,但是字符串的长度并不是字符串类型的一部分,这和数组有着本质区别。可以看到,hello, world的内存结构如下(图片来自于《Go语言高级编程》):

虽然不是切片,但是字符串支持切片操作,如:

str := "hello, world"
hello := str[:5]
world := str[7:]

字符串的底层是不可修改的,比如以下代码,我们将其编译(GOOS=linux GOARCH=amd64 go tool compile -S main.go)成汇编代码时,可以看到,hello, world字符串有一个SRODATA标记,表示编译器将其标记为只读字段。

package main

func main() {
    str := "hello, world"
    println([]byte(str))
}
go.string."hello, world" SRODATA dupok size=12
    0x0000 68 65 6c 6c 6f 2c 20 77 6f 72 6c 64              hello, world

这意味着字符串会分配到只读的内存空间上,也就意味着[]byte(str)的操作会发生内存拷贝。比如,我们将如下代码编译成汇编:

package main

func main() {
    str := "hello, world"

    b := []byte(str)
    println(b)

    str1 := string(b)
    println(str1)
}
0x0037 00055 (main.go:6)        CALL    runtime.stringtoslicebyte(SB)
...
0x0080 00128 (main.go:9)        CALL    runtime.slicebytetostring(SB)

可以发现,在汇编代码里,将字符串转换为字节切片的语句[]byte(str)会被runtime.stringtoslicebyte函数代替,而字节转换为字符串的的语句string(b)会被runtime.slicebytetostring函数替代,下面我们分析以下这两个函数。

func stringtoslicebyte(buf *tmpBuf, s string) []byte {
   var b []byte
   if buf != nil && len(s) <= len(buf) {
      *buf = tmpBuf{}
      b = buf[:len(s)]
   } else {
      b = rawbyteslice(len(s))
   }
   copy(b, s)
   return b
}

可以发现,将字符串转换为字节切片的实现中,会首先预置一个32字节长度的底层数组,只有当数组的长度小于字符串长度时,才会重新申请内存,但是无论如何,都会发生copy,即将字符串的的子层数组复制len(s)个到切片的底层数组去。

// slicebytetostring converts a byte slice to a string.
// It is inserted by the compiler into generated code.
// ptr is a pointer to the first element of the slice;
// n is the length of the slice.
// Buf is a fixed-size buffer for the result,
// it is not nil if the result does not escape.
func slicebytetostring(buf *tmpBuf, ptr *byte, n int) (str string) {
   if n == 0 {
      // Turns out to be a relatively common case.
      // Consider that you want to parse out data between parens in "foo()bar",
      // you find the indices and convert the subslice to string.
      return ""
   }
   if raceenabled {
      racereadrangepc(unsafe.Pointer(ptr),
         uintptr(n),
         getcallerpc(),
         abi.FuncPCABIInternal(slicebytetostring))
   }
   if msanenabled {
      msanread(unsafe.Pointer(ptr), uintptr(n))
   }
   if asanenabled {
      asanread(unsafe.Pointer(ptr), uintptr(n))
   }
   if n == 1 {
      p := unsafe.Pointer(&staticuint64s[*ptr])
      if goarch.BigEndian {
         p = add(p, 7)
      }
      stringStructOf(&str).str = p
      stringStructOf(&str).len = 1
      return
   }

   var p unsafe.Pointer
   if buf != nil && n <= len(buf) {
      p = unsafe.Pointer(buf)
   } else {
      p = mallocgc(uintptr(n), nil, false)
   }
   stringStructOf(&str).str = p
   stringStructOf(&str).len = n
   memmove(p, unsafe.Pointer(ptr), uintptr(n))
   return
}

从上段代码也可以看出,当从字节切片到字符串的转换时,会根据[]byte的长度来决定是否重新分配内存,最后通过memove拷贝数组到字符串。同时,runtime包也提供了slicebytetostringtmp函数实现字节切片到字符串的零拷贝,但是调用者需要保证返回的字符串不会在调用的goroutine修改原切片或与另一个goroutine同步后被使用。

// slicebytetostringtmp returns a "string" referring to the actual []byte bytes.
//
// Callers need to ensure that the returned string will not be used after
// the calling goroutine modifies the original slice or synchronizes with
// another goroutine.
//
// The function is only called when instrumenting
// and otherwise intrinsified by the compiler.
//
// Some internal compiler optimizations use this function.
// - Used for m[T1{... Tn{..., string(k), ...} ...}] and m[string(k)]
//   where k is []byte, T1 to Tn is a nesting of struct and array literals.
// - Used for "<"+string(b)+">" concatenation where b is []byte.
// - Used for string(b)=="foo" comparison where b is []byte.
func slicebytetostringtmp(ptr *byte, n int) (str string) {
   if raceenabled && n > 0 {
      racereadrangepc(unsafe.Pointer(ptr),
         uintptr(n),
         getcallerpc(),
         abi.FuncPCABIInternal(slicebytetostringtmp))
   }
   if msanenabled && n > 0 {
      msanread(unsafe.Pointer(ptr), uintptr(n))
   }
   if asanenabled && n > 0 {
      asanread(unsafe.Pointer(ptr), uintptr(n))
   }
   stringStructOf(&str).str = unsafe.Pointer(ptr)
   stringStructOf(&str).len = n
   return
}

一般而言,slicebytetostringtmp用于编译器内部优化,我试了不少其所举的例子,也没有在正常代码中生成这种汇编,不知道大家有没有办法触发,欢迎留言。

3. 无需复制元素的字符串和字节切片转换

前面说过,字符串和字节切片转换一般都面临着内存分配,但是在一些情况下,当前的官方标准Go编译器对一些简单情形做了优化,从而避免了底层元素的复制。

注意,以下优化的所有验证版本都是Go 1.19,不敢保证后续版本这些优化的有效性。

3.1 range关键字后跟随的:从字符串到字节切片的转换

package main

import (
   "fmt"
   t "testing"
)

func forRangeStr(s string) {
   for i, b := range s {
      _, _ = i, b
   }
}

func forRangeStrByBytes(s string) {
   for i, b := range []byte(s) {
      _, _ = i, b
   }
}

func forRangeStrByBytes1(s string) {
   bs := []byte(s)
   for i, b := range bs {
      _, _ = i, b
   }
}

func forRangeBytes(bs []byte) {
   for i, b := range bs {
      _, _ = i, b
   }
}

func forRangeBytesByStr(bs []byte) {
   for i, b := range string(bs) {
      _, _ = i, b
   }
}

func forRangeBytesByStr1(bs []byte) {
   s := string(bs)
   for i, b := range s {
      _, _ = i, b
   }
}

func main() {
   bs := []byte{32: 'x'}
   s := string(bs)
   stat := func(str string, f func(s string)) int {
      allocs := t.AllocsPerRun(1, func() {
         f(str)
      })
      return int(allocs)
   }

   fmt.Println("forRangeStr:", stat(s, forRangeStr))
   fmt.Println("forRangeStrByBytes:", stat(s, forRangeStrByBytes))
   fmt.Println("forRangeStrByBytes1:", stat(s, forRangeStrByBytes1))

   stat1 := func(bs []byte, f func(b []byte)) int {
      allocs := t.AllocsPerRun(1, func() {
         f(bs)
      })
      return int(allocs)
   }

   fmt.Println("forRangeBytes:", stat1(bs, forRangeBytes))
   fmt.Println("forRangeBytesByStr:", stat1(bs, forRangeBytesByStr))
   fmt.Println("forRangeBytesByStr1:", stat1(bs, forRangeBytesByStr1))
}

打印如下:

forRangeStr: 0
forRangeStrByBytes: 0
forRangeStrByBytes1: 1
forRangeBytes: 0
forRangeBytesByStr: 1
forRangeBytesByStr1: 1

AllocsPerRun函数会打印每次函数运行时向申请的内存次数,还记得前面提到的会首先预置一个32字节长度的底层数组这里,也就是说,当操作的字符串或者字节切片的长度小于等于32的时候,就不会发生堆内存的申请,所以测试代码中bs的长度设置为33,当设置为小于等于32的数据后,所有打印都是0。

从结果可以看出,在range之后,只有从字符串到字节切片的转换不会发生内存分配,反过来会发生内存复制。接下来我们从汇编代码的角度看看这个问题。

"".forRangeStrByBytes STEXT size=32 args=0x10 locals=0x0 funcid=0x0 align=0x0 leaf
   ...
"".forRangeStrByBytes1 STEXT size=112 args=0x10 locals=0x48 funcid=0x0 align=0x0
   ...
   0x002c 00044 (main.go:21)  CALL   runtime.stringtoslicebyte(SB)
   ...
"".forRangeBytesByStr STEXT size=176 args=0x18 locals=0x58 funcid=0x0 align=0x0
   ...
   0x002c 00044 (main.go:34)  CALL   runtime.slicebytetostring(SB)
   ...
"".forRangeBytesByStr1 STEXT size=176 args=0x18 locals=0x58 funcid=0x0 align=0x0
   ...
   0x002c 00044 (main.go:40)  CALL   runtime.slicebytetostring(SB)
   ...

其实我们也可以从汇编的角度验证这个问题,可以看到,在汇编代码中,forRangeStrByBytes这个函数并没有调用runtime.stringtoslicebyte函数,而forRangeStrByBytes1则调用了这个函数,说明会产生内存复制。而对于从字节切片到字符串的转换,则都会调用runtime.slicebytetostring函数,即产生了内存复制。

3.2 用作比较的操作数:从字节切片到字符串的转换

func Equal(s1, s2 []byte) bool {
   return string(s1) == string(s2)
}

func main() {
   bs1 := []byte{32: 'x'}
   bs2 := []byte{32: 'x'}

   stat := func(bs1, bs2 []byte, f func(s1, s2 []byte) bool) int {
      allocs := t.AllocsPerRun(1, func() {
         _ = f(bs1, bs2)
      })
      return int(allocs)
   }

   fmt.Println(stat(bs1, bs2, Equal))
}

比如以上的函数Equal,作为比较时对切片数组的字符串类型转换就不会触发内存复制。这就会导致一下以下函数f1f2的效率要高的多。

package main

import "testing"

func TestString(t *testing.T) {

}

func f1(x, y, z []byte) {
   switch {
   case string(x) == string(y):
      _, _ = x, y
   case string(x) == string(z):
      _, _ = x, z
   }
}

func f2(x, y, z []byte) {
   switch string(x) {
   case string(y):
      _, _ = x, y
   case string(z):
      _, _ = x, z
   }
}

func BenchmarkF1(b *testing.B) {
   x := []byte{32: 'x'}
   y := []byte{32: 'y'}
   z := []byte{32: 'z'}
   for i := 0; i < b.N; i++ {
      f1(x, y, z)
   }
}

func BenchmarkF2(b *testing.B) {
   x := []byte{32: 'x'}
   y := []byte{32: 'y'}
   z := []byte{32: 'z'}
   for i := 0; i < b.N; i++ {
      f2(x, y, z)
   }
}

测试结果如下:

$ go test -bench=. -benchmem
goos: darwin
goarch: arm64
pkg: go_test/string_equal
BenchmarkF1-8           188518736                6.272 ns/op           0 B/op          0 allocs/op
BenchmarkF2-8           25256488                45.92 ns/op          144 B/op          3 allocs/op
PASS
ok      go_test/string_equal    3.522s

3.3 map读操作的键:从字节切片到字符串的转换

package main

import t "testing"

var m = map[string]int{}
var key = []byte{'k', 'e', 'y'}
var n int

func get() {
   n = m[string(key)]
}

func inc() {
   m[string(key)]++
}

func set() {
   m[string(key)] = 123
}

func main() {
   stat := func(f func()) int {
      allocs := t.AllocsPerRun(1, f)
      return int(allocs)
   }
   println(stat(get)) // 0
   println(stat(set)) // 1
   println(stat(inc)) // 1
}

可以看到,对map表的读操作get()不会有内存分配,但是对map表中数据的写操作都会触发内存分配。值得注意的是,此次字节切片长度不大于32也会触发堆内存的操作,调试发现,slicebytetostring的入参buf是nil,具体为何,没有深究。

3.4 如果字符串衔接表达式中有非空字符串常量:从字节切片到字符串的转换

package main

import "testing"

var s = []byte{32: 'x'} // len(s) == 33

func f() string {
   return (" " + string(s) + string(s))[1:]
}

func g() string {
   return string(s) + string(s)
}

var x string

func main() {
   stat := func(add func() string) int {
      c := func() {
         x = add()
      }
      allocs := testing.AllocsPerRun(1, c)
      return int(allocs)
   }

   println(stat(f)) // 1
   println(stat(g)) // 3
}

可以看到,函数f因为操作数中多含有一个空格字符常量,会使得其string(s)不会执行slicebytetostring函数,即不会发生内存复制;唯一一次内存复制发生在最后的拼接。

我们可以看看以下的测试:

package main

import "testing"

var (
   str40 = []byte{39: 'x'} // len(str40) = 40
   str   string
)

func BenchmarkConcat(b *testing.B) {
   for i := 0; i < b.N; i++ {
      str = string(str40) + string(str40)
   }
}

func BenchmarkConcatSplit(b *testing.B) {
   for i := 0; i < b.N; i++ {
      str = string(str40[:32]) + string(str40[32:]) +
         string(str40[:32]) + string(str40[32:])
   }
}

func BenchmarkConcatSplit1(b *testing.B) {
   for i := 0; i < b.N; i++ {
      str = (" " + string(str40[:32]) + string(str40[32:]) +
         string(str40[:32]) + string(str40[32:]))[1:]
   }
}

对于将一个长度为40的字节切片合并为一个字符串,有三种实现方法,第一种是直接拼接,第二种是切换成小于等于32的切片长度再拼接,最后一种是使用了以上的优化,多出一个非空字符串常量。测试的结果如下:

BenchmarkConcat-8               23052577                52.34 ns/op          176 B/op          3 allocs/op
BenchmarkConcatSplit-8          28551374                41.20 ns/op           80 B/op          1 allocs/op
BenchmarkConcatSplit1-8         29324706                39.94 ns/op           96 B/op          1 allocs/op
PASS

最后的结果很有意思,在时间参数上,BenchmarkConcatSplit1表现最优,在空间表现上,貌似BenchmarkConcatSplit表现的更优,其实如果转换为汇编代码,我们就可以分析如下:

  • BenchmarkConcatBenchmarkConcatSplit中的string()操作都发生了内存复制,即调用了slicebytetostring函数;
  • BenchmarkConcat发生的是内存分配(操作切片长度大于32),而BenchmarkConcatSplit发生的是内存复制,所以benchmark没有检测到;
  • 所有测试函数的最后一步的字符串拼接都发生了内存复制,因为BenchmarkConcatSplit1BenchmarkConcatSplit多浪费了至少一个字节,所以其内存效果看起来没有后者好;
  • 其实在string()类型转换操作部分,BenchmarkConcatSplit1BenchmarkConcatSplit的性能是要好的。

4. 字符串和字节切片的零拷贝转换

前面介绍的都是字符串和字节切片之间面临着可能得内存分配的转换,下面将介绍一下字符串和字节切片的零拷贝转换及其注意事项,以字符串转换为字节切片为例,考虑到字节切片和字符串的底层都是字节数组,我们可以写出如下的转换代码:

func unsafeStringToBytes(s string) []byte {
   sh := (*reflect.StringHeader)(unsafe.Pointer(&s))
   bh := &reflect.SliceHeader{
      Data: sh.Data,
      Len:  sh.Len,
      Cap:  sh.Len,
   }

   return *(*[]byte)(unsafe.Pointer(bh))
}

但是注意到reflect.StringHeader的描述:

Moreover, the Data field is not sufficient to guarantee the data it references will not be garbage collected, so programs must keep a separate, correctly typed pointer to the underlying data.

那么,在return语句之前,此时,s不再使用。 然而,在bh.Data中有其底层数组地址的副本,并且由于bh不是从实际切片创建的,因此GC不会将该地址视为引用。 因此,如果GC在这里运行,它将释放s

为了验证以上的观点,我们设计如下的实验,实验主要参考SliceHeader Literals in Go create a GC Race and Flawed Escape-Analysis. Exploitation with unsafe.Pointer on Real-World Code。因为即使GC后,字符串所指向的内存也不一定会被清空或者被占用,所以我们使用很大的内存申请来占用内存,并且显示地在该位置运行runtime.GC()并且加上等待,使之更容易触发。

// 不安全的转换方法
func unsafeStringToBytes(s string) []byte {
   sh := (*reflect.StringHeader)(unsafe.Pointer(&s))
   sliceHeader := &reflect.SliceHeader{
      Data: sh.Data,
      Len:  sh.Len,
      Cap:  sh.Len,
   }

   runtime.GC()
   time.Sleep(1 * time.Nanosecond)

   return *(*[]byte)(unsafe.Pointer(sliceHeader))
}

// 写入log
func TestWriteLog(t *testing.T) {
   f1, err := os.Create("./log")
   if err != nil {
      log.Fatal(err)
   }
   defer f1.Close()

   for i := 0; i <= 1000000; i++ {
      _, err := f1.WriteString("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\nBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB\n")
      if err != nil {
         t.Fatal(err)
      }
   }
}

// 申请内存,积极触发GC
func heapHeapHeap() {
   var a *[]byte
   for {
      tmp := make([]byte, 100000000, 100000000)
      a = &tmp
      _ = a
   }
}

// 主测试函数
func TestStringToBytes(t *testing.T) {
   go heapHeapHeap()

   f1, err := os.Open("./log")
   if err != nil {
      log.Fatal(err)
   }
   defer f1.Close()

   reader := bufio.NewReader(f1)
   count := 1
   var firstChar byte

   for {
      s, _ := reader.ReadString('\n')
      if len(s) == 0 {
         continue
      }
      firstChar = s[0]

      // HERE BE DRAGONS
      bytes2 := unsafeStringToBytes(s)

      _, _ = reader.ReadString('\n')

      if len(bytes2) > 0 && firstChar != bytes2[0] {
         t.Fatalf("win! after %d iterations\n", count)
         os.Exit(0)
      }

      count++
      t.Log(count)
   }
}

首先我们在TestWriteLog中将以下字符串各一百万行写入log文件中,然后在TestStringToBytes中读出数据,并且利用heapHeapHeap不停地申请内存。可以看到,当在unsafeStringToBytesreturn之前发生GC时,很可能会使得第二次的reader.ReadString('\n')可能重用之前的空间,导致firstCharbytes2[0]不相等,在我的测试中,第3519次就发生了预期中的事情。当然,还有几率触发fatal error: found pointer to free object的崩溃。

slice_test.go:171: 3519
slice_test.go:166: win! after 3519 iterations

接下来,我们改写unsafeStringToBytes如下safeStringToBytes,这样就不会触发错误,原因是在转换过程中,始终是有指针指向sh.Data这块地址的,即使在return b之前的位置发生GC,也不会回收这块地址。

func safeStringToBytes(s string) []byte {
   sh := (*reflect.StringHeader)(unsafe.Pointer(&s))

   var b []byte
   pbytes := (*reflect.SliceHeader)(unsafe.Pointer(&b))
   pbytes.Data = sh.Data
   pbytes.Len = sh.Len
   pbytes.Cap = sh.Len

   // 实际使用中去掉以下两行
   runtime.GC()
   time.Sleep(1 * time.Nanosecond)

   return b
}

有关以上的问题,我想为大家做下深入的解析,在Go运行时,其实切片真实的数据结构的定义是runtime.slice结构,而reflect.SliceHeader是提供给用户的运行时结构体,如果我们显示地申明一个切片,其指向底层的是unsafe.Pointer类型的array,这是指针类型,所以GC的时候自然不会释放这块地址,而reflect.SliceHeaderData字段是uintptr类型,这只是一个整型,而不是指针,所以在GC时,指向的地址可能会被回收(如果没有其他指针指向这块地址)。同理,字符串也是一样,其运行时真实的字符串结构是runtime.stringStruct,其str对象指向的也是unsafe.Pointer类型。

这也是为什么在safeStringToBytes显示地申明了一个[]byte类型的bGC时就不会回收底层数据的原因。至于为什么Go要在reflect包中使用uintptr而不是unsafe.Pointer,这就不得而知了。

type slice struct {
   array unsafe.Pointer
   len   int
   cap   int
}

type stringStruct struct {
   str unsafe.Pointer
   len int
}

具体大家可以参考SliceHeader Literals in Go create a GC Race and Flawed Escape-Analysis. Exploitation with unsafe.Pointer on Real-World Code,该实验中甚至在该位置写上了runtime.KeepAlive(s)强行保证s不会被GC,但我觉得这有点多余了,不过写上也不浪费什么,这只是告诉编译器,将这个变量的存活期保证到使用了runtime.KeepAlive()的时刻,参考Go 语言中 runtime.KeepAlive() 方法的一些随笔

需要注意的是,通过safeStringToBytes转换得到的字节切片发生了写操作,还是会panic的,这是因为对只读区发生了写操作,这是不被允许的。

5. 高效的字符串拼接方法

一般而言,字符串拼接有多种方式,常见的有使用+运算符和使用string.Builder,下面我们主要比较以下两种方式。

5.1 明确字符串长度

package main

import (
   "strings"
   "testing"
)

var str1 = string([]byte{11: 'x'})
var str2 = string([]byte{15: 'x'})
var str3 = string([]byte{31: 'x'})
var str4 = string([]byte{32: 'x'})

func concatPlus(a, b, c, d string) string {
   return a + b + c + d
}

func concatBuilder(a, b, c, d string) string {
   var builder strings.Builder
   builder.Grow(len(a) + len(b) + len(c) + len(d))
   builder.WriteString(a)
   builder.WriteString(b)
   builder.WriteString(c)
   builder.WriteString(d)

   return builder.String()
}

func BenchmarkPlus(b *testing.B) {
   for i := 0; i < b.N; i++ {
      _ = concatPlus(str1, str2, str3, str4)
      _ = concatPlus(str4, str4, str4, str4)
   }
}

func BenchmarkBuilder(b *testing.B) {
   for i := 0; i < b.N; i++ {
      _ = concatBuilder(str1, str2, str3, str4)
      _ = concatBuilder(str4, str4, str4, str4)
   }
}
BenchmarkPlus-8         14329576                70.24 ns/op          240 B/op          2 allocs/op
BenchmarkBuilder-8      15144519                78.16 ns/op          240 B/op          2 allocs/op
PASS

可以发现,二者在性能上相差无几,从代码简洁性上来说,我们不如选择+更优。

5.2 未知数量的字符串

var str = string([]byte{9: 'x'})

func concatsPlus(n int, s string) string {
   var str string
   for i := 0; i < n; i++ {
      str += s
   }
   return str
}

func concatsBuilder(n int, s string) string {
   var builder strings.Builder
   for i := 0; i < n; i++ {
      builder.WriteString(s)
   }
   return builder.String()
}

func BenchmarkConcatsPlus(b *testing.B) {
   for i := 0; i < b.N; i++ {
      concatsPlus(10000, str1)
   }
}

func BenchmarkConcatsBuilder(b *testing.B) {
   for i := 0; i < b.N; i++ {
      concatsBuilder(10000, str1)
   }
}
BenchmarkConcatsPlus-8                37          29995482 ns/op        530997611 B/op     10021 allocs/op
BenchmarkConcatsBuilder-8          18770             64770 ns/op          514801 B/op         23 allocs/op
PASS

从结果上分析,在不明确拼接次数的基础上,使用string.Builder比运算符+要高效的多。

5.3 strings.Builder+的内存原理

以上两种情形,二者性能表现差距巨大的原因是因为二者的内存分配方式不一样。

字符串在Go语言中是不可变类型,占用内存大小是固定的,当使用+拼接2个字符串时,生成一个新的字符串,那么就需要开辟一段新的空间,新空间的大小是原来两个字符串的大小之和。拼接第三个字符串时,再开辟一段新空间,新空间大小是三个字符串大小之和,以此类推。假设一个字符串大小为10byte,拼接10000次,需要申请的内存大小为:

(10 + 2 * 10 + 3 * 10 + ... + 10000 * 10)byte ≈ 500 MB 

string.Builder的扩容方式和切片扩容类似,其申请是做了优化的,如下,可以看到,其内存一共用了0.52MB,比+高效的多,和前面benchmark的测试结果也是相符的。

func TestBuilderConcatsCount(t *testing.T) {
   var builder strings.Builder
   cap, total := 0, 0
   for i := 0; i < 10000; i++ {
      if builder.Cap() != cap {
         fmt.Printf("%d ", builder.Cap())
         cap = builder.Cap()
         total += cap
      }
      builder.WriteString(str)
   }
   fmt.Printf("\n")
   fmt.Printf("total: %d", total)
}
16 32 64 128 256 512 896 1408 2048 3072 4096 5376 6912 9472 12288 16384 21760 28672 40960 57344 73728 98304 131072 
total: 514800--- PASS: TestBuilderConcatsCount (0.00s)
PASS

所以,如果无法确认拼接个数的情况下,还是推荐使用string.Builder拼接字符串!

6. 参考文献

1.3 数组、字符串和切片

字符串拼接性能及原理

Go编程优化101

Go SliceHeader 和 StringHeader,你知道吗?

SliceHeader Literals in Go create a GC Race and Flawed Escape-Analysis. Exploitation with unsafe.Pointer on Real-World Code