声明、类型、语句与控制结构
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 实例进行初始化。
往期回顾
关注我
参考
《Go 语言精进之路:从新手到高手的编程思想、方法和技巧》——白明