golang 的 map 实现(一)

概述

哈希表是工程中常用到的数据类型,能提供快速的检索和更新。复杂度一般为 O(1)

本篇博文分 两部分写,第一部分是源码学习,第二部分是一些内部实现,以及觉着有意思的一些地方,以及个人思考

理论

哈希表需要解决的问题有两个

  • 位置索引
  • 数据碰撞

索引交给 hash function 哈希算法,常用就是模运算

解决碰撞主要有以下三种方式

  1. 分离链接,也就是利用链表性质存储冲突的 key,然后通过遍历来区分(单独的存储层面)
  2. 开放定址
    1. 线性探测(存储层面以及算法层面都有所调整)
    2. 平方探测(同上,只是算法层面小改动而已)
    3. 双散列
  3. 再散列 (扩容以及数据迁移)

可扩散列是用来解决数据太大而无法装进内存的场景,此处不讨论

哈希表的效率load factor 装填因子有关,用来估量其平均复杂度。含义就是一个其计算方式一般就是使用 已经存储的数据量 / 可索引地址的数量。 或者说,单个索引地址的平均长度

碰撞的解决

理想情况下,没有碰撞的时候,使用一个数组,与一个哈希算法就可以实现散列结构。但是碰撞无法完全避免,那么就有了以下几种方式来解决。

分离链接

分离链接的核心是通过使用链表来处理碰撞问题。数组用来做索引,内部存储链表,链表存储的是哈希碰撞的 key 以及 value,存储 key 是为了在冲突的时候,仍旧可以通过比对来实现定位。

开放定址

链表的问题是节点申请,会造成内存的频繁操作。如果在数据量不是特别大的时候,可以考虑开放定址的方式。其仍旧使用一个比较大的数组。只是在发生碰撞的时候,可以通过向固定方向进行偏移来进行存储,从而解决碰撞问题。
线性探测和平方探测就在于偏移量选择上。双散列(略)

再散列

碰撞某种程度上可以说是存储空间较小造成的。那么 rehash 的思想就是,申请更大的空间,然后将数据重新计算,重新定位。

Golang 的 map 实现

golang 中的 map 是一个哈希表,其实现方式使用到了链表以及 rehash。

链表是在用在较小层面碰撞,rehash 则是当 load factor 较大的时候使用的方式。

注意:本篇记录是基于 go 1.9.2 版本记录的。

数据结构

golang 的 map 内并没有直接存储传递进来的 keyvalue,而是使用了其引用,以及 key 的 hash 值的高位(后面再说)。

下面是 map 数据结构的部分,选取了主要是跟存储相关的域。

type hmap struct {
    B          uint8
    buckets    unsafe.Pointer
    oldbuckets unsafe.Pointer
    extra      *mapextra
}

buckets 与 oldbuckets 是指向一段连续地址的指针地址。主要是用来存储 key 和 value 的引用地址,暂时理解成数据部分好了。其中oldbuckets 只有在扩容的时候才会用到。两者与前面『分离链接』实现中的数组功能类似,供初步索引使用。

type mapextra struct {
    overflow [2]*[]*bmap
    nextOverflow *bmap
}

暂时只关注 nextOverflow 就好,指向的也是一个类似于 buckets 的连续地址(最后一个 bucket 的最后维护的是一个地址。 buckets 和 oldbuckets 中没有这个),从名字上就能看出来是在空间不够时(但是又不足以触发 rehash 逻辑)从系统中申请内存临时使用的空间做 缓冲。

type bmap struct {
    tophash [bucketCnt]uint8
}

bmap 是 bucket 数据结构的部分结构。 其功能是来大致确认这个链表的地址。是一个空间为 8 的数组。其值是 key 的 hash 值的高位。当传递进来一个 key 的时候,会做比对,然后确定这个数组下标,这个下标,与这个 key 所存储的链表头部有关。

内存结构

下面的结构都是通过源码个人理解画出来的,可能有所偏差。其实应该放在 map 的操作之后。但是为了有助于理解后面的操作,所以就放在了前面。

先不考虑扩容场景,map 存储数据会先使用 buckets,当空间不够(先这么说)才会去使用 overflow 区域。所以下面就放了这两个的结构。

bucket

