「读书笔记」了解 map 实现原理并高效使用

201 阅读7分钟

声明、类型、语句与控制结构

14 了解 map 实现原理并高效使用

什么是 map?

map 表示一组无序的键值对key-value,map 对 value 没有限制,但是对 key 的类型有严格要求:key 的类型应该严格定义了作为 “==” 和 “!=” 两个操作符的操作数时的行为。因此函数map切片不能作为 map 的 key 类型。

map 类型不支持“零值可用”,未显式赋初值的 map 类型变量的零值为 nil,对处于零值状态的 map 变量进行操作将会导致运行时 panic。创建 map 类型变量有两种方式:

  • 使用复合字面值创建 map 类型变量。
  • 使用 make 创建 map 类型变量。

和切片一样,map 也是引用类型,将 map 类型变量作为函数参数传入不会有很大的性能损耗,并且在函数内部对 map 变量的修改在函数外部也是可见的。

基本操作

  • 查找:判断某个 key 是否存在与某个 map 中,可以使用 "comma ok"惯用法来进行查找:

    _, ok := m["key"]
    if !ok {
       // "key" 不在 map 中
    }
    
  • 取值:如果要读取 key 对应的 value 值:v := m["key"],如果 key 不存在于 map 中,v 会被赋予一个“合法”值 0,这个值是 value 类型 int 的零值。在这样的情况下,我们无法判断这个 0 是"key"对应的值还是因为"key"不存在而返回的零值,为此我们还需要借助"comma ok"惯用法。
  • 删除:delete(m, "key"),即使要删除的数据在 map 中不存在,delete 也不会导致 panic。
  • 遍历:for k, v := range m,注意千万不要依赖遍历 map 所得到的元素次序,如果需要一个稳定的遍历次序,一个通用做法是使用另一种数据结构来按需要的次序保存 key,比如切片。

map 底层实现

与语法层面 map 类型变量一一对应的是 runtime.hmap 类型的实例。hmap 是 map 类型的 header,可以理解为 map 类型的描述符,它存储了后续 map 类型操作所需的所有信息。

type hmap struct {
   count     int    // 当前 map 中元素个数,len 函数返回的就是 count 的值
   flags     uint8  // 当前 map 所处的状态标志:iterator、oldIterator、hashWriting、sameSizeGrow
   B         uint8  // bucket 数量的以 2 为底的对数,即 2^B = bucket数量
   noverflow uint16 // overflow bucket 的大约数量
   hash0     uint32 // 哈希函数的种子值
​
   buckets    unsafe.Pointer // 指向 bucket 数组的指针
   oldbuckets unsafe.Pointer // 在 map 扩容阶段指向前一个 bucket 数组的指针
   nevacuate  uintptr        // 在 map 扩容阶段充当扩容进度计数器,所有下标小于 nevacuate 的 bucket 都已经完成了数据排空和迁移操作
​
   extra *mapextra // 可选字段。如果有 overflow bucket 存在,且 key、value 都因不包含指针而被内联的情况下,该字段将存储所有指向 overflow bucket 的指针,保证 overflow bucket 是始终可用的(不被垃圾回收掉)
}

真正用来存键值对数据的是 bucket,每个 bucket 中存储的是 Hash 值低 bit 位数值相同的元素,默认的元素个数为 BUCKETSIZE(8)。当某个 bucket 的 8 个空槽(slot)都已填满且 map 尚未达到扩容条件时,运行时会建立 overflow bucket,并将该 overflow bucket 挂在上面 bucket 末尾的 overflow 指针上,这样两个 bucket 形成了一个链表结构,该结构的存在将持续到下一次 map 扩容。

