map实现

133 阅读5分钟

未初始化时读不会panic

A nil map behaves like an empty map when reading, but attempts to write to a nil map will cause a runtime panic; don’t do that. To initialize a map, use the built in make function:

不支持并发

// fatal error: concurrent map read and map write

// 对同样的key/不同的key 同时写入都会报错 fatal error: concurrent map writes

// 顺序写一拨key, 同时读 同一拨key/或者另外一拨key 不会报错;

// 不能一边写入,一边for range ,会报错 fatal error: concurrent map iteration and map write

缩容

// 缩容仅针对溢出桶过多的情况,delete并不会缩容

实现

cloud.tencent.com/developer/a…

hmap.buckets 指向 array of 2^B Buckets,

bmap:

  • tophash : [8]uint8 数组,(hash值的高8位)
  • Followed by bucketCnt keys and then bucketCnt elems, an overflow pointer

扩容:

  • bucketCnt:表示一个桶最多存储 8 个 key-value 对
  • 装载因子 6.5,即元素数量超过(桶数量*6.5) 时将触发 map 扩容
  • 或溢出的桶数量 noverflow>=32768(1<<15)
  • 插入和删除的函数内都有一段代码用于在每次插入和删除操作时,执行一次搬迁工作

源码

// A header for a Go map.
type hmap struct {
   // Note: the format of the hmap is also encoded in cmd/compile/internal/reflectdata/reflect.go.
   // Make sure this stays in sync with the compiler's definition.
   count     int // # live cells == size of map.  Must be first (used by len() builtin)
   flags     uint8
   B         uint8  // log_2 of # of buckets (can hold up to loadFactor * 2^B items)
   noverflow uint16 // approximate number of overflow buckets; see incrnoverflow for details
   hash0     uint32 // hash seed

   buckets    unsafe.Pointer // array of 2^B Buckets. may be nil if count==0.
   oldbuckets unsafe.Pointer // previous bucket array of half the size, non-nil only when growing
   nevacuate  uintptr        // progress counter for evacuation (buckets less than this have been evacuated)

   extra *mapextra // optional fields
}
// A bucket for a Go map.
type bmap struct {
   // tophash generally contains the top byte of the hash value
   // for each key in this bucket. If tophash[0] < minTopHash,
   // tophash[0] is a bucket evacuation state instead.
   tophash [bucketCnt]uint8
   // Followed by bucketCnt keys and then bucketCnt elems.
   // NOTE: packing all the keys together and then all the elems together makes the
   // code a bit more complicated than alternating key/elem/key/elem/... but it allows
   // us to eliminate padding which would be needed for, e.g., map[int64]int8.
   // Followed by an overflow pointer.
}

查找

先找到bucket, 再匹配tophash, 当前bucket没有再去overflow里面找

hash := t.hasher(key, uintptr(h.hash0))
m := bucketMask(h.B)
b := (*bmap)(add(h.buckets, (hash&m)*uintptr(t.bucketsize)))//找到bucket
...
top := tophash(hash)

创建

// makemap_small implements Go map creation for make(map[k]v) and
// make(map[k]v, hint) when hint is known to be at most bucketCnt
// at compile time and the map needs to be allocated on the heap.
func makemap_small() *hmap {
   h := new(hmap)
   h.hash0 = fastrand()
   return h
}

// makemap implements Go map creation for make(map[k]v, hint).

值得注意的是,makemap() 创建的 hash 数组,数组的前面是 hash 表的空间,当 hint >= 4 时后面会追加 2^(hint-4) 个桶,之后进行内存页对齐又追加了若干个桶,所以创建 map 时一次内存分配既分配了用户预期大小的 hash 数组,又追加了一定量的预留的溢出桶,还做了内存对齐,一举多得。

扩容

if !h.growing() && (overLoadFactor(h.count+1, h.B) || tooManyOverflowBuckets(h.noverflow, h.B)) {
   hashGrow(t, h)
   ...
}
  • bucketCnt:表示一个桶最多存储 8 个 key-value 对
  • 装载因子 6.5,即元素数量超过(桶数量*6.5) 时将触发 map 扩容
  • 或溢出的桶数量 noverflow>=32768(1<<15)

插入和删除的函数内都有下面一段代码用于在每次插入和删除操作时,执行一次搬迁工作:

if h.growing() {
   growWork(t, h, bucket)
}
func growWork(t *maptype, h *hmap, bucket uintptr) {
   // make sure we evacuate the oldbucket corresponding
   // to the bucket we're about to use
   evacuate(t, h, bucket&h.oldbucketmask())

   // evacuate one more oldbucket to make progress on growing
   if h.growing() {
      evacuate(t, h, h.nevacuate)
   }
}

(1)每执行一次插入或删除,都会调用 growWork() 函数搬迁 0~2 个 hash 桶(有可能这次需要搬迁的 2 个桶在此之前都被搬过了); (2)搬迁是以 hash 桶为单位的,包含对应的 hash 桶和这个桶的溢出链表; (3)被 delete 掉的元素(emptyone 标志)会被舍弃不进行搬迁。

迭代

map 的迭代是通过 hiter 结构和对应的两个辅助函数(mapiterinit()mapiternext())实现的。hiter 结构由编译器在调用辅助函数之前创建并传入,每次迭代结果也由 hiter 结构传回。

mapiterinit()函数主要是决定我们从哪个位置开始迭代,为什么是从哪个位置,而不是直接从 hash 数组头部开始呢?hash 表中数据每次插入的位置是变化的,这是因为实现的原因,一方面 hash 种子是随机的,这导致相同的数据在不同的 map 变量内的 hash 值不同;另一方面即使同一个 map 变量内,数据删除再添加的位置也有可能变化,因为在同一个桶及溢出链表中数据的位置不分先后,所以为了防止用户错误的依赖于每次迭代的顺序,map 作者干脆让相同的 map 每次迭代的顺序也是随机的。

map 的遍历由函数mapiternext()完成,过程如下: (1)从 hash 数组中第 it.startBucket 个桶开始,先遍历 hash 桶,然后是这个桶的溢出链表; (2)之后 hash 数组偏移量+1,继续前一步动作; (3)遍历每一个桶,无论是 hash 桶还是溢出桶,都从 it.offset 偏移量开始; (4)当迭代器经过一轮循环回到 it.startBucket 的位置,结束遍历。

注意: (1)map 如果在遍历开始时发现处于写入状态,那么报并发读写异常,终止程序。 (2)迭代还需要关注扩容的情况:如果是在迭代开始后才 growing,那么迭代初始状态如 it.buckets 和 it.B 等将被改变,迭代有可能出现异常。如果是先 growing,再开始迭代。这种情况下,不会出现异常,会先到旧 hash 表中检查 key 对应的桶有没有被迁移,未迁移则遍历旧桶,已迁移则遍历新 hash 表里对应的桶。

其他

map 的一个关键点在于,哈希函数的选择。在程序启动时,会检测 cpu 是否支持 aes,如果支持,则使用 aes hash,否则使用 memhash。这是在函数 alginit() 中完成,位于路径:src/runtime/alg.go 下。

go map 不支持并发读写,会导致不可恢复的异常(终止程序)。如果一定要并发,请用 sync.Map 或自己解决冲突。