bmap
|    数据对齐
|    |
|    |  |    key field  |  value field  | 指向下一个 bucket 的指针
|____|__|_|_|_|_|_|_|_|_|_|_|_|_|_|_|_|_|_|

bmap 为 8 个 uint8 的连续空间,用来存储 tophash 。后面跟的数据对齐是内存层面的操作。 key field 和 value field 是用来存储 key 引用以及 value 引用,两者都是 8 个空间,可以通过 tophash 的偏移量来进行计算每个引用自身的存储位置,从而获取到 key 以及 value。

nextOverFlow

|_|_|_|_|_|_|_-|

每两个竖线之间都是一个 bucket

nextoverflow 指向的为一组 bucket 大小的连续空间,功能与上面的 bucket 一样。不过 nextoverflow 最后一个 bucket,也就是上面 |----| 是特殊使用,不是用来存储数据,而是一个结尾符。用来告知该缓冲区已经结束。实现方式就是在最后一个引用大小的空间,存储了 hmap 结构中的 buckets 的头地址。

注意:在需要创建的 bucket 超过 8 个的时候,golang 预先申请了 nextoverflow 的空间,减少内存操作(细节赞),那么此时 buckets 和 nextoverflow 在内存上就是连续的了。结构就会如下图。

                                          |     | ----> 最后一个 bucketSize 大小的空间
buckets 头部                 nextOverFlow  |   存储了 buckets 头部地址
|                           |             |  |  |
|                           |             |  |  |
|_|_|_|_|_|_|_|_|_|_|_|_|_|_|_|_|_|_|_|_|_|-| 最后 '-' 为 sys.PtrSize,不知道 ptrSeize 和 bucketSize

这个图是最早画的,不舍得删

接口实现

map 的接口实现除了上面的内存结构,还有一些没有画出来的域有关,比如数据竞争,装载因子大小,扩容时候使用的数据结构都有关。

创建

创建其实就是完成内存的申请,以及一些初始值的设定。那么这里假设创建的空间较大,也就是说将 overflow 区域的初始化,也一并放在这里记录。

makemap 是完成 map 结构的函数。下面是原生代码抽出来,并方便阅读改了一些的 『伪伪代码』

// hint 代表的 capacity
func makemap(t *maptype, hint int64) *hmap  {
    // 条件检查
    t.keysize = sys.PtrSize = t.key.size
    t.valuesize = sys.PtrSize = t.elem.size

    // 通过 hint 确定 hmap 中最小的 B 应该是多大。
    // B 与后面的内存空间申请,以及未来可能的扩容都有关。B 是一个基数。
    // overLoadFactor 考虑了装载因子。golang 将其初始设置为 0.65
    B := uint8(0)
    for ; overLoadFactor(hint, B); B++ {}

    // golang 是 lazy 形式申请内存
        if B != 0 {
        var nextOverflow *bmap
        buckets, nextOverflow = makeBucketArray(t, B)
        if nextOverflow != nil {
            extra = new(mapextra)
            extra.nextOverflow = nextOverflow
        }
    }

    // 后面就是将内存地址关联到 hmap 结构,并返回实例
    h.count = 0  // 记录存储的 k/v pari 数量。扩容时候会用到
    h.B = B  // 记录基数
    h.flags = 0 // 与状态有关。包含并发控制,以及扩容。

    ...
}

// makeBucketArray 会根据情况判断是否要申请 nextOverflow 。
func makeBucketArray(t *maptype, b uint8) (buckets unsafe.Pointer, nextOverflow *bmap) {
    base := uintptr(1 << b)
    nbuckets := base
    if b >= 4 {
        // 向上调整 nbuckets
    }

    // 注意,是按照 nbuckets 申请内存的
    buckets = newarray(t.bucket, int(nbuckets))

    // 处理 overflow 情况,
    if base != nbuckets {
        // 移动到 数据段 的末尾
        nextOverflow = (*bmap)(add(buckets, base*uintptr(t.bucketsize)))

        // 设置末尾地址,参考上面内存图中 nextoverflow 最后的那个指针位。用来做末尾检测
        last := (*bmap)(add(buckets, (nbuckets-1)*uintptr(t.bucketsize)))
        last.setoverflow(t, (*bmap)(buckets))
    }
    return buckets, nextOverflow
}

配合看内存图的第三个,效果更佳。便于有一个整体印象。

读取