每个 bucket 由三部分组成:tophash 区域、key 存储区域和 value 存储区域。

  • tophash 区域:当向 map 插入一条数据或从 map 按 key 查询数据的时候,运行时会使用哈希函数对 key 做哈希运算并获得一个哈希值 hashcode。运行时将hashcode“一分为二”看待,其中低位区的值用于选定 bucket,高位区的值用于在某个 bucket 中确定 key 的位置。因此,每个 bucket 的 tophash 区域是用于快速定位 key 位置的,这样避免了逐个 key 进行比较这种代价较大的操作,尤其是当 key 是 size 较大的字符串类型时,这是一种以空间换时间的思路。

  • key 存储区域:tophash 区域下面是一块连续的内存区域,存储的是该 bucket 承载的所有 key 数据。运行时在分配 bucket 时需要知道 key 的大小,当我们声明一个 map 类型变量时,Go 运行时就会为该变量对应的特定 map 类型生成一个 runtime.maptype 实例(如存在则复用)。

    type maptype struct {
       typ        _type
       key        *_type
       elem       *_type
       bucket     *_type // 表示 hash bucket 的内部类型
       keysize    uint8  // key 的大小
       elemsize   uint8  // elem 的大小
       bucketsize uint16 // bucket 的大小
       flags      uint32
    }
    

    该实例包含了 map 类型的所有元信息,编译器会将语法层面的 map 操作重写成运行时对应的函数调用,这些运行时函数有一个共同的特点:第一个参数都是 maptype 指针类型的参数。Go 运行时就是利用 maptype 参数中的信息确定 key 的类型和大小的,同时 maptype 的存在也让 Go 中所有 map 类型共享一套运行时 map 操作函数,而无需像 C++ 那样为每种 map 类型创建一套 map 操作函数,从而减少了对最终二进制文件空间的占用。

  • value 存储区域:key 存储区域下方是另一块连续的内存区域,该区域存储的是 key 对应的 value,该区域的创建也得到了 maptype 中信息的帮助。Go 运行时采用了将 key 和 value 分开存储而不是采用一个 kv 接着一个 kv 的 kv 紧邻方式存储,这带来的是算法上的复杂性,但却减少了因内存对齐带来的内存浪费。另外注意,如果 key 或 value 的数据长度大于一定数值,那么运行时不会在 bucket 中直接存储数据,而是会存储 key 或 value 数据的指针。目前 Go 运行时定义的最大 key 和 value 的长度是 128。

map 扩容

Go 运行时的 map 实现中引入了一个 LoadFactor(负载因子),当 count > LoadFactor * 2^B 或 overflow bucket 过多时,Go 运行时会对 map 进行扩容。目前 LoadFactor 设置为 6.5。

  • 如果是因为 overflow bucket 过多导致的“扩容”,实际上运行时会新建一个和现有规模一样的 bucket 数组,然后在进行 assign 和 delete 操作时进行排空和迁移;
  • 如果是因为当前数据数量超出 LoadFactor 指定的水位的情况,那么运行时会建立一个两倍于现有规模的 bucket 数组,但真正的排空和迁移工作也是在进行 assign 和 delete 操作时住不进行的。原 bucket 数组会挂在 hmap 的 oldbuckets 指针下面,直到原 buckets 数组中所有数据都迁移到新数组,原 buckets 数组才会被释放。

map 与并发

充当 map 描述符角色的 hmap 实例自身是有状态的(hmap.flags)且对状态的读写是没有并发保护的,因此 map 实例不是并发写安全的,不支持并发读写

  • 如果对 map 实例进行并发读写,程序运行时会发生 panic。
  • 如果仅仅是并发读,则 map 是没有问题的。
  • Go1.9 版本中引入了支持并发写安全的 sync.Map 类型,可以用来在并发读写的场景下替换掉 map。
  • 另外考虑到 map 可以自动扩容,map 中数据元素的 value 位置可能在这一过程中发生变化,因此 Go 不允许读取 map 中 value 的地址,这个约束是在编译期间就生效的。

尽量使用 cap 参数创建 map

如果初始创建 map 时没有创建足够多可以应付 map 使用场景的 bucket,那么随着插入 map 元素数量的增多,map 会频繁扩容,而这一过程将降低 map 的访问性能,因此,如果可能的话,我们最好对 map 使用规模做出粗略的估算,并使用 cap 参数对 map 实例进行初始化

往期回顾

关注我

掘金:XQGang

Github: XQ-Gang

参考

《Go 语言精进之路:从新手到高手的编程思想、方法和技巧》——白明