哈希碰撞的解决方式
hash 获取桶索引的方式:
取模: hash % m;
与运算: hash & (m-1)
m 代表桶的数量
我们知道当 hash key 都相同,即产生 hash 碰撞,有两种方式解决。
拉链法
先看图理解
我们看到 "John Smith" 与 "Sandra Dee" 的 hash(key) 对应的桶号都相同是 152
152 这个桶会变成一个链表,即大家的key相同,值不同。
如果我们要找 key "Sandra Dee" 的时候,就先通过 hash 函数找到152号桶, 然后遍历链表,找到 “Sandra Dee”。
开放寻址法
从图中看出, "John Smith" 与 "Sandra Dee" 的 hash(key) 对应的桶号都相同是 152,产生 hash 碰撞,
那么后加入的元素 "Sandra Dee" 自动放在 152 相邻的后一个位置 153 号桶,
如果 "Ted Baker" 的 hash 值是153,发现153已经被占用了后,就放在相邻的后一个位置 154 上。
说的题外话,我们仔细思考一下就会发现,所谓 hash map 的时间复杂读是 O(1),那是理论上。
但如果我们的 hash 函数选的不好,散列不均匀,老是发生 hash 碰撞,即 hash map 的时间复杂度就会变成 O(n)。
所以要想 hash map 的性能好,hash 函数的选择是关键。
好了开始进入正题。
底层数据结构
hmap
即 map 的数据结构
type hmap struct {
count int // 元素数量
flags uint8 // 当前map的状态(具体有哪些状态,在map.go文件开头就能看到)
B uint8 // 2^B = bucket size (桶数)
noverflow uint16 // 已经使用了的溢出桶的数量
hash0 uint32 // hash seed
buckets unsafe.Pointer // 桶的地址指针
oldbuckets unsafe.Pointer // 旧桶的地址指针
nevacuate uintptr // 下一个要迁移的旧桶编号
extra *mapextra // 溢出桶
}
这里的 buckets 是 bmap 结构体的数组的头指针。
这里的旧桶主要是为了扩容时使用。
溢出桶则是当一个桶存满了,数据就存到溢出桶,这也就是 开放地址寻址法 的体现。
bmap
即 bucket(桶)的数据结构:
type bmap struct {
tophash [bucketCnt]uint8
}
bucketCnt = 8,即 tophash 是一个定长为 8 的数组,存储的是每个key 的 hash 值的高 8 位。 key是顺序存储的,所以 tophash也是以key的顺序存储的高8位 hash 值。
这里为什么没有看到 key, value呢?
编译期间会给它加料,动态地创建一个新的结构:
type bmap struct {
topbits [8]uint8
keys [8]keytype
values [8]valuetype
}
bmap 还有 3 个方法,他的溢出桶 bmap.overflow 是一个方法:
func (b *bmap) overflow(t *maptype) *bmap {
return *(**bmap)(add(unsafe.Pointer(b), uintptr(t.bucketsize)-sys.PtrSize))
}
咱们再来一张图就比较容易形象地理解了:
mapextra
type mapextra struct {
overflow *[]*bmap // 已经使用了的溢出桶
oldoverflow *[]*bmap // 旧桶使用了的溢出桶
nextOverflow *bmap // 下一个溢出桶的地址
}
overflow 存储当前已经使用了的溢出桶
oldoverflow 则是在扩容阶段存储旧桶的溢出桶地址
hmap,bmap,mapextra 的关系如下图:
接下来从源码可以看到:
// src/runtime/map.go
func makeBucketArray(t *maptype, b uint8, dirtyalloc unsafe.Pointer) (buckets unsafe.Pointer, nextOverflow *bmap) {
...
}
如果桶的数量 >= 2^4时,有大概率会提前创建好 2^(B-4) 个溢出桶。
再从上图中我们也可以看出,桶和 mapextra 中溢出桶的地址是连续的。nextOverflow 是第一个要被使用的溢出桶地址(头节点)。
从上一小节(bmap)得知每个桶都会有一个溢出桶,当桶的溢出桶也存满了后,就会用到 mapextra 中的溢出桶。这是从图中看出 nextOverflow 指向了第二个溢出桶地址
当 mapextra 中一个溢出桶用完后,hmap 中 noverflow 字段值就+1。mapextra 中的 overflow slice 就追加一个已经使用了的溢出桶地址。
扩容
hash map 有个负载因子的概念: 负载因子 = count(元素个数) / 2^B(桶的个数)
当负载因子 > 6.5 的 时候,就进行翻倍扩容;
如果 溢出桶数量(noverflow)较多:
B <= 15 && noverflow >= 2^B 或者 B > 15 noverflow >= 2^15, (uint16(1)<<(B&15) 为 2^B 的意思)
这两种情况下, 就表示溢出桶数量较多了,源码如下:
func tooManyOverflowBuckets(noverflow uint16, B uint8) bool {
if B > 15 {
B = 15
}
return noverflow >= uint16(1)<<(B&15)
}
第二种情况就是等量扩容
扩容过程不是立即执行的,而是渐进式的
扩容的时候, hmap 结构体中 oldbuckets字段 的值 = bucket(当前的bucket),bucket字段 就指向新的桶数组头指针。
nevacuate 字段存储要迁移的旧桶编号,比如下图中的 编号就是 0:
翻倍扩容
当装载因子大于6.5时,即元素过多桶太少的情况,就会发生翻倍扩容
扩容时,让B += 1,这时桶的数量扩大一倍, 假设扩容之前B=2
索引的计算 h & ( 2^B - 1 ),之前索引看tophash的低2位,新的索引需要重新计算,需要看tophash的低3位,按照新索引从旧桶分流到新桶
流程大概如下:
假设扩容前,旧桶数量是4个(B=2)
假设装载因子此时很高了,每个桶都装满了8个
我们拿第0个旧桶中个元素的tophash举例
可能为xxxxx000, xxxxxx100这两种(因为这两种二进制的tophash & 3 = 0, 2^B-1 = 3)
现在,发生扩容
原先第0号旧桶中的xxxxx000 & 7 = 0 , 放入新的0号桶
原先第0号旧桶中的xxxxx100 & 7 = 0 , 放入新的4号桶
(这里比如xxxxx00, 因为当前桶数量是4个,用两位即可表示,所以我们只关注hash值的最低两位,其实就是B的值)
之前获取桶索引的方式如下(以0号为例):
hash值: xxxx00
4(桶的数量)-1: & 11
----------------------
00
这样一个 hash值的 key 就会存储到 0 号桶。
扩容后新桶树变成8,获取桶索引的方式如下:
hash值: xxxx00
8(桶的数量)-1: & 111
----------------------
x00
比如得到的值,如果是100,那么就是到 4 号桶
如果是 000 到 0 号桶。
所以旧桶的 tophash/key/value 会自动分流到等距的新桶中。如下图:
所以,旧桶中的元素,一部分运往新桶的前一半,一部分运往新桶的后一半。
源码里提到 X, Y part,其实就是我们说的如果是扩容到原来的 2 倍,桶的数量是原来的 2 倍,前一半桶被称为 X part,后一半桶被称为 Y part。
一个 bucket 中的 key 可能会分裂落到 2 个桶,一个位于 X part,一个位于 Y part。所以在搬迁一个 cell 之前,需要知道这个 cell 中的 key 是落到哪个 Part。
很简单,重新计算 cell 中 key 的 hash,并向前“多看”一位,决定落入哪个 Part。
以上计算过程就是一个取模运算过程(转换为移位运算)
等量扩容
我们发现map在不断地插入元素、删除元素的过程中,可能会出现很多溢出桶,导致元素在桶内的分布很松散,(房子多、人少),查找时,会不断的在bucket之间链式搜索,性能会趋近于一个链表,时间复杂度为O(n),这时为了优化这个问题,就会等量扩容。
等量扩容就是,生成数量一样的桶数组,然后搬迁到新桶。既然一样,迁移来迁移去有啥意思?
这里就要说一下,map 的 delete 了。
map 的 delete 并不会物理删除桶中的 tophash,而是将值改为 emptyOne。这样逻辑上咱们的桶 就有空间被浪费了。
等量扩容就可以节约空间,使空间更加紧凑。如下图:
由于是等量扩容,桶的索引无需重新计算,直接搬迁到同样索引的新桶即可。
“渐进式"扩容
在扩容时,如果有大量的 key/value 需要搬迁,会非常影响性能。
因此 Go map 的扩容采取了一种称为“渐进式”地方式,原有的 key 并不会一次性搬迁完毕,每次最多只会搬迁 2个桶 (var xy [2]evacDst)。
真正搬迁 buckets 的动作在 growWork() 函数中,而调用 growWork() 函数的动作是在 mapassign 和 mapdelete 函数中。
也就是插入或修改、删除 key 的时候,都会尝试进行搬迁 buckets 的工作。
oldbuckets nil 则说明搬迁做完了。
特殊情况
有一个特殊情况是:有一种 key,每次对它计算 hash,得到的结果都不一样。这个 key 就是 math.NaN() 的结果,它的含义是 not a number,类型是 float64。
当它作为 map 的 key,在搬迁的时候,会遇到一个问题:再次计算它的哈希值和它当初插入 map 时的计算出来的哈希值不一样!
你可能想到了,这样带来的一个后果是,这个 key 是永远不会被 Get 操作获取的!当我使用 m[math.NaN()] 语句的时候,是查不出来结果的。这个 key 只有在遍历整个 map 的时候,才有机会现身。所以,可以向一个 map 插入任意数量的 math.NaN() 作为 key。
当搬迁碰到 math.NaN() 的 key 时,只通过 tophash 的最低位决定分配到 X part 还是 Y part(如果扩容后是原来 buckets 数量的 2 倍)。如果 tophash 的最低位是 0 ,分配到 X part;如果是 1 ,则分配到 Y part。
PS:
当桶的容量用完后,会通过 翻倍扩容 和等量扩容 两种方式来进行扩容。
扩容是渐进式的。
golang没有重载,map 访问为什么有下面两种写法?
v := m[key]
v, ok := m[key]
这是编译器做了工作,第一种写法 编译时 Op 值为 OINDEXMAP,第二种是 OAS2MAPR。
根据不同的 Op 值 ,在运行时调用不同的函数:
// src/runtime/map.go
func mapaccess1(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer
func mapaccess2(t *maptype, h *hmap, key unsafe.Pointer) (unsafe.Pointer, bool)
(这1,2的函数命名方式.. google的开源项目也这样么 -_-!!!)
文章出处:
《深入golang -- map》https://zhuanlan.zhihu.com/p/495750540
下面这篇文章讲得很详细,结合上面的那篇,搞懂map真是so easy啦。
《GoLang之map的扩容过程是怎样的(7)》https://blog.csdn.net/weixin_52690231/article/details/125265834
https://geek-docs.com/go-tutorials/go-examples/t_goland-program-to-read-a-number-n-and-print-the-series-1plus2plus-plusn.html
https://www.topgoer.com/beego框架/
https://www.topgoer.cn/docs/gomianshiti/mian105