读取有 mapaccess1mapaccess2 两个,前者返回指针,后者返回指针和一个 bool,用于判断 key 是否存在。这里只说 mapaccess1。 指针是 value field中存储的地址

func mapaccess1(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {
// 如果为空或者长度为 0,那么就返回一个 0 值

// 如果正在被写入,那么抛出异常

// 获取 key 的 hash 值

// 确认该 key 所在的 bucket 位置 (可能是在 buckets 也有可能在 oldbuckets 中)
// 使用模计算,先计算出如果在 buckets 中,则是在哪个 bucket
// 检测 oldbucket 是否为空,如果不为空,则用上面同样的方式得出在 oldbuckets 的位置
// 并检测该 bucket 是否已经被 evacuate ,如果已经被 evacuate 则使用 buckets, 否则使用 oldbuckets 中的位置
    m := uintptr(1)<<h.B - 1
    b := (*bmap)(add(h.buckets, (hash&m)*uintptr(t.bucketsize)))  // buckets 结构
    if c := h.oldbuckets; c != nil {
        if !h.sameSizeGrow() {  // 上次扩容是等量还是双倍扩容, 会有影响
            // There used to be half as many buckets; mask down one more power of two.
            m >>= 1
        }
        oldb := (*bmap)(add(c, (hash&m)*uintptr(t.bucketsize)))
        if !evacuated(oldb) {
            b = oldb
        }
    }

// 得到 bucket 以后,通过 tophash 来再次定位,如果定位不到,则递归该 bucket 的 overflow 域,循环查找。
// 以上步骤有两个结果。
// 1 遍历到最后,都没有找到命中的 tophash ,此时则返回一个零值。
// 2 命中 tophash 则进行 key 比对,相同则返回对应的 val 位置,不同则通过 overflow 继续获取,否则返回一个零值

写入

这里用『写入』并不是很严格。 因为最后返回的是一个指针地址,用来存储 value。 即通过输入的 key 来确认要写入 value 引用的地址 (考虑 bucket 结构中的 value field

在更进一步讲写入的操作之前,说一下有关扩容的事情。随着写入量的增加,扩容不可避免。如果扩容,那么涉及新空间的申请,然后是老空间数据的迁移,以及最后老空间的回收。 数据迁移部分可以一次性完成,但是这样可能会导致某次操作特别慢,所以 golang 在迁移时,使用了 lazy 的方式,只有当要变更一个 oldbucket 内元素的时候,会安静该 oldbucket 重新 hash 写入到 buckets 中去,并将该 oldbucket 删除引用,交由 gc 进行空间回收。

更多的 grow 相关操作会在 『内部』细说,这里更多的是整体流程。

mapassign 是完成该操作的主体函数。(写到这里,突然不想写了…心好累)

func mapassign(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {
    // 并发检查
    // 计算 hash 值
    // 更新状态为 写入

    // 下面是一个三层的循环嵌套。从里向外说
    // 第三层是为了在一个 bucket 中,定位到一个 key 的位置
        // 如果成功(更新操作),则直接可以计算出 val 的位置,跳转到结束阶段
        // 定位失败,则第二层循环开始工作
    // 第二层循环是递归该 bucket 的 overflow 区域,持续获取新的bucket位置
        // 成功,则执行第三层循环
        // 失败(没有 overflow 区域了,即插入操作)跳回到第一层
    // 第一层是为了获取空间来执行写入操作(如果是插入操作,则 h.count++,记录 map 内 key 的数量)
        // 要么 hashGrow (后面讲),然后接着跳转到三层循环,继续运行
        // 要么 overflow, 本层直接执行插入操作
        // 操作完成

    // 最后返回 val 地址
}

删除

mapdelete 是负责删除的的主体函数

func mapdelete(t *maptype, h *hmap, key unsafe.Pointer) {
    // 同 access 定位到位置,没定位到就啥都不干
    // 定位到以后,会先删除 value 里面的引用,后面由 gc 进行进行空间回收
    // 将 tophash 中对应位置设置为 empty (有意思的也就是这里)
    // h.count--
}

参考资料
https://www.gitbook.com/book/tiancaiamao/go-internals/details

文章首次发布于个人博客 https://blog.i19.me

猜你喜欢

转载自blog.csdn.net/totoro19/article/details/79581414