Map 有时能够返回一个值,有时候能够返回多个值,这是 Map 的特权语法。今天继续从底层角度来聊聊 Go 语言内置数据结构,Map。
Map
Map 中大量类似但又冗余的函数,原因之一便是没有泛型。
1package main
2
3var m = make(map[int] int, 10)
4
5func main() {
6 v1 := m[1]
7 v2, ok := m[2]
8 println(v1, v2, ok)
9}
利用之前提到的工具 go tool objdump 能够获得反汇编后的函数:
- make → runtime.makemap
- 10 → hint
- m[1] → runtime.mapaccess1_fast64
- m[2] → runtime.mapaccess2_fast64
以上这些过程都是编译器帮我完成的,编译器判断赋值语句,如左边有一个值就翻译为 runtime.mapaccess1_fast64, 需要注意以下三点:
- 当 hint 大小大于 8 时,采用的是 makemap
- 当 hint 小于 8 时,采用的是 makemap_small
- map 分配栈上时,不一定会调用 makemap
为什么一个 Go 语言内置数据结构就有这么多情况,那如果 Go 的 20 来个内置数据结构都有这种判定,岂不是底层就有差不多 60 个不同判定结构?确实是这样的,map 中存在大量类似但又冗余的函数,但不至于把性能拉低很多。这种问题主要是原因之一是 Go 语言在 1.16 版本前没有泛型。可喜的是,在 Go 1.17 版本之后将会引入泛型,大家感兴趣已经可以自己去尝试了。
曹大有个预见:有了泛型以后,类似 Map 底层函数中的数据结构很有可能有一波大的代码更新,不过也相比于现在的代码来说会轻松点,毕竟现在重复的代码太多了。
再来看看 Map 的底层结构有哪些:
注:图中有些不严谨的地方,后续会跟进完善,但不影响我们分析。
当我们写了一个 map 数据结构,其实底层对应的是 hamp 这个结构。在 hmap 中,关键的字段可以从图中看到
然后再来看看 Map 元素操作
主要分为三个操作:mapaccess 访问、mapassign 赋值 和 mapdelete 删除。mapaccess 和 mapassign 的原理和操作其实都差不多,具体流程如下:
想要访问一个 map 元素,先对一个 key 做 hash。比如有个 hash values 值是 64 位。这 64 为可以大概分成三部分:开头前八位我们叫做 tophash、最后几位我们叫做 low bits。而这个 low bits 其实是跟 bucket 大小相关的,比如我们 bucket 的大小是 2 的 5次方 32,那么 low bits 就会对 5 做与运算。
具体我们怎么找到,某个元素在 bucket 中存在于什么位置呢?即先对这个 key 做 hash 处理,然后找到 topash 值,根据这个 topash 的值来找到 bucket 中的某个位置。同时在选中之前需要确定 bmap 选的哪个 bucket。
这里的 tophash 其实是和 bmap 的对比,tophash 存在也并不意味着这个 key 是在 Map 中存在的,实际上还需要和 bmap 中的 keys 做个简单对比。 如果 topash 和 keys 都是相等,那么才能说明这个 key 是存在的。
有一点疑问了,既然访问和赋值流程差不多,那有什么区别呢?访问找到就结束,而赋值找到了还需要做一步覆盖,如没找到就要找个空填进去(这里也需要解决 hash 冲突问题)。
总结一下三个常见解决 hash 表冲突的方法:
- 链式寻址
- 拉链法(Go 语言用的这种),哈希冲突如果放不下,那么就一直回链。
- 开放地址法
mapdelete 的操作也类似,找到这个值,然后对比 tophash 和 keys,找到了则置空 empty。
Map 扩容
这也是 map 最麻烦的过程。一般而言,扩容在哪个地方出发?访问,赋值还是删除?一般是在 mapassign 中。load factor 过大 或者 overflow bucket 过多的时候,具体流程如下:
从图中可以看到,搬运过程是需要渐进的。
扩容中
- mapasssign:将命中的 bucket 从 oldbuckets 顺⼿搬运到 buckets 中,顺便再多搬运⼀个 bucket
- mapdelete:将命中的 bucket 从 oldbuckets 顺⼿搬运到 buckets 中,顺便再多搬运⼀个 bucket
- mapaccess: 优先在 oldbuckets 中找,如果命中,则说明这 个 bucket 没有被搬运
需要注意:搬运 bucket x 时,会被该桶的 overflow 桶也⼀并搬完
Map 缺陷
最后,来说说 Map 的缺陷:
- 如果已经扩容,无法进行收缩
1package main
2
3var m = make(map[int]int)
4
5func main() {
6 for i := 0; i < 10000000; i ++ {
7 m[i] = i
8 }
9
10 for i := 0; i < 9990000; i++ {
11 delete(m, i)
12 }
13}
- 保证并发安全时,要手动读写锁,容易出错
1// 没有 sync map 之前,需要自己手动加锁
2package main
3
4import "sync"
5
6type mapWithLock struct {
7 m map[int]int
8 mux sync.RWMutex
9}
10
11func (m *mapWithLock) readMap(idx int) {
12 m.RLock()
13 defer m.RUnlock()
14 v := m[idx]
15 // do a lot things
16}
- 多核心下表现较差
- 难以使用 sync.Pool 进行重用
1var slicePool = sync.Pool{}
2
3// slice can be easily reused
4func processUserRequest1() {
5 sl := slicePool.Get()
6 defer func() {
7 sl := sl[:0]
8 slicePool.Put(sl)
9 }()
10 // process user logic
11}
1var mapPool = sync.Pool{}
2
3// what about map?
4func processUserRequest2() {
5 m := map.Pool.Get()
6 defer func() {
7 // how to reset a map?
8 // FIXME
9 slicePool.Put(m)
10 }()
11 // process user logic
12}
关于 Map 的讲解就到这里,像里面还省略了一些同时涉及到 overflow 和扩容中的操作,这个很复杂。不过基础部分都涉及到了。