GO MAP原理
hmap结构
1 | type hmap struct { |
1 | type bmap struct { |
初始化
make
通过make函数和hint指定元素数量来初始化map
1 | hash := make(map[string]int, hint) |
字面量
目前的现代编程语言基本都支持使用字面量的方式初始化哈希,一般都会使用 key: value 的语法来表示键值对,Go 语言中也不例外:
1 | hash := map[string]int{ |
当哈希表中的元素数量少于或者等于 25 个时,编译器会将字面量初始化的结构体转换成以下的代码,将所有的键值对一次加入到哈希表中:
1 | hash := make(map[string]int, 3) |
一旦哈希表中元素的数量超过了 25 个,编译器会创建两个数组分别存储键和值,这些键值对会通过如下所示的 for 循环加入哈希:
1 | hash := make(map[string]int, 26) |
无论是字面量
或是make
方式map底层都是通过makemap
函数来创建
makemap
1 | func makemap(t *maptype, hint int, h *hmap) *hmap { |
makemap
函数通过指定hint
来通过B
计算bucket数量
计算公式
1 | hint ≤ 2^B * 6.5 |
通过B数量指定hmap会创建2^B个桶和一些溢出桶
1 | if b >= 4 { |
- 当桶的数量小于 2^4 时,由于数据较少、使用溢出桶的可能性较低,会省略创建的过程以减少额外开销;
- 当桶的数量多于 2^4 时,会额外创建 2^𝐵−4 个溢出桶;
扩容
为什么需要扩容,当溢出桶
承装元素超过8个时,溢出桶的mapextra
指向新的溢出桶,直到能够满足承装元素数目,此时,hash查找会退化成链表查找,时间复杂度为O(n)
扩容判断条件:
- 当前未在扩容状态并且负载因子>=6.5
- 有太多的
溢出桶
map扩容为渐进式扩容,只在map操作当前桶时才对当前桶进行扩容1
2
3
4if !h.growing() && (overLoadFactor(h.count+1, h.B) || tooManyOverflowBuckets(h.noverflow, h.B)) {
hashGrow(t, h)
goto again // Growing the table invalidates everything, so try again
}
扩容步骤分为hashGrow
和growWork
hashGrow
不做桶元素迁移,只是将当前桶指向oldbuckets,然后创建等量的buckets和溢出桶作为newbuckets指向hmap.buckets1
2
3
4
5
6h.B += bigger #更新B
h.flags = flags #更新扩容状态
h.oldbuckets = oldbuckets
h.buckets = newbuckets
h.nevacuate = 0 #扩容计数器
h.noverflow = 0 #溢出桶数量- growWork会在元素赋值是触发当前桶操作,然后对当前桶进行扩容,将将旧桶数据驱逐到新桶,操作步骤在growWork.evacuate
evacuate里的扩容方式分为等量扩容和非等量扩容
1 | if !h.sameSizeGrow() { |
等量扩容
对hash后的key后B位计算桶号,然后将元素等量分配
例如:hash(a)的二进制为0101110001010110,扩容前B=2,桶号为10=2,扩容后桶号为110,桶号为110=6,将元素平均分配到这两个桶
非等量扩容
非等量扩容不扩容桶的数量,由于之前产生过很多溢出桶,但是溢出桶的元素很稀疏,所以只是将桶序号重新整理,清到多余的溢出桶,然后整理到新的buckets,
##查找和写入
查找
- 查找主要分为
mapaccess1
和mapaccess2
两个函数,区别就是mapaccess2
在找到目标值时会多返回一个true,在未找到时会返回false - 查找流程hmap里通过tophash找到对应buckets桶号,再到bmap里区寻找tophash高8位对应的key,如果不在当前桶就去溢出桶里找,如果溢出桶找不到说明元素不在map,返回false
- 如果查找时正在扩容,会判断oldbucktes是否为nil,不为nil找旧桶,缩减m找到旧桶编号去找旧桶元素位置
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15//hash&m找到桶编号作为偏移值找到对应的bmap结构体
b := (*bmap)(add(h.buckets, (hash&m)*uintptr(t.bucketsize)))
if c := h.oldbuckets; c != nil { //存在旧桶,说明正在扩容状态中
if !h.sameSizeGrow() { //判断是否翻倍扩容
m >>= 1 //翻倍扩容时,新的桶数是旧的2倍,m需要减半才能找到旧桶编号
}
oldb := (*bmap)(add(c, (hash&m)*uintptr(t.bucketsize))) //找到对应的旧桶位置
if !evacuated(oldb) {
b = oldb //如果旧桶没有完成数据迁移,那么更新b指向旧桶bmap
}
}
top := tophash(hash) //取hash值高八位,因为bmp.tophash中0-4是标志位,所以hash值小于5的自动加5
bucketloop:
//遍历当前bmp和溢出桶
...
写入
- 函数首先会检查
map
的标志位flags
。如果flags
的写标志位此时被置 1 了,说明有其他协程在执行“写”操作,进而导致程序panic
。这也说明了map
对协程是不安全的。 - 扩容是渐进式的,如果
map
处在扩容的过程中,那么当key
定位到了某个bucket
后,需要确保这个bucket
对应的老bucket
完成了迁移过程。即老bucket
里的key
都要迁移到新的bucket
中来(分裂到 2 个新bucket
),才能在新的bucket
中进行插入或者更新的操作。
上面说的操作是在函数靠前的位置进行的,只有进行完了这个搬迁操作后,我们才能放心地在新bucket
里定位key
要安置的地址,再进行之后的操作。 - 如果这个
bucket
的8
个key
都已经放置满了,那在跳出循环后,发现inserti
和insertk
都是空,这时候需要在bucket
后面挂上overflow bucket
。当然,也有可能是在overflow bucket
后面再挂上一个overflow bucket
。这就说明,太多key hash
到了此bucket
。
在正式安置key
之前,还要检查map
的状态,看它是否需要进行扩容。如果满足扩容的条件,就主动触发一次扩容操作。
删除
- 首先会检查
h.flags
标志,如果发现写标位是 1,直接panic
,因为这表明有其他协程同时在进行写操作。 - 计算 key 的哈希,找到落入的
bucket
。检查此map
如果正在扩容的过程中,直接触发一次搬迁操作。 - 删除操作同样是两层循环,核心还是找到
key
的具体位置。寻找过程都是类似的,在bucket
中挨个cell
寻找。 - 找到对应位置后,对
key
或者value
进行“清零”操作: 最后,将count
值减1
,将对应位置的tophash
值置成Empty
。