map操作函数的定义和使用 map是什么意思( 四 )

通过汇编语言可以找到赋值操作对应源码中的函数是 mapassign,对应扩容条件的源码如下:

map操作函数的定义和使用 map是什么意思


解释一下:第 1 点:我们知道,每个 bucket 有 8 个空位,在没有溢出,且所有的桶都装满了的情况下,装载因子算出来的结果是 8 。因此当装载因子超过 6.5 时,表明很多 bucket 都快要装满了,查找效率和插入效率都变低了 。在这个时候进行扩容是有必要的 。第 2 点:是对第 1 点的补充 。就是说在装载因子比较小的情况下,这时候 map 的查找和插入效率也很低,而第 1 点识别不出来这种情况 。表面现象就是计算装载因子的分子比较小,即 map 里元素总数少,但是 bucket 数量多(真实分配的 bucket 数量多,包括大量的 overflow bucket) 。不难想像造成这种情况的原因:不停地插入、删除元素 。先插入很多元素,导致创建了很多 bucket,但是装载因子达不到第 1 点的临界值,未触发扩容来缓解这种情况 。之后,删除元素降低元素总数量,再插入很多元素,导致创建很多的 overflow bucket,但就是不会触犯第 1 点的规定,你能拿我怎么办?overflow bucket 数量太多,导致 key 会很分散,查找插入效率低得吓人,因此出台第 2 点规定 。这就像是一座空城,房子很多,但是住户很少,都分散了,找起人来很困难 。对于命中条件 1,2 的限制,都会发生扩容 。但是扩容的策略并不相同,毕竟两种条件应对的场景不同 。对于条件 1,元素太多,而 bucket 数量太少,很简单:将 B 加 1,bucket 最大数量(2^B)直接变成原来 bucket 数量的 2 倍 。于是,就有新老 bucket 了 。注意,这时候元素都在老 bucket 里,还没迁移到新的 bucket 来 。而且,新 bucket 只是最大数量变为原来最大数量(2^B)的 2 倍(2^B * 2) 。对于条件 2,其实元素没那么多,但是 overflow bucket 数特别多,说明很多 bucket 都没装满 。解决办法就是开辟一个新 bucket 空间,将老 bucket 中的元素移动到新 bucket,使得同一个 bucket 中的 key 排列地更紧密 。这样,原来,在 overflow bucket 中的 key 可以移动到 bucket 中来 。结果是节省空间,提高 bucket 利用率,map 的查找和插入效率自然就会提升 。对于条件 2 的解决方案,曹大的博客里还提出了一个极端的情况:如果插入 map 的 key 哈希都一样,就会落到同一个 bucket 里,超过 8 个就会产生 overflow bucket,结果也会造成 overflow bucket 数过多 。移动元素其实解决不了问题,因为这时整个哈希表已经退化成了一个链表,操作效率变成了 O(n) 。再来看一下扩容具体是怎么做的 。由于 map 扩容需要将原有的 key/value 重新搬迁到新的内存地址,如果有大量的 key/value 需要搬迁,会非常影响性能 。因此 Go map 的扩容采取了一种称为“渐进式”地方式,原有的 key 并不会一次性搬迁完毕,每次最多只会搬迁 2 个 bucket 。上面说的 hashGrow() 函数实际上并没有真正地“搬迁”,它只是分配好了新的 buckets,并将老的 buckets 挂到了 oldbuckets 字段上 。真正搬迁 buckets 的动作在 growWork() 函数中,而调用 growWork() 函数的动作是在 mapassign 和 mapdelete 函数中 。也就是插入或修改、删除 key 的时候,都会尝试进行搬迁 buckets 的工作 。先检查 oldbuckets 是否搬迁完毕,具体来说就是检查 oldbuckets 是否为 nil 。我们先看 hashGrow() 函数所做的工作,再来看具体的搬迁 buckets 是如何进行的 。
map操作函数的定义和使用 map是什么意思


主要是申请到了新的 buckets 空间,把相关的标志位都进行了处理:例如标志 nevacuate 被置为 0,表示当前搬迁进度为 0 。值得一说的是对 h.flags 的处理:flags := h.flags &^ (iterator | oldIterator)if h.flags&iterator != 0 { flags |= oldIterator}这里得先说下运算符:&^ 。这叫按位置 0运算符 。例如:x = 01010011y = 01010100z = x &^ y = 00000011如果 y bit 位为 1,那么结果 z 对应 bit 位就为 0,否则 z 对应 bit 位就和 x 对应 bit 位的值相同 。所以上面那段对 flags 一顿操作的代码的意思是:先把 h.flags 中 iterator 和 oldIterator 对应位清 0,然后如果发现 iterator 位为 1,那就把它转接到 oldIterator 位,使得 oldIterator 标志位变成 1 。潜台词就是:buckets 现在挂到了 oldBuckets 名下了,对应的标志位也转接过去吧 。几个标志位如下:// 可能有迭代器使用 bucketsiterator = 1// 可能有迭代器使用 oldbucketsoldIterator = 2// 有协程正在向 map 中写入 keyhashWriting = 4// 等量扩容(对应条件 2)sameSizeGrow = 8再来看看真正执行搬迁工作的 growWork() 函数 。func growWork(t *maptype, h *hmap, bucket uintptr) { // 确认搬迁老的 bucket 对应正在使用的 bucket evacuate(t, h, bucket&h.oldbucketmask()) // 再搬迁一个 bucket,以加快搬迁进程 if h.growing() { evacuate(t, h, h.nevacuate) }}h.growing() 函数非常简单:func (h *hmap) growing() bool { return h.oldbuckets != nil}如果 oldbuckets 不为空,说明还没有搬迁完毕,还得继续搬 。bucket&h.oldbucketmask() 这行代码,如源码注释里说的,是为了确认搬迁的 bucket 是我们正在使用的 bucket 。oldbucketmask() 函数返回扩容前的 map 的 bucketmask 。所谓的 bucketmask,作用就是将 key 计算出来的哈希值与 bucketmask 相与,得到的结果就是 key 应该落入的桶 。比如 B = 5,那么 bucketmask 的低 5 位是 11111,其余位是 0,hash 值与其相与的意思是,只有 hash 值的低 5 位决策 key 到底落入哪个 bucket 。接下来,我们集中所有的精力在搬迁的关键函数 evacuate 。源码贴在下面,不要紧张,我会加上大面积的注释,通过注释绝对是能看懂的 。之后,我会再对搬迁过程作详细说明 。源码如下:

推荐阅读