Golang Map源码分析(二、基础操作之写入、扩容)
注意当前go版本代码为1.23
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 type hmap struct { count int flags uint8 B uint8 noverflow uint16 hash0 uint32 buckets unsafe.Pointer oldbuckets unsafe.Pointer nevacuate uintptr extra *mapextra }
上一篇文章我们介绍了go map初始化、,接下来我们看看map的基础操作是如何实现的吧
基础操作 写入 通过汇编语言可以看到,向 map 中插入或者修改 key,最终调用的是 mapassign
函数。
实际上插入或修改 key 的语法是一样的,只不过前者操作的 key 在 map 中不存在,而后者操作的 key 存在 map 中。
mapassign 有一个系列的函数,根据 key 类型的不同,编译器会将其优化为相应的“快速函数”。
key 类型
插入
uint32
mapassign_fast32(t *maptype, h *hmap, key uint32) unsafe.Pointer
uint64
mapassign_fast64(t *maptype, h *hmap, key uint64) unsafe.Pointer
string
mapassign_faststr(t *maptype, h *hmap, ky string) unsafe.Pointer
我们只用研究最一般的赋值函数 mapassign
。
整体流程与读取比较相似,可以将其分成几个部分依次分析,首先是函数会根据传入的键拿到对应的哈希和桶:
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 func mapassign (t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer { hash := t.Hasher(key, uintptr (h.hash0)) h.flags ^= hashWriting if h.buckets == nil { h.buckets = newobject(t.Bucket) } again: bucket := hash & bucketMask(h.B) if h.growing() { growWork(t, h, bucket) } b := (*bmap)(add(h.buckets, bucket*uintptr (t.BucketSize))) top := tophash(hash) var inserti *uint8 var insertk unsafe.Pointer var elem unsafe.Pointer bucketloop: for { for i := uintptr (0 ); i < abi.OldMapBucketCount; i++ { if b.tophash[i] != top { if isEmpty(b.tophash[i]) && inserti == nil { inserti = &b.tophash[i] insertk = add(unsafe.Pointer(b), dataOffset+i*uintptr (t.KeySize)) elem = add(unsafe.Pointer(b), dataOffset+abi.OldMapBucketCount*uintptr (t.KeySize)+i*uintptr (t.ValueSize)) } if b.tophash[i] == emptyRest { break bucketloop } continue } k := add(unsafe.Pointer(b), dataOffset+i*uintptr (t.KeySize)) if t.IndirectKey() { k = *((*unsafe.Pointer)(k)) } if !t.Key.Equal(key, k) { continue } if t.NeedKeyUpdate() { typedmemmove(t.Key, k, key) } elem = add(unsafe.Pointer(b), dataOffset+abi.OldMapBucketCount*uintptr (t.KeySize)+i*uintptr (t.ValueSize)) goto done } ovf := b.overflow(t) if ovf == nil { break } b = ovf } if !h.growing() && (overLoadFactor(h.count+1 , h.B) || tooManyOverflowBuckets(h.noverflow, h.B)) { hashGrow(t, h) goto again } if inserti == nil { newb := h.newoverflow(t, b) inserti = &newb.tophash[0 ] insertk = add(unsafe.Pointer(newb), dataOffset) elem = add(insertk, abi.OldMapBucketCount*uintptr (t.KeySize)) } if t.IndirectKey() { kmem := newobject(t.Key) *(*unsafe.Pointer)(insertk) = kmem insertk = kmem } if t.IndirectElem() { vmem := newobject(t.Elem) *(*unsafe.Pointer)(elem) = vmem } typedmemmove(t.Key, insertk, key) *inserti = top h.count++ done: if h.flags&hashWriting == 0 { fatal("concurrent map writes" ) } h.flags &^= hashWriting if t.IndirectElem() { elem = *((*unsafe.Pointer)(elem)) } return elem }
主要步骤总结:0.前置检查 -> 1.计算哈希值 ->2. 定位桶(如果map正在扩容,则优先进行桶对应的扩容操作) -> 3.遍历桶内条目 -> 4.1在桶中查找键,比较 tophash 和 key ,若找到则更新(更新后直接跳转步骤5),否则准备插入-> 4.2.插入新键值对,更新相关数据结构->5. 清除写入标志并返回值的指针
重点介绍一下步骤4.1与4.2:
4.1在桶中查找键
遍历目标桶中的槽位,比较每个槽位的 tophash
(哈希值的高8位)与目标 tophash
。
如果 tophash
不匹配,且槽位为空,记录该位置作为潜在的插入点。
如果槽位为 emptyRest
,表示已经到达桶的末尾,停止遍历。
如果 tophash匹配,进一步比较键是否相同:
若键相同,执行更新操作,将新值写入对应位置,然后跳转到完成步骤。
如果当前桶遍历完仍未找到匹配的键,继续遍历溢出桶(overflow bucket)。
4.2.插入新键值对,更新相关数据结构
需注意,哈希并不会在 runtime.mapassign这个运行时函数中将值拷贝到桶中,该函数只会返回内存地址,真正的赋值操作是在编译期间插入的:
1 2 3 4 00018 (+5 ) CALL runtime.mapassign_fast64(SB)00020 (5 ) MOVQ 24 (SP), DI ;; DI = &value00026 (5 ) LEAQ go .string ."88" (SB), AX ;; AX = &"88" 00027 (5 ) MOVQ AX, (DI) ;; *DI = AX
其中 24(SP)
是该函数返回的值地址,我们通过 LEAQ
指令将字符串的地址存储到寄存器 AX
中,MOVQ
指令将字符串 "88"
存储到了目标地址上完成了这次哈希的写入。
扩容 上面介绍写入过程时省略了扩容操作,随着哈希表中元素的逐渐增加,哈希的性能会逐渐恶化,所以map在写入时会map容量进行检查是否需要扩容,如果达到对应瓶颈,我们需要通过扩容更多的桶和更大的内存保证哈希的读写性能:
Go 语言采用一个 bucket 里装载 8 个 key,定位到某个 bucket 后,还需要再定位到具体的 key,这实际上又用了时间换空间。
当然,这样做,要有一个度,不然所有的 key 都落在了同一个 bucket 里,直接退化成了链表,各种操作的效率直接降为 O(n),是不行的。
因此,需要有一个指标来衡量前面描述的情况,就是装载因子
。
mapassign函数会在以下两种情况发生时触发哈希的扩容:
装载因子超过阈值,源码里定义的阈值是 6.5。
使用了太多溢出桶;overflow 的 bucket 数量过多:当 B 小于 15,也就是 bucket 总数 2^B 小于 2^15 时,如果 overflow 的 bucket 数量超过 2^B;当 B >= 15,也就是 bucket 总数 2^B 大于等于 2^15,如果 overflow 的 bucket 数量超过 2^15。
对应扩容条件的源码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 func overLoadFactor (count int , B uint8 ) bool { return count > abi.OldMapBucketCount && uintptr (count) > loadFactorNum*(bucketShift(B)/loadFactorDen) }func tooManyOverflowBuckets (noverflow uint16 , B uint8 ) bool { if B > 15 { B = 15 } return noverflow >= uint16 (1 )<<(B&15 ) }
第 1 点:每个 bucket 有 8 个空位,在没有溢出,且所有的桶都装满了的情况下,装载因子算出来的结果是 8。因此当装载因子超过 6.5 时,表明很多 bucket 都快要装满了,查找效率和插入效率都变低了。在这个时候进行扩容是有必要的。
第 2 点:是对第 1 点的补充。就是说在装载因子比较小的情况下,这时候 map 的查找和插入效率也很低,而第 1 点识别不出来这种情况。表面现象就是计算装载因子的分子比较小,即 map 里元素总数少,但是 bucket 数量多(真实分配的 bucket 数量多,包括大量的 overflow bucket)。
对于命中条件 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)
。
扩容的入口runtime.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 func hashGrow (t *maptype, h *hmap) { bigger := uint8 (1 ) if !overLoadFactor(h.count+1 , h.B) { bigger = 0 h.flags |= sameSizeGrow } oldbuckets := h.buckets newbuckets, nextOverflow := makeBucketArray(t, h.B+bigger, nil ) flags := h.flags &^ (iterator | oldIterator) if h.flags&iterator != 0 { flags |= oldIterator } h.B += bigger h.flags = flags h.oldbuckets = oldbuckets h.buckets = newbuckets h.nevacuate = 0 h.noverflow = 0 if h.extra != nil && h.extra.overflow != nil { 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 } }
哈希在扩容的过程中会通过 runtime.makeBucketArray
创建一组新桶和预创建的溢出桶,随后将原有的桶数组设置到 oldbuckets
上并将新的空桶设置到 buckets
上,溢出桶也使用了相同的逻辑更新,下图展示了触发扩容后的哈希,引自Go 语言设计与实现 :
我们在 runtime.hashGrow
中还看不出来等量扩容和翻倍扩容的太多区别,等量扩容创建的新桶数量只是和旧桶一样,该函数中只是创建了新的桶,并没有对数据进行拷贝和转移。哈希表的数据迁移的过程在是 runtime.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 func growWork (t *maptype, h *hmap, bucket uintptr ) { evacuate(t, h, bucket&h.oldbucketmask()) if h.growing() { evacuate(t, h, h.nevacuate) } }func evacuate (t *maptype, h *hmap, oldbucket uintptr ) { b := (*bmap)(add(h.oldbuckets, oldbucket*uintptr (t.BucketSize))) newbit := h.noldbuckets() if !evacuated(b) { 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, abi.OldMapBucketCount*uintptr (t.KeySize)) if !h.sameSizeGrow() { y := &xy[1 ] y.b = (*bmap)(add(h.buckets, (oldbucket+newbit)*uintptr (t.BucketSize))) y.k = add(unsafe.Pointer(y.b), dataOffset) y.e = add(y.k, abi.OldMapBucketCount*uintptr (t.KeySize)) } for ; b != nil ; b = b.overflow(t) { k := add(unsafe.Pointer(b), dataOffset) e := add(k, abi.OldMapBucketCount*uintptr (t.KeySize)) for i := 0 ; i < abi.OldMapBucketCount; i, k, e = i+1 , add(k, uintptr (t.KeySize)), add(e, uintptr (t.ValueSize)) { top := b.tophash[i] if isEmpty(top) { b.tophash[i] = evacuatedEmpty continue } if top < minTopHash { throw("bad map state" ) } k2 := k if t.IndirectKey() { k2 = *((*unsafe.Pointer)(k2)) } var useY uint8 if !h.sameSizeGrow() { hash := t.Hasher(k2, uintptr (h.hash0)) if h.flags&iterator != 0 && !t.ReflexiveKey() && !t.Key.Equal(k2, k2) { useY = top & 1 top = tophash(hash) } else { if hash&newbit != 0 { useY = 1 } } } if evacuatedX+1 != evacuatedY || evacuatedX^1 != evacuatedY { throw("bad evacuatedN" ) } b.tophash[i] = evacuatedX + useY dst := &xy[useY] if dst.i == abi.OldMapBucketCount { dst.b = h.newoverflow(t, dst.b) dst.i = 0 dst.k = add(unsafe.Pointer(dst.b), dataOffset) dst.e = add(dst.k, abi.OldMapBucketCount*uintptr (t.KeySize)) } dst.b.tophash[dst.i&(abi.OldMapBucketCount-1 )] = top if t.indirectkey { *(*unsafe.Pointer)(xk) = k2 } else { typedmemmove(t.key, xk, k) } if t.IndirectElem() { *(*unsafe.Pointer)(dst.e) = *(*unsafe.Pointer)(e) } else { typedmemmove(t.Elem, dst.e, e) } dst.i++ dst.k = add(dst.k, uintptr (t.KeySize)) dst.e = add(dst.e, uintptr (t.ValueSize)) } } if h.flags&oldIterator == 0 && t.Bucket.Pointers() { b := add(h.oldbuckets, oldbucket*uintptr (t.BucketSize)) ptr := add(b, dataOffset) n := uintptr (t.BucketSize) - dataOffset memclrHasPointers(ptr, n) } } if oldbucket == h.nevacuate { advanceEvacuationMark(h, t, newbit) } }
runtime.evacuate
会将一个旧桶中的数据分流到两个新桶,所以它会创建两个用于保存分配上下文的 runtime.evacDst
结构体,这两个结构体分别指向了一个新桶:
下面通过相关流程图(取自Go 程序员面试笔试宝典 )来宏观地看一下扩容前后的变化。
扩容前,B = 2,共有 4 个 buckets,lowbits 表示 hash 值的低位。假设我们不关注其他 buckets 情况,专注在 2 号 bucket。并且假设 overflow 太多,触发了等量扩容(对应于前面的条件 2)。
扩容完成后,overflow bucket 消失了,key 都集中到了一个 bucket,更为紧凑了,提高了查找的效率。
假设触发了 2 倍的扩容,那么扩容完成后,老 buckets 中的 key 分裂到了 2 个 新的 bucket。一个在 x part,一个在 y 的 part。依据是 hash 的 lowbits。新 map 中 0-3
称为 x part,4-7
称为 y part。
注意,上面的两张图忽略了其他 buckets 的搬迁情况,表示所有的 bucket 都搬迁完毕后的情形。实际上,我们知道,搬迁是一个“渐进”的过程,并不会一下子就全部搬迁完毕。所以在搬迁过程中,oldbuckets 指针还会指向原来老的 []bmap,并且已经搬迁完毕的 key 的 tophash 值会是一个状态值,表示 key 的搬迁去向。
问题 map key为什么无序? 1.哈希表的实现机制
map 在底层是通过哈希表(hash table)实现的
key 通过哈希函数计算得到的哈希值决定了其在哈希表中的位置
哈希值的计算结果本身就不保证与 key 的顺序有关
当 map 容量需要扩展时,会触发扩容操作
扩容后所有 key 会重新计算哈希值并分配到新的位置
这个过程会改变原有的顺序
2.遍历实现
Go 在遍历 map 时会随机选择一个起始位置
这样做是为了避免程序依赖 map 的遍历顺序
每次遍历的顺序都可能不同
3.性能考虑
无序可以提高查询和插入的效率
不需要维护额外的顺序信息
减少了内存开销
如果需要有序遍历 map,可以:
1 2 3 4 5 6 7 8 9 10 11 12 13 keys := make ([]string , 0 , len (m))for k := range m { keys = append (keys, k) } sort.Strings(keys)for _, k := range keys { fmt.Println(k, m[k]) }
或者使用专门的有序 map 实现,如标准库中的 container/list 等。
总之,map 的无序性是一种设计选择,在大多数场景下这种设计是合理的,因为它提供了更好的性能和实现简单性。如果确实需要有序性,可以在 map 之上构建额外的有序索引。
float 类型可以作为 map 的 key 吗 从语法上看,是可以的。Go 语言中只要是可比较的类型都可以作为 key。除开 slice,map,functions 这几种类型,其他类型都是 OK 的。具体包括:布尔值、数字、字符串、指针、通道、接口类型、结构体、只包含上述类型的数组。这些类型的共同特征是支持 ==
和 !=
操作符,k1 == k2
时,可认为 k1 和 k2 是同一个 key。如果是结构体,只有 hash 后的值相等以及字面值相等,才被认为是相同的 key。很多字面值相等的,hash出来的值不一定相等,比如引用。
顺便说一句,任何类型都可以作为 value,包括 map 类型。
精度问题 :
1.精度问题(浮点数在计算机中表示时,存在精度误差的问题。比如,0.1 + 0.2 可能不会精确等于 0.3,这使得浮点数的直接比较变得不可靠。在 map
中,键的等值比较是必须的,但由于浮点数的精度问题,直接比较两个浮点数是否相等可能会导致意外结果
IEEE 754 标准 :
哈希和等值比较 :
map
需要键具有可哈希性和可比较性,以便能够一致地查找、插入和删除。浮点数由于其表示的不确定性,不适合直接用于需要精确匹配的场景中。
如何比较两个 map 相等 map 深度相等的条件:
1 2 3 1 、都为 nil 2 、非空、长度相等,指向同一个 map 实体对象3 、相应的 key 指向的 value “深度”相等
直接将使用 map1 == map2 是错误的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 func compareMaps (map1, map2 map [string ]int ) bool { if len (map1) != len (map2) { return false } for k, v1 := range map1 { v2, ok := map2[k] if !ok { return false } if v1 != v2 { return false } } return true }
参考链接 1.Go 程序员面试笔试宝典
2.《Go学习笔记》
3.Go 语言设计与实现