GOLANG
最近我在 Stack Overflow 上发现了一个有趣的问题,关于在 Golang 中使用接口作为值(map[int]interface{}
)与使用空结构体作为值(map[int]struct{}
)时,Golang maps 的内存分配问题。提问者设置了两个基准测试来比较这两种 map 类型,并发现了一些奇怪的结果。测试如下:
下面的代码中,我们有两个函数,每个函数都会创建一个 map,并重复执行一个零值赋值一定次数。也就是说,在执行结束时,map 将有固定数量的条目,每个条目将接收类型的零值(对于接口来说是 nil
,对于空结构体来说是 struct{}{}
)。
package main
func main() {}
func MapWithInterface() {
m := map[int]interface{}{}
for i := 1; i <= 100; i++ {
m[i] = nil
}
}
func MapWithEmptyStruct() {
m := map[int]struct{}{}
for i := 1; i <= 100; i++ {
m[i] = struct{}{}
}
}
基准测试结果如下:
package main
import "testing"
func Benchmark_Interface(b *testing.B) {
for i := 0; i < b.N; i++ {
MapWithInterface()
}
}
func Benchmark_EmptyStruct(b *testing.B) {
for i := 0; i < b.N; i++ {
MapWithEmptyStruct()
}
}
这些基准测试的结果如下所示:
goos: darwin
goarch: amd64
pkg: awesomeProject1
Benchmark_Interface-8 130419 8949 ns/op 7824 B/op 7 allocs/op
Benchmark_EmptyStruct-8 165147 6964 ns/op 3070 B/op 17 allocs/op
PASS
ok awesomeProject1 3.122s
这两个 map 的条目都被分配了零值(nil
和 struct{}{}
,即不需要分配的值)。但是我们发现空结构体版本运行得更快,使用的内存更少,但是它却进行了更多的分配。为什么会出现这种情况呢?这两个基准测试看起来如此相似,为什么会有如此不同的结果呢?
TL;DR
Golang 中的 map 内部设计高度优化,以实现更好的性能和内存管理。map 会跟踪可以持有指针的键和值。如果 bucket 中的条目不能持有指针,map 就会创建溢出 bucket,以避免垃圾回收(GC)的不必要开销,这会导致更多的分配和更好的性能(map[int]struct{}
的情况)。
详细解释
在回答这个问题之前,我们需要了解 map 的初始化和 map 结构。我们将先介绍这些主题,然后分析一些基准测试,以了解我们在这里尝试理解的两种 map 类型的性能。我创建了一个存储库,其中包含一些测试,以帮助理解本文的答案。因此,如果你在文本中看到对测试或基准测试的引用,它可能在存储库中。
Map 的初始化
map 有两种初始化方法:
make(map[int]string)
:当我们不知道要添加到 map 中的条目数量时使用。make(map[int]string, hint)
:当我们有一个要添加到 map 中的条目数量的想法时使用。hint
是 map 的初始容量的估计值。
无论我们选择哪种初始化方法,map 都是可变的,并且会根据需要增长。但是,第二种方法为至少 hint
个条目预分配内存,这会提高性能。
Map 的结构
在 Go 中,map 是一个哈希表,将其键值对存储到 bucket 中。每个 bucket 是一个数组,最多可容纳 8 个条目。默认的 bucket 数量为 1。当每个 bucket 中的条目数量达到平均负载(也称为负载因子)时,map 将通过将 bucket 数量加倍来变大。每次 map 增长时,都会为新条目分配内存。实际上,每当 bucket 的负载达到 6.5 或更高时,map 就会增长。这个值是硬编码的,并且被选择以优化内存使用。
在幕后,map 是指向 hmap
结构的指针。还有 map
结构,它包含一些有关 map 类型的信息。Golang maps 的源代码可以在此处找到:
https://github.com/golang/go/blob/master/src/runtime/map.go
正如你在上面的链接中所看到的,map 的内部结构很复杂。在下面的链接中,你可以找到一些关于如何“黑掉”map类型的见解。Aleksandr Kochetkov 做了一个非常好的工作,展示了一些 map 内部的细节。
https://hackernoon.com/some-insights-on-maps-in-golang-rm5v3ywh
https://play.golang.org/p/NaoC8fkmy9x
需要注意的一件重要事情是,maps 会跟踪可以持有指针的键和值。如果 bucket 中的条目不能持有任何指针,则将标记该 bucket 为不包含指针,并且 map 将创建溢出 bucket(这意味着更多的内存分配)。这避免了 GC 的不必要开销。请参见这个结构体中的评论(第 132 行),以及这个帖子 作为参考。
基准测试分析
空结构体 struct{}
没有字段,也不能持有任何指针。因此,在空结构体的情况下,bucket 将被标记为 不包含指针 。这将避免在 map 中进行不必要的扫描,我们可以期望获得更好的执行速度。此外,我们还可以期望 map[int]struct{}
类型的 map 进行更多的内存分配,因为它在增长时会创建更多的溢出 bucket。interface{}
类型与空结构体不同,它可以包含任何值,包括指针。为了理解这对映射性能的影响,我们需要了解映射桶如何跟踪所有指针的内存前缀大小(ptrdata 字段,第33行)。能够保存指针的映射类型不会分配额外的溢出桶。但是,这些类型需要被GC扫描。
在映射的内部实现中,我们可以看到ptrdata
字段如何用于决定是否创建更多的溢出桶(map.go,第265行)。请参考此链接,查看map[int]struct{}
和map[int]interface{}
的所有指针占用的内存前缀大小。
当我们查看CPU分析时,Benchmark_Interface
和Benchmark_EmptyStruct
之间的差异就显而易见了。Benchmark_Interface
没有(*hmap)createOverflow
方法,因此会导致额外的内存分配流程。
Benchmark_EmptyStruct CPU profile
Benchmark_EmptyStruct CPU profile (png, svg)
Benchmark_Interface CPU profile
Benchmark_Interface CPU profile (png, svg)
Benchmarks Results
我定制了基准测试,以传递条目数和映射的初始容量(hint
)。以下是执行结果。当条目很少或初始容量大于条目数时,结果基本相同。如果有许多条目和初始容量为0,则会得到相当不同的分配数字。
随着条目数量的增加,当我们比较Interface
和EmptyStruct
时,我们清楚地看到速度上的差异。Interface
基准测试比EmptyStruct
基准测试慢得多。我们还可以看到,当映射的初始容量大于条目数时,映射不需要太多分配,因为为所有条目预先分配了空间。
Conclusion
导致Stack Overflow上的问题的不同结果与性能和内存管理的映射优化有关。类型为map[int]interface{}
的映射较慢,因为它们在GC扫描可以保存指针的桶时会出现性能下降。类型为map[int]struct{}
的映射使用的内存较少,因为它们实际上使用的内存较少 😄。不需要为空结构体分配空间(Test_EmptyStructValueSize
表明struct{}{}
大小为零)。尽管nil
是interface{}
的零值,但interface{}
类型需要一些空间来存储任何值(TestNilInterfaceValueSize
测试显示存储nil
值的interface{}
类型的大小不为零)。最后,空结构体基准测试分配更多,因为map[int]struct{}
类型需要更多的溢出桶(为了性能优化),因为其桶不保存任何指针。
译自:https://levelup.gitconnected.com/memory-allocation-and-performance-in-golang-maps-b267b5ad9217
评论(0)