【注意】最后更新于 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
。