Golang中有很多容易被忽略的代码性能问题,本文会总结一下,不定时更新

字符串拼接

在 Go 语言中,字符串(string) 是不可变的,拼接字符串事实上是创建了一个新的字符串对象。如果代码中存在大量的字符串拼接,对性能会产生严重的影响。

在 Go 语言中,常见的字符串拼接方式有如下 5 种:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
//使用+
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(预分配切片的容量)
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)
}

使用benchmark进行性能测试:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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) }

//每个 benchmark 用例中,生成了一个长度为 10 的字符串,并拼接 1w 次。
//运行该实例:
$ go test -bench="Concat$" -benchmem .
goos: darwin
goarch: amd64
pkg: string_splice
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkPlusConcat-12                22          54710179 ns/op        530997033 B/op     10014 allocs/op
BenchmarkSprintfConcat-12             13         100785277 ns/op        834397218 B/op     37469 allocs/op
BenchmarkBuilderConcat-12          13022            114200 ns/op          505841 B/op         24 allocs/op
BenchmarkBufferConcat-12           10000            106262 ns/op          423537 B/op         13 allocs/op
BenchmarkByteConcat-12             10000            100763 ns/op          612337 B/op         25 allocs/op
BenchmarkPreByteConcat-12          22982             58192 ns/op          212992 B/op          2 allocs/op
PASS
ok      string_splice   14.923s

分析

从基准测试的结果来看,使用 +fmt.Sprintf 的效率是最低的,和其余的方式相比,性能相差约 1000 倍,而且消耗了超过 1000 倍的内存。当然 fmt.Sprintf 通常是用来格式化字符串的,一般不会用来拼接字符串。

strings.Builderbytes.Buffer[]byte 的性能差距不大,而且消耗的内存也十分接近,性能最好且消耗内存最小的是 preByteConcat,这种方式预分配了内存,在字符串拼接的过程中,不需要进行字符串的拷贝,也不需要分配新的内存,因此性能最好,且内存消耗最小。

结论

综合易用性和性能,一般推荐使用 strings.Builder 来拼接字符串。

这是 Go 官方对 strings.Builder 的解释:

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

Builder使用写方法高效地构建字符串。它最大限度地减少了内存复制。

string.Builder 也提供了预分配内存的方式 Grow

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
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()
}

//使用 []byte(预分配切片的容量)
BenchmarkPreByteConcat-12          22982             58192 ns/op          212992 B/op          2 allocs/op
//使用string.Builder
BenchmarkBuilder-12                12116            106057 ns/op          505841 B/op         24 allocs/op
//使用string.Builder(用Grow预分配容量)
BenchmarkGrowBuilder-12            22615             56309 ns/op          106496 B/op          1 allocs/op

//与预分配内存的 []byte 相比,因为省去了 []byte 和字符串(string) 之间的转换,内存分配次数还减少了 1 次,内存消耗减半。

切片(slice)

先看两个函数

  • 两个函数的作用是一样的,取origin切片的最后 2 个元素。
  • 第一个函数直接在原切片基础上进行切片。
  • 第二个函数创建了一个新的切片,将origin的最后两个元素拷贝到新切片上,然后返回新切片。
1
2
3
4
5
6
7
8
9
func lastNumsBySlice(origin []int) []int {
	return origin[len(origin)-2:]
}

func lastNumsByCopy(origin []int) []int {
	result := make([]int, 2)
	copy(result, origin[len(origin)-2:])
	return result
}

写两个测试用例来比较这两种方式的性能差异

  • generateWithCap 用于随机生成 n 个 int 整数,64位机器上,一个 int 占 8 Byte,128 * 1024 个整数恰好占据 1 MB 的空间。
  • printMem 用于打印程序运行时占用的内存大小。
  • 随机生成一个大小为 1 MB 的切片( 128*1024 个 int 整型,恰好为 1 MB)。
  • 分别调用 lastNumsBySlicelastNumsByCopy 取切片的最后两个元素。
  • 最后然后打印程序所占用的内存。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
func generateWithCap(n int) []int {
	rand.Seed(time.Now().UnixNano())
	nums := make([]int, 0, n)
	for i := 0; i < n; i++ {
		nums = append(nums, rand.Int())
	}
	return nums
}

func printMem(t *testing.T) {
	t.Helper()
	var rtm runtime.MemStats
	runtime.ReadMemStats(&rtm)
	t.Logf("%.2f MB", float64(rtm.Alloc)/1024./1024.)
}

func testLastChars(t *testing.T, f func([]int) []int) {
	t.Helper()
	ans := make([][]int, 0)
	for k := 0; k < 100; k++ {
		origin := generateWithCap(128 * 1024) // 1M
		ans = append(ans, f(origin))
	}
	printMem(t)
	_ = ans
}

func TestLastCharsBySlice(t *testing.T) { testLastChars(t, lastNumsBySlice) }
func TestLastCharsByCopy(t *testing.T)  { testLastChars(t, lastNumsByCopy) }

运行结果如下:

结果差异非常明显,lastNumsBySlice 耗费了 100.18 MB 内存,也就是说,申请的 100 个 1 MB 大小的内存没有被回收。因为切片虽然只使用了最后 2 个元素,但是因为与原来 1M 的切片引用了相同的底层数组,底层数组得不到释放,因此,最终 100 MB 的内存始终得不到释放。而 lastNumsByCopy 仅消耗了 3.18 MB 的内存。这是因为,通过 copy,指向了一个新的底层数组,当 origin 不再被引用后,内存会被垃圾回收(garbage collector, GC)。

1
2
3
4
5
6
7
8
9
go test -run=^TestLastChars -v
=== RUN   TestLastCharsBySlice
    slice_test.go:49: 100.18 MB
--- PASS: TestLastCharsBySlice (0.24s)
=== RUN   TestLastCharsByCopy
    slice_test.go:50: 3.18 MB
--- PASS: TestLastCharsByCopy (0.23s)
PASS
ok      slice   0.976s

如果我们在循环中,显示地调用 runtime.GC(),效果会更加地明显:

lastNumsByCopy 内存占用直接下降到 0.19 MB。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
func testLastChars(t *testing.T, f func([]int) []int) {
	t.Helper()
	ans := make([][]int, 0)
	for k := 0; k < 100; k++ {
		origin := generateWithCap(128 * 1024) // 1M
		ans = append(ans, f(origin))
		runtime.GC()
	}
	printMem(t)
	_ = ans
}

go test -run=^TestLastChars -v
=== RUN   TestLastCharsBySlice
    slice_test.go:50: 100.18 MB
--- PASS: TestLastCharsBySlice (0.28s)
=== RUN   TestLastCharsByCopy
    slice_test.go:51: 0.19 MB
--- PASS: TestLastCharsByCopy (0.23s)
PASS
ok      slice   1.195s

range和for性能比较

循环[]int:

  • generateWithCap 用于生成长度为 n 元素类型为 int 的切片。
  • 从最终的结果可以看到,遍历 []int 类型的切片,for 与 range 性能几乎没有区别。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
func generateWithCap(n int) []int {
	rand.Seed(time.Now().UnixNano())
	nums := make([]int, 0, n)
	for i := 0; i < n; i++ {
		nums = append(nums, rand.Int())
	}
	return nums
}

func BenchmarkForIntSlice(b *testing.B) {
	nums := generateWithCap(1024 * 1024)
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		length := len(nums)
		var tmp int
		for k := 0; k < length; k++ {
			tmp = nums[k]
		}
		_ = tmp
	}
	b.StopTimer()
}

func BenchmarkRangeIntSlice(b *testing.B) {
	nums := generateWithCap(1024 * 1024)
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		var tmp int
		for _, num := range nums {
			tmp = num
		}
		_ = tmp
	}
	b.StopTimer()
}

go test -bench=IntSlice$ .
goos: darwin
goarch: amd64
pkg: range_for
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkForIntSlice-12             4018            270420 ns/op
BenchmarkRangeIntSlice-12           4238            278649 ns/op
PASS
ok      range_for       3.869s

循环[]struct:

  • 仅遍历下标的情况下,for 和 range 的性能几乎是一样的。
  • items 的每一个元素的类型是一个结构体类型 ItemItem 由两个字段构成,一个类型是 int,一个是类型是 [4096]byte,也就是说每个 Item 实例需要申请约 4KB 的内存。
  • 在这个例子中,for 的性能大约是 range (同时遍历下标和值) 的 1000 倍。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
type Item struct {
	id  int
	val [4096]byte
}

func BenchmarkForStruct(b *testing.B) {
	var items [1024]Item
	for i := 0; i < b.N; i++ {
		length := len(items)
		var tmp int
		for k := 0; k < length; k++ {
			tmp = items[k].id
		}
		_ = tmp
	}
}

func BenchmarkRangeIndexStruct(b *testing.B) {
	var items [1024]Item
	for i := 0; i < b.N; i++ {
		var tmp int
		for k := range items {
			tmp = items[k].id
		}
		_ = tmp
	}
}

func BenchmarkRangeStruct(b *testing.B) {
	var items [1024]Item
	for i := 0; i < b.N; i++ {
		var tmp int
		for _, item := range items {
			tmp = item.id
		}
		_ = tmp
	}
}

go test -bench=Struct$ . 
goos: darwin
goarch: amd64
pkg: range_for
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkForIndexStruct-12               4349650               264.9 ns/op
BenchmarkRangeIndexStruct-12             3725941               295.4 ns/op
BenchmarkRangeStruct-12                     4047            261774 ns/op
PASS
ok      range_for       4.953s

*循环[]struct{}

切片元素从结构体 Item 替换为指针 *Item 后,for 和 range 的性能几乎是一样的。而且使用指针还有另一个好处,可以直接修改指针对应的结构体的值。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
func generateItems(n int) []*Item {
	items := make([]*Item, 0, n)
	for i := 0; i < n; i++ {
		items = append(items, &Item{id: i})
	}
	return items
}

