【注意】最后更新于 June 23, 2021,文中内容可能已过时,请谨慎使用。
     
   
    
      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.Builder、bytes.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)。
 
- 分别调用 
lastNumsBySlice 和 lastNumsByCopy 取切片的最后两个元素。 
- 最后然后打印程序所占用的内存。
 
 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 的每一个元素的类型是一个结构体类型 Item,Item 由两个字段构成,一个类型是 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:
easyjson
使用空结构体
空结构体占用空间为0
1
2
  | 
//在 Go 语言中,我们可以使用 unsafe.Sizeof 计算出一个数据类型实例需要占用的字节数。
fmt.Println(unsafe.Sizeof(struct{}{})) //输出0
  | 
 
特殊变量:zerobase
空结构体是没有内存大小的结构体。这句话是没有错的,但是更准确的来说,其实是有一个特殊起点的,那就是 zerobase 变量,这是一个 uintptr 全局变量,占用 8 个字节。当在任何地方定义无数个 struct {} 类型的变量,编译器都只是把这个 zerobase 变量的地址给出去。换句话说,在 golang 里面,涉及到所有内存 size 为 0 的内存分配,那么就是用的同一个地址 &zerobase 。