【Golang】关于Map扩容策略
一、概括
使用哈希表的目的就是要快速查找到目标 key,然而,随着向 map 中添加的 key 越来越多,key 发生碰撞的概率也越来越大。bucket 中的 8 个 cell 会被逐渐塞满,查找、插入、删除 key 的效率也会越来越低。最理想的情况是一个 bucket 只装一个 key,这样,就能达到 O(1) 的效率,但这样空间消耗太大,用空间换时间的代价太高。
Go 语言采用一个 bucket 里装载 8 个 key,定位到某个 bucket 后,还需要再定位到具体的 key,这实际上又用了时间换空间。当然,这样做,要有一个度,不然所有的 key 都落在了同一个 bucket 里,直接退化成了链表,各种操作的效率直接降为 O(n),是不行的。因此,需要有一个指标来衡量前面描述的情况,这就是装载因子。Go 源码里这样定义 装载因子
1 | loadFactor := count / (2^B) |
count 就是 map 的元素个数,2^B 表示 bucket 数量,再来说触发 map 扩容的时机:在向 map 插入新 key 的时候,会进行条件检测,符合下面这 2 个条件,就会触发扩容:
Map的扩容有2种机制
1、装载因子超过阈值,源码里定义的阈值是 6.5,触发double扩容
2、overflow 的 bucket 数量过多:当 B 小于 15,也就是 bucket 总数 2^B 小于 2^15 时,如果 overflow 的 bucket 数量超过 2^B;当 B >= 15,也就是 bucket 总数 2^B 大于等于 2^15,如果 overflow 的 bucket 数量超过 2^15,触发等量扩容
可以看对应的函数是 mapassign
1 2 3 4 5 6 7 8 9 10 11 12 13 | // src/runtime/hashmap.go/mapassign// 触发扩容时机 if !h.growing() && (overLoadFactor(int64(h.count), h.B) || tooManyOverflowBuckets(h.noverflow, h.B)) { hashGrow(t, h) } // 装载因子超过 6.5 func overLoadFactor(count int64, B uint8) bool { return count >= bucketCnt && float32(count) >= loadFactor*float32((uint64(1)<<B)) } // overflow buckets 太多 func tooManyOverflowBuckets(noverflow uint16, B uint8) bool { if B < 16 { return noverflow >= uint16(1)<<B } return noverflow >= 1<<15 } |
第 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)。
二、源码分析
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | // Picking loadFactor: too large and we have lots of overflow // buckets, too small and we waste a lot of space. I wrote // a simple program to check some stats for different loads: // (64-bit, 8 byte keys and elems) // loadFactor %overflow bytes/entry hitprobe missprobe // 4.00 2.13 20.77 3.00 4.00 // 4.50 4.05 17.30 3.25 4.50 // 5.00 6.85 14.77 3.50 5.00 // 5.50 10.55 12.94 3.75 5.50 // 6.00 15.27 11.67 4.00 6.00 // 6.50 20.90 10.79 4.25 6.50 // 7.00 27.14 10.15 4.50 7.00 // 7.50 34.03 9.73 4.75 7.50 // 8.00 41.10 9.40 5.00 8.00 // // %overflow = percentage of buckets which have an overflow bucket // bytes/entry = overhead bytes used per key/elem pair // hitprobe = # of entries to check when looking up a present key // missprobe = # of entries to check when looking up an absent key // // Keep in mind this data is for maximally loaded tables, i.e. just // before the table grows. Typical tables will be somewhat less loaded. |
hashGrow函数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 | // 只是分配新的buckets,并将老的buckets挂到oldbuckets字段上 // 真正搬迁的动作在growWork()中 func hashGrow(t *maptype, h *hmap) { // If we've hit the load factor, get bigger. // Otherwise, there are too many overflow buckets, // so keep the same number of buckets and "grow" laterally. // B+1 相当于之前的2倍空间 bigger := uint8(1) // 对应条件2 if !overLoadFactor(h.count+1, h.B) { // 进行等量扩容,B不变 bigger = 0 h.flags |= sameSizeGrow } // 将oldbuckets挂到buckets上 oldbuckets := h.buckets // 申请新的buckets空间 newbuckets, nextOverflow := makeBucketArray(t, h.B+bigger, nil) // 对标志位的处理 // &^表示 按位置0 // x=01010011 // y=01010100 // z=x&^y=00000011 // 如果y的bit位为1,那么z相应的bit位就为0 // 否则z对应的bit位就和x对应的bit位相同 // // 其实就是将h.flags的iterator和oldItertor位置为0 // 如果发现iterator位为1,那就把它转接到 oldIterator 位 // 使得 oldIterator 标志位变成 1 // bucket挂到了oldbuckets下,那么标志位也一样转移过去 flags := h.flags &^ (iterator | oldIterator) if h.flags&iterator != 0 { flags |= oldIterator } // // 可能有迭代器使用 buckets // iterator = 1 // 可能有迭代器使用 oldbuckets // oldIterator = 2 // 有协程正在向 map 中写入 key // hashWriting = 4 // 等量扩容(对应条件 2) // sameSizeGrow = 8 // 提交grow的动作 // commit the grow (atomic wrt gc) h.B += bigger h.flags = flags h.oldbuckets = oldbuckets h.buckets = newbuckets // 搬迁进度为0 h.nevacuate = 0 // 溢出bucket数量为0 h.noverflow = 0 if h.extra != nil && h.extra.overflow != nil { // Promote current overflow buckets to the old generation. if h.extra.oldoverflow != nil { throw( "oldoverflow is not nil" ) } h.extra.oldoverflow = h.extra.overflow h.extra.overflow = nil } if nextOverflow != nil { if h.extra == nil { h.extra = new(mapextra) } h.extra.nextOverflow = nextOverflow } // the actual copying of the hash table data is done incrementally // by growWork() and evacuate(). } // growWork 真正执行搬迁工作的函数 // 调用其的动作在mapssign和mapdelete函数中,也就是插入、修改或删除的时候都会尝试进行搬迁 func growWork(t *maptype, h *hmap, bucket uintptr) { // make sure we evacuate the oldbucket corresponding // to the bucket we're about to use // 确保搬迁的老bucket对应的正在使用的新bucket // bucketmask 作用就是将key算出来的hash值与bucketmask相&,得到key应该落入的bucket // 只有hash值低B位决策key落入那个bucket evacuate(t, h, bucket&h.oldbucketmask()) // evacuate one more oldbucket to make progress on growing // 再搬迁一个bucket,加快搬迁进度,这就是说为什么可能每次操作会搬迁1-2个bucket if h.growing() { evacuate(t, h, h.nevacuate) } } // 返回扩容前的bucketmask // // 所谓的bucketmask作用就是将 key 计算出来的哈希值与 bucketmask 相与 // 得到的结果就是 key 应该落入的桶 // 比如 B = 5,那么 bucketmask 的低 5 位是 11111,其余位是 0 // hash 值与其相与的意思是,只有 hash 值的低 5 位决策 key 到底落入哪个 bucket。 // oldbucketmask provides a mask that can be applied to calculate n % noldbuckets(). func (h *hmap) oldbucketmask() uintptr { return h.noldbuckets() - 1 } // 检查oldbuckets是否搬迁完 // growing reports whether h is growing. The growth may be to the same size or bigger. func (h *hmap) growing() bool { return h.oldbuckets != nil } |
核心搬迁函数:evacuate
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 | // evacDst is an evacuation destination. type evacDst struct { // 标识bucket移动的目标地址 b *bmap // current destination bucket // k-v的索引 i int // key/elem index into b // 指向k k unsafe.Pointer // pointer to current key storage // 指向v e unsafe.Pointer // pointer to current elem storage } // evacuate 核心搬迁函数 func evacuate(t *maptype, h *hmap, oldbucket uintptr) { // 定位老的bucket的地址 b := (*bmap)(add(h.oldbuckets, oldbucket*uintptr(t.bucketsize))) // 结果是2^B,进行计算 如 B = 5,结果为32 newbit := h.noldbuckets() // 如果b没被搬迁过 if !evacuated(b) { // TODO: reuse overflow buckets instead of using new ones, if there // is no iterator using the old buckets. (If !oldIterator.) // xy contains the x and y (low and high) evacuation destinations. // xy包含了两个可能搬迁到的目的bucket地址 // 默认是等量扩容的,用x来搬迁 var xy [2]evacDst x := &xy[0] x.b = (*bmap)(add(h.buckets, oldbucket*uintptr(t.bucketsize))) x.k = add(unsafe.Pointer(x.b), dataOffset) x.e = add(x.k, bucketCnt*uintptr(t.keysize)) // 如果不是等量扩容,前后的bucket序号有变 // 使用y来搬迁 if !h.sameSizeGrow() { // Only calculate y pointers if we're growing bigger. // Otherwise GC can see bad pointers. y := &xy[1] // y代表的bucket序号增加了2^B y.b = (*bmap)(add(h.buckets, (oldbucket+newbit)*uintptr(t.bucketsize))) y.k = add(unsafe.Pointer(y.b), dataOffset) y.e = add(y.k, bucketCnt*uintptr(t.keysize)) } // 遍历所有的bucket,包括溢出bucket // b是老bucket的地址 for ; b != nil; b = b.overflow(t) { k := add(unsafe.Pointer(b), dataOffset) e := add(k, bucketCnt*uintptr(t.keysize)) // 遍历bucket里所有的cell for i := 0; i < bucketCnt; i, k, e = i+1, add(k, uintptr(t.keysize)), add(e, uintptr(t.elemsize)) { // 当前cell的tophash值 top := b.tophash[i] // 如果cell为空,即没有key // 说明其被搬迁过,作标记然后继续下一个cell if isEmpty(top) { b.tophash[i] = evacuatedEmpty continue } // 一般不会出现这种情况 // 未搬迁的cell只可能是empty或者正常的tophash // 不会小于minTopHash if top < minTopHash { throw( "bad map state" ) } // 进行一次拷贝避免相同内存地址问题 k2 := k // key如果是指针就进行解引用 if t.indirectkey() { k2 = *((*unsafe.Pointer)(k2)) } // 默认值为0标识默认是使用x,进行等量扩容 var useY uint8 // 增量扩容 if !h.sameSizeGrow() { // Compute hash to make our evacuation decision (whether we need // to send this key/elem to bucket x or bucket y). // 计算hash值,与第一次写入一样 hash := t.hasher(k2, uintptr(h.hash0)) // 有协程在遍历map 且 出现相同的key,计算出的hash值不同 // 这里只会有一种情况,也就是float64的时候 // 每次hash出来都会是不同的hash值,这就意味着无法通过get去获取其key确切位置 // 因此采用取最低位位置来分辨 // 为下一个level重新计算一个随机的tophash // 这些key将会在多次增长后均匀的分布在所有的存储桶中 if h.flags&iterator != 0 && !t.reflexivekey() && !t.key.equal(k2, k2) { // If key != key (NaNs), then the hash could be (and probably // will be) entirely different from the old hash. Moreover, // it isn't reproducible. Reproducibility is required in the // presence of iterators, as our evacuation decision must // match whatever decision the iterator made. // Fortunately, we have the freedom to send these keys either // way. Also, tophash is meaningless for these kinds of keys. // We let the low bit of tophash drive the evacuation decision. // We recompute a new random tophash for the next level so // these keys will get evenly distributed across all buckets // after multiple grows. // 第B位 置1 // 如果tophash最低位是0就分配到x part 否则分配到y part useY = top & 1 top = tophash(hash) } else { // 对于正常的key // 第B位 置0 if hash&newbit != 0 { // 使用y部分 useY = 1 } } } if evacuatedX+1 != evacuatedY || evacuatedX^1 != evacuatedY { throw( "bad evacuatedN" ) } // 这里其实就是重新设置tophash值 // 标记老的cell的tophash值,表示搬到useT部分(可能是x也可能是y,看具体取值) b.tophash[i] = evacuatedX + useY // evacuatedX + 1 == evacuatedY // 选择目标bucket的内存起始部分 dst := &xy[useY] // evacuation destination // 如果i=8说明要溢出了 if dst.i == bucketCnt { // 新建一个溢出bucket dst.b = h.newoverflow(t, dst.b) // 从0开始计数 dst.i = 0 // 标识key要移动到的位置 dst.k = add(unsafe.Pointer(dst.b), dataOffset) // 标识value要移动到的位置 dst.e = add(dst.k, bucketCnt*uintptr(t.keysize)) } // 重新设置tophash dst.b.tophash[dst.i&(bucketCnt-1)] = top // mask dst.i as an optimization, to avoid a bounds check if t.indirectkey() { // 将原key(指针类型)复制到新的位置 *(*unsafe.Pointer)(dst.k) = k2 // copy pointer } else { // 将原key(值类型)复制到新位置 typedmemmove(t.key, dst.k, k) // copy elem } // 如果v是指针,操作同key if t.indirectelem() { *(*unsafe.Pointer)(dst.e) = *(*unsafe.Pointer)(e) } else { typedmemmove(t.elem, dst.e, e) } // 定位到下一个cell dst.i++ // These updates might push these pointers past the end of the // key or elem arrays. That's ok, as we have the overflow pointer // at the end of the bucket to protect against pointing past the // end of the bucket. // 两个溢出指针在bucket末尾用于保证 遍历到bucket末尾的指针 dst.k = add(dst.k, uintptr(t.keysize)) dst.e = add(dst.e, uintptr(t.elemsize)) } } // 如果没有协程在用老的bucket,就将老的bucket清除,帮助gc // Unlink the overflow buckets & clear key/elem to help GC. if h.flags&oldIterator == 0 && t.bucket.ptrdata != 0 { b := add(h.oldbuckets, oldbucket*uintptr(t.bucketsize)) // Preserve b.tophash because the evacuation // state is maintained there. ptr := add(b, dataOffset) n := uintptr(t.bucketsize) - dataOffset // 只清除k-v部分,tophash用于标识搬迁状态 memclrHasPointers(ptr, n) } } // 如果此次搬迁的bucket等于当前搬迁进度,更新搬迁进度 if oldbucket == h.nevacuate { advanceEvacuationMark(h, t, newbit) } } // 更新搬迁进度 func advanceEvacuationMark(h *hmap, t *maptype, newbit uintptr) { // 进度+1 h.nevacuate++ // 尝试往后看1024个bucket,确保行为是O(1)的 // Experiments suggest that 1024 is overkill by at least an order of magnitude. // Put it in there as a safeguard anyway, to ensure O(1) behavior. stop := h.nevacuate + 1024 if stop > newbit { stop = newbit } // 寻找没有搬迁过的bucket for h.nevacuate != stop && bucketEvacuated(t, h, h.nevacuate) { h.nevacuate++ } // 现在h.nevacuate之前的bucket都被搬迁完毕了 // 如果所有bucket搬迁完毕 if h.nevacuate == newbit { // newbit == # of oldbuckets // 清除oldbuckets,释放bucket array // Growing is all done. Free old main bucket array. h.oldbuckets = nil // 清除老的溢出bucket // [0]表示当前溢出bucket // [1]表示老的溢出bucket // Can discard old overflow buckets as well. // If they are still referenced by an iterator, // then the iterator holds a pointers to the slice. if h.extra != nil { h.extra.oldoverflow = nil } // 清除正在扩容的标志位 h.flags &^= sameSizeGrow } } |
源码里提到 X, Y part,其实就是我们说的如果是扩容到原来的 2 倍,桶的数量是原来的 2 倍,前一半桶被称为 X part,后一半桶被称为 Y part。一个 bucket 中的 key 会分裂落到 2 个桶中。一个位于 X part,一个位于 Y part。所以在搬迁一个 cell 之前,需要知道这个 cell 中的 key 是落到哪个 Part。
其实很简单,重新计算 cell 中 key 的 hash,并向前“多看”一位,决定落入哪个 Part
设置 key 在原始 buckets 的 tophash 为 evacuatedX 或是 evacuatedY,表示已经搬迁到了新 map 的 x part 或是 y part。新 map 的 tophash 则正常取 key 哈希值的高 8 位。
对于增量扩容来说:某个 key 在搬迁前后 bucket 序号可能和原来相等,也可能是相比原来加上 2^B(原来的 B 值),取决于 hash 值 第 6 bit 位是 0 还是 1。
当搬迁碰到 math.NaN() 的 key 时,只通过 tophash 的最低位决定分配到 X part 还是 Y part(如果扩容后是原来 buckets 数量的 2 倍)。如果 tophash 的最低位是 0 ,分配到 X part;如果是 1 ,则分配到 Y part,已搬迁完的key的tophash值是一个状态值,表示key的搬迁去向
三、map的结构
Go中的map是一个指针,占用8个字节,指向hmap构造体; 源码src/runtime/map.go
中能够看到map的底层构造
每个map的底层构造是hmap,hmap蕴含若干个构造为bmap的bucket数组。每个bucket底层都采纳链表构造。接下来,咱们来具体看下map的构造
在源码中,表示 map 的结构体是 hmap,它是 hashmap 的“缩写”:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | // A header for a Go map. type hmap struct { // 元素个数,调用 len(map) 时,直接返回此值 count int flags uint8 // buckets 的对数 log_2 B uint8 // overflow 的 bucket 近似数 noverflow uint16 // 计算 key 的哈希的时候会传入哈希函数 hash0 uint32 // 指向 buckets 数组,大小为 2^B // 如果元素个数为0,就为 nil buckets unsafe.Pointer // 扩容的时候,buckets 长度会是 oldbuckets 的两倍 oldbuckets unsafe.Pointer // 指示扩容进度,小于此地址的 buckets 迁移完成 nevacuate uintptr extra *mapextra // optional fields } |
说明一下,B
是 buckets 数组的长度的对数,也就是说 buckets 数组的长度就是 2^B。bucket 里面存储了 key 和 value,后面会再讲。
buckets 是一个指针,最终它指向的是一个结构体:
1 2 3 | type bmap struct { tophash [bucketCnt]uint8 } |
但这只是表面(src/runtime/hashmap.go)的结构,编译期间会给它加料,动态地创建一个新的结构:
1 2 3 4 5 6 7 | type bmap struct { topbits [8]uint8 keys [8]keytype values [8]valuetype pad uintptr overflow uintptr } |
bmap
就是我们常说的“桶”,桶里面会最多装 8 个 key,这些 key 之所以会落入同一个桶,是因为它们经过哈希计算后,哈希结果是“一类”的。在桶内,又会根据 key 计算出来的 hash 值的高 8 位来决定 key 到底落入桶内的哪个位置(一个桶内最多有8个位置)。
对于这些 overflow 的 bucket,在 hmap 结构体和 bmap 结构体里分别有一个 extra.overflow
和 overflow
字段指向它们。
如果我们仔细看 mapextra 结构体里对 overflow 字段的注释,会发现这里有“文章”。
1 2 3 4 5 6 | type mapextra struct { overflow *[]*bmap oldoverflow *[]*bmap nextOverflow *bmap } |
其中 overflow
这个字段上面有一大段注释,我们来看看前两行:
1 2 | // If both key and elem do not contain pointers and are inline, then we mark bucket // type as containing no pointers. This avoids scanning such maps. |
- 作者:踏雪无痕
- 出处:http://www.cnblogs.com/chenpingzhao/
- 本文版权归作者和博客园共有,如需转载,请联系 pingzhao1990#163.com
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 10年+ .NET Coder 心语 ── 封装的思维:从隐藏、稳定开始理解其本质意义
· 地球OL攻略 —— 某应届生求职总结
· 提示词工程——AI应用必不可少的技术
· Open-Sora 2.0 重磅开源!
· 字符编码:从基础到乱码解决
2015-09-26 JavaScript中的闭包(closure)