func BenchmarkForPointer(b *testing.B) {
	items := generateItems(1024)
	for i := 0; i < b.N; i++ {
		length := len(items)
		var tmp int
		for k := 0; k < length; k++ {
			tmp = items[k].id
		}
		_ = tmp
	}
}

func BenchmarkRangePointer(b *testing.B) {
	items := generateItems(1024)
	for i := 0; i < b.N; i++ {
		var tmp int
		for _, item := range items {
			tmp = item.id
		}
		_ = tmp
	}
}

go test -bench=Pointer$ .
goos: darwin
goarch: amd64
pkg: range_for
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkForPointer-12            771190              1377 ns/op
BenchmarkRangePointer-12          871620              1372 ns/op
PASS
ok      range_for       2.994s

总结

range 在迭代过程中返回的是迭代值的拷贝,如果每次迭代的元素的内存占用很低,那么 for 和 range 的性能几乎是一样,例如 []int。但是如果迭代的元素内存占用较高,例如一个包含很多属性的 struct 结构体,那么 for 的性能将显著地高于 range,有时候甚至会有上千倍的性能差异。对于这种场景,建议使用 for,如果使用 range,建议只迭代下标,通过下标访问迭代值,这种使用方式和 for 就没有区别了。如果想使用 range 同时迭代下标和值,则需要将切片/数组的元素改为指针,才能不影响性能。

Json

Json 作为一种重要的数据格式,具有良好的可读性以及自描述性,广泛地应用在各种数据传输场景中。

Go 语言里面原生支持了这种数据格式的序列化以及反序列化,内部使用反射机制实现,性能有点差,在高度依赖 json 解析的应用里,往往会成为性能瓶颈。

json-iterator:

  • 使用非常方便,同步提供官方json标准库一样的接口,另外还提供了一系列更加灵活的操作方法

    1
    2
    3
    4
    
    val := []byte(`{"ID":1,"Name":"Reds","Colors":["Crimson","Red","Ruby","Maroon"]}`)
    
    // 仅解析 Colors 字段,并直接得到 string 类型
    str := jsoniter.Get(val, "Colors", 0).ToString()
    
  • 标准库6倍的性能,而且这个性能是在不使用代码生成的前提下获得的,关于它是如何做到的这篇文章有详尽的解释。

  • 从标准库迁移非常方便,只需要一行代码

    1
    
    json := jsoniter.ConfigCompatibleWithStandardLibrary
    
  • 使用方法:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    
    //序列化Marshal
    // 直接把结构体转化成字符串
    MarshalToString(v interface{}) (string, error)
    //把结构体转化成json,兼容go标准库encoding/json的序列化方法,返回一个字节切片和错误
    Marshal(v interface{}) ([]byte, error)
    //转化成字节切片,第一个参数是结构体对象,第二个参数是前缀字符串必须为"",第三个参数为缩进表示,只能是空格
    MarshalIndent(v interface{}, prefix, indent string) ([]byte, error)
    
    //反序列化Unmarshal
    //把字节切片的JSON格式转换成对应的结构体
    Unmarshal(data []byte, v interface{}) error 
    //把字符串的JSON格式转换成对应的结构体
    UnmarshalFromString(str string, v interface{}) error 
    
    //jsoniter.Get
    //获取深层嵌套JSON结构的值的快速方法
    Get(data []byte, path ...interface{}) Any
    
    //NewDecoder
    //通过流的方式操作json,适用于大文件json
    //NewDecoder适用于json/stream NewDecoder API。
    //new decoder返回从r读取的新解码器。
    //不是json/编码解码器,而是返回一个解码器
    //更多信息请参考https://godoc.org/encoding/json#NewDecoder
    NewDecoder(reader io.Reader) *Decoder 
    
    
    

easyjson

  • easyjson增加一个预编译的过程,预先生成对应结构的序列化反序列化代码,除此之外,easyjson 还放弃了一些原生库里面支持的一些不必要的特性,比如:key 类型声明,key 大小写不敏感等等,以达到更高的性能

  • 生成代码执行 easyjson -all <file.go> 即可,如果不指定 -all 参数,只会对带有 //easyjson:json 的结构生成代码

    1
    2
    3
    4
    
    //easyjson:json
    type A struct {
        Bar string
    }
    

使用空结构体

空结构体占用空间为0

1
2
//在 Go 语言中,我们可以使用 unsafe.Sizeof 计算出一个数据类型实例需要占用的字节数。
fmt.Println(unsafe.Sizeof(struct{}{})) //输出0

特殊变量:zerobase

空结构体是没有内存大小的结构体。这句话是没有错的,但是更准确的来说,其实是有一个特殊起点的,那就是 zerobase 变量,这是一个 uintptr 全局变量,占用 8 个字节。当在任何地方定义无数个 struct {} 类型的变量,编译器都只是把这个 zerobase 变量的地址给出去。换句话说,在 golang 里面,涉及到所有内存 size 为 0 的内存分配,那么就是用的同一个地址 &zerobase