Go 底层数据结构 - Map

208 阅读5分钟

Go 底层数据结构(二) - Map

当前 Golang 版本 1.18.1

基础概念:

  1. Map 是并发不安全的
  2. Map 的元素为值,不可取址与修改

注意事项:

  • Map 使用前,需使用make()初始化:
    • 对未初始化的 Map 进行读取操作,会返回默认值
    • 对未初始化的 Map 进行插入操作,会抛出 panic: assignment to entry in nil map
  • 提前规划容量可以减少因扩容产生的开销
数据结构

hmap 数据结构

// 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 // # 元素个数
    flags     uint8  // 记录几个特殊的位标记,如当前是否有别的线程正在写map、当前是否为相同大小的增长
    B         uint8  // 桶个数的对数 (可以容量 2^B 个元素)
    noverflow uint16 // 溢出的桶的数量的近似值
    hash0     uint32 // hash 种子
​
    buckets    unsafe.Pointer // 指向2^B个桶组成的数组的指针,数据存在这里。当 count == 0 时,该值可能为 nil
    oldbuckets unsafe.Pointer // 指向扩容前的旧buckets数组,只在map增长时有效。
    nevacuate  uintptr        // 计数器,标示扩容后搬迁的进度
​
    extra *mapextra // 选项字段,保存溢出桶的链表和未使用的溢出桶数组的首地址
}

mapextra 数据结构

// 该结构包含所有 map 中不存在的字段
type mapextra struct {
    // 如果 key 和 elem(元素) 都不包含指针,且是 inline 状态,那么我们会标记这个桶类型为“不包含指针”,
    // 这个操作避免了 gc 扫描整个 map。
    // 然而,bmap.overflow 是一个指针。为了保持溢出桶的存活(不被 gc 回收),我们将这些指针全部保存到 hmap.extra.overflow & hmap.extra.overflow 结构中。
    // overflow 仅包含 hmap.buckets 中的溢出桶,oldoverflow 仅包含 hmap.oldbuckets 中的溢出桶
    // The indirection allows to store a pointer to the slice in hiter.
    overflow    *[]*bmap
    oldoverflow *[]*bmap
​
    // nextOverflow holds a pointer to a free overflow bucket.
    nextOverflow *bmap
}

每个桶最多存储 8 对键值对,如果多于 8 个,那么会申请一个新的 bucket,并将它与之前的 bucket 链起来。

这里定义在字段 bucketCnt

bucketCntBits = 3

bucketCnt = 1 << bucketCntBits = 8 // 1 左移三位

在每个桶中,键值对以数组形式保存,将键、值区分开顺序存放。 其中, 前 4 位保存 key,后 4 位保存 value。

v2-5e4be7641d03d56c2dc68db1563cb6c9_r.jpg

// A bucket for a Go map. hmap 中每个 buckets 的数据结构
type bmap struct {
    // tophash 储存每一个键哈希值的高位字节
    // 如果 tophash[0] < minTopHash = 5, tophash[0] 此时为稀疏状态.
    tophash [bucketCnt]uint8
    // 提醒:将 key 和 value 分开打包在一起,会比交替 k/v 保存更复杂。
    // 但是这会让我们消除填充字节(字节对齐),例如 map[int64]int8
}

桶指针指向桶地址,可以通过桶首地址及偏移量查询所有的桶,通过每个桶再查找到对应的键。

如何定位键值对
查找过程

按 key 的类型采用相应的 hash 算法得到 key 的 hash 值。将 hash 值 的低 8 位当作 hmap 结构体中 buckets 数组的 index。

确定桶内索引:

确定桶后,顺序遍历数组的 key 部分,与 key 的 hash 值的高 8 位匹配。

先比较 hash 值高位与 bucket 的 tophash[i] 是否相等,如果相等则再比较 bucket 的第 i 个的 key 与所给的 key 是否相等。如果相等,则返回其对应的 value,反之,在 overflow buckets 中按照上述方法继续寻找。

