数据结构
go map的数据结构的话大概是这个图的样子
主要就两个结构,hmap和bmap
hmap
hmap是来表示map的结构体。
type hmap struct {
count int
flags uint8
B uint8
noverflow uint16
hash0 uint32
buckets unsafe.Pointer
oldbuckets unsafe.Pointer
nevacuate uintptr
extra *mapextra
}
分别分析下这些变量的作用
- count 元素数量
- flags 状态标识,比如被写,被遍历等
- B 桶(bmap)数量的对数,也就是说桶的数量是2^B个
- hash0 哈希种子,增加哈希函数的随机性
- buckets 指向bmap数组的指针
- oldbuckets 指向旧bmap数组的指针,与扩容有关,下面再介绍
- nevacute 表示扩容进度
bmap
bmap就是桶,hmap的buckets指向的就是bmap数组。
type bmap struct {
tophash [bucketCnt]uint8
}
看似很少,但实际在编译期间会动态创建一个新的结构
type bmap struct {
topbits [8]uint8
keys [8]keytype
values [8]valuetype
pad uintptr
overflow uintptr
}
解释下这些变量
- topbits 即上面的tophash,取了hash值的高八位
- keys,values 键值对,都是长度为8的数组,意味着一个桶最多放8个kv
- pad 对齐填充
- overflow 溢出桶,可以理解为指针指向一个新的桶,即链表法
可以看到key是放在一起的,value也是放到一起的,这样的目的是为了节省空间
定位
先看张图
假设hmap的B为5,那么后5位决定放在哪个bmap桶里。
如果是插入的情况,会对桶进行内遍历,找到第一个空位然后插入进去,把tophash值设为hash值前八位。
如果是查找的情况,对桶进行内遍历,hash值的前八位与每个cell的tophash进行比较。如果在 bucket 中没找到,并且 overflow 不为空,还要继续去 overflow bucket 中寻找,直到找到或是所有的cell都找遍了,函数返回 h[key] 的指针,如果 h 中没有此 key,那就会返回一个 key 相应类型的零值,不会返回 nil。
对于查找来说,获得hash值后只会通过后B位来定位到哪个bmap桶,然后要进行内循环寻找对应的kv,如果没有的话,还要去溢出桶去找。
相对于java Hashmap来说,这个八个长度的数组就相当于链表了,当发生哈希碰撞后,存储到一个bmap里。
扩容
当元素数量变多时,会导致碰撞变多,那么bmap里的值就更多,查找效率就会变低,所以到一定程度时就需要扩容。
需要一个指标衡量,就是负载因子。
go的负载因子规则是
loadFactor := count / (2^B)
即元素数量除以桶的数量,默认为6.5。
Java的Hashmap的负载因子默认是0.75,所以Go相对于java来说更倾向于用空间换时间。
触发扩容有两种情况:
- 负载因子超过了默认值
- overflow的bucket数量过多
第一种很好理解,就是正常元素过多造成的扩容
第二种情况就是因为元素不断的进行增删造成溢出桶很多,元素很少,没有满足负载因子的默认值,但是效率很低
面对这两种情况,扩容的策略并不同,我分别解释一下
负载因子超过默认值
这种情况只需要最简单的扩容,即把B加1,使桶扩容两倍的大小。但Go并没有直接把桶的元素转移,而是采取了类似于redis的渐进式扩容,这也就解释了hmap里oldbucket的作用。
扩容后会先将olbucket指向数组,每次插入修改删除时都会调用growwork()方法,尝试搬迁,搬迁后序号不变。
overflow的bucket数量过多
这种情况与java的hashmap扩容很相似,都属于链表上桶进行位置变换,来减少链表长度,提高效率。
B+1,使扩容两倍,通过判断新增的那一位是否为1,来决定桶放的位置。
并发问题
map 并不是一个线程安全的数据结构。同时读写一个 map 是未定义的行为,如果被检测到,会直接 panic。