map实现原理
Go语言采用哈希查找表,并且使用链表解决哈希冲突
内存模型:
1 |
|
buckets是一个指针,指向一个结构体:
1 |
|
在运行期间,bmap结构体其实不止包含 tophash
字段,因为哈希表中可能存储不同类型的键值对,而且 Go 语言也不支持泛型,所以键值对占据的内存空间大小只能在编译时进行推导。
bmap 中的其他字段在运行时也都是通过计算内存地址的方式访问的,所以它的定义中就不包含这些字段,
不过我们能根据编译期间的 cmd/compile/internal/gc.bmap
函数重建它的结构:
1 |
|
bmap: 我们常说的桶,最多装8个key,根据key计算出来的hash值的高8位来决定落入桶的哪个位置(一个桶最多有8个位置)
当 map 的 key 和 value 都不是指针,并且 size 都小于 128 字节的情况下,会把 bmap 标记为不含指针,这样可以避免 gc 时扫描整个 hmap。
但是,我们看 bmap 其实有一个 overflow 的字段,是指针类型的,破坏了 bmap 不含指针的设想,这时会把 overflow 移动到 extra 字段来。
slice 和 map 分别作为函数参数时有什么区别?
当 map 和 slice 作为函数参数时,在函数参数内部对 map 的操作会影响 map 自身;而对 slice 却不会
主要原因:一个是指针(*hmap),一个是结构体(slice)。
Go 语言中的函数传参都是值传递,在函数内部,参数会被 copy 到本地。
*hmap指针 copy 完之后,仍然指向同一个 map,因此函数内部对 map 的操作会影响实参。
而 slice 被 copy 后,会成为一个新的 slice,对它进行的操作不会影响到实参。
哈希函数
map 的一个关键点在于,哈希函数的选择。
在程序启动时,会检测 cpu 是否支持 aes,如果支持,则使用 aes hash,否则使用 memhash。
这是在函数 alginit() 中完成,位于路径:src/runtime/alg.go 下。
选择 hash 函数主要考察的是两点:性能、碰撞概率。
key的定位
桶的数量m = 2^B
取模:hash%m, 但是开销太大,代码实现上用的位操作代替, 与运算 hash&(m-1)
hash冲突的解决办法:
“拉链法”: 在冲突的桶后面练一个新桶
开放地址法: 遍历后面的桶,key相等或者为nil
map遍历过程
扩容过程不是一个原子操作,每次最多只搬运两个bucket,如果触发了扩容操作,那么很长一段时间里,map的状态都除以一个中间态:有些bucket已经搬迁到新家,而有些bucket还待在老地方
难点在于:遍历如果发生在扩容过程中,会涉及遍历新老bucket的过程
查看汇编命令:
1 |
|
先是调用 mapiterinit 函数初始化迭代器(器hiter),然后循环调用 mapiternext 函数进行 map 迭代。
1 |
|
赋值过程
mapassign函数
检查标志位 flags
对 key 计算 hash 值,根据 hash 值按照之前的流程,找到要赋值的位置(可能是插入新 key,也可能是更新老 key),对相应位置进行赋值。
源码大体和之前讲的类似,核心还是一个双层循环,外层遍历 bucket 和它的 overflow bucket,内层遍历整个 bucket 的各个 cell
准备两个指针,一个(inserti)指向 key 的 hash 值在 tophash 数组所处的位置,另一个(insertk)指向 cell 的位置(也就是 key 最终放置的地址),当然,对应 value 的位置就很容易定位出来了。
这三者实际上都是关联的,在 tophash 数组中的索引位置决定了 key 在整个 bucket 中的位置(共 8 个 key),而 value 的位置需要“跨过” 8 个 key 的长度。
在循环的过程中,inserti 和 insertk 分别指向第一个找到的空闲的 cell。
如果之后在 map 没有找到 key 的存在,也就是说原来 map 中没有此 key,这意味着插入新 key。那最终 key 的安置地址就是第一次发现的“空位”(tophash 是 empty)。
果这个 bucket 的 8 个 key 都已经放置满了,那在跳出循环后,发现 inserti 和 insertk 都是空,这时候需要在 bucket 后面挂上 overflow bucket。
在正式安置 key 之前,还要检查 map 的状态,看它是否需要进行扩容。如果满足扩容的条件,就主动触发一次扩容操作。
这之后,整个之前的查找定位 key 的过程,还得再重新走一次。因为扩容之后,key 的分布都发生了变化。
最后,会更新 map 相关的值,如果是插入新 key,map 的元素数量字段 count 值会加 1;在函数之初设置的 hashWriting 写标志出会清零。
ps:mapassign 函数返回的指针就是指向的 key 所对应的 value 值位置,有了地址,就很好操作赋值了
删除过程
先判断flags标志位,是否有其他协程在进行写操作。如果有直接返回panic
计算key的hash,找到落入的bucket。 检查此map如果处于扩容过程中,执行一次搬迁操作
找到key的具体位置,在bucket中挨个cell寻找,找到对应位置后,对key和value进行清零操作
最后将hamp接口体的count数量-1,将对应位置的tophash置为empty
扩容过程
什么时候需要扩容:
1.装载因子 > 6.5 . locadFactor := count / (2^B)
2.使用的溢出桶过多: B<15 ,count > 2^B ; B>15 ,count >2^15
hashGrow() 分配新的 buckets,并将老的 buckets 挂到了 oldbuckets 字段上
真正搬迁 buckets 的动作在 growWork() 函数中,而调用 growWork() 函数的动作是在 mapassign 和 mapdelete 函数中
olldbuckets 为 nil扩容完成
Key为什么是无序的
map 在扩容后,会发生 key 的搬迁,原来落在同一个 bucket 中的 key,搬迁后,有些 key 就要远走高飞了(bucket 序号加上了 2^B)。
而遍历的过程,就是按顺序遍历 bucket,同时按顺序遍历 bucket 中的 key。搬迁后,key 的位置发生了重大的变化,有些 key 飞上高枝,有些 key 则原地不动。这样,遍历 map 的结果就不可能按原来的顺序了。
我们在遍历 map 时,并不是固定地从 0 号 bucket 开始遍历,每次都是从一个随机值序号的 bucket 开始遍历,并且是从这个 bucket 的一个随机序号的 cell 开始遍历。这样,即使你是一个写死的 map,仅仅只是遍历它,也不太可能会返回一个固定序列的 key/value 对了。
ps: “迭代 map 的结果是无序的”这个特性是从 go 1.0 开始加入的