插入过程
  1. 根据 key 算出 hash 值,进而得出对应的 bucket
  2. 如果 bucket 在 old table 中,将其重新散列到 new table 中
  3. 在 bucket 中,查找空闲的位置,如果已经存在需要插入的 key,更新其对应的 value
  4. 根据 table 中元素的个数,判断是否 grow table
  5. 如果对应的 bucket 已经 full,重新申请的新的 bucket 作为 overbucket
  6. 将 key/value pair 插入到 bucket 中

在扩容过程中,oldbucket 是被冻结的,查找时会在 oldbucket 中查找,但不会在 oldbucket 中插入数据。

删除过程
  1. 如果 hmap 不为空,计算其 hash 值,进而得出对应bucket

    bucket := hash & bucketMask(h.B)

  2. 判断是否存在溢出桶,如果存在则添加

    if h.growing() {
        growWork(t, h, bucket)
    }
    b := (*bmap)(add(h.buckets, bucket*uintptr(t.bucketsize)))
    
  3. 找到 tophash 值

  4. 依次遍历哈希桶溢出桶

  5. 找到对应键与元素,根据键与元素是否包含指针采取不同删除策略:

    • 如果存在指针,则 memclrHasPointers(k, t.key.size),memclrHasPointers(e, t.elem.size)
    • 如果不存在,则直接 *(*unsafe.Pointer)(k) = nil,memclrNoHeapPointers(e, t.elem.size)
    // Only clear key if there are pointers in it.
    if t.indirectkey() {
        *(*unsafe.Pointer)(k) = nil
    } else if t.key.ptrdata != 0 {
        memclrHasPointers(k, t.key.size)
    }
    e := add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.elemsize))
    if t.indirectelem() {
        *(*unsafe.Pointer)(e) = nil
    } else if t.elem.ptrdata != 0 {
        memclrHasPointers(e, t.elem.size)
    } else {
        memclrNoHeapPointers(e, t.elem.size)
    }
    
如何扩容

map 每次扩容,其大小均为扩容前的 2 倍,即扩容前大小为 2^B,扩容后大小为 2^(B+1)

哈希表大小始终为2的指数倍,则有 (hash mod 2^B) 等价于 (hash & (2^B-1))。这样可以简化运算,避免了取余操作。

每次扩容后,一般认为 (hash mod 2^B) != (hash mod 2^(B+1)),所以扩容之后需要重新计算每一项在哈希表中的新位置。当 hash 表扩容之后,需要将那些旧的 pair 重新哈希到新的table上(源代码中称之为 evacuate ), 这个工作并没有在扩容之后一次性完成,而是逐步的完成(在 insert 和 remove 时每次搬移 1-2 个 pair ),Go语 言使用的是增量扩容。

增量扩容的原因

缩短 map 容器的响应时间。扩容会建立一个大小是原来 2 倍的新的表,将旧的 bucket 搬到新的表中之后,并不会将旧的 bucket 从 oldbucket 中删除,而是加上一个已删除的标记。

假如我们直接将 map 用作某个响应实时性要求非常高的 web 应用存储,如果不采用增量扩容,当 map 里面存储的元素很多之后,扩容时系统就会卡往,导致较长一段时间内无法响应请求。不过增量扩容本质上还是将总的扩容时间分摊到了每一次哈希操作上面。

负载因子

我们可以在注释(github)中找到相关信息:

// 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)

师爷,翻译翻译:

// 选择负载因子:

// 过大的负载因子我们存在许多溢出桶;过小的负载因子又会浪费许多空间。

// 我编写了一个简单的程序去检验不同的负载因子的实际表现情况。

// 如下图:👇

image-20220331003521197.png

// Keep in mind this data is for maximally loaded tables, i.e. just

// before the table grows. Typical tables will be somewhat less loaded.

// 牢记,在实验数据表改变前,这个值即负载因子。

// Maximum average load of a bucket that triggers growth is 6.5.
// Represent as loadFactorNum/loadFactorDen, to allow integer math.
loadFactorNum = 13
loadFactorDen = 2

参考资料:

go/map.go at master · golang/go (github.com)

Go 语言 map 的底层实现完整剖析 - 知乎 (zhihu.com)

map的实现 · 《深入解析Go》