4.go map源码-扩容/缩容

702 阅读4分钟

上集回顾:map的增删改查

1. 触发条件

go map的扩容是增量扩容,发生的动作是元素的插入或更新,即:mapssign函数中处理

func mapassign(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {
    ...
    
    if !h.growing() && (overLoadFactor(h.count+1, h.B) || tooManyOverflowBuckets(h.noverflow, h.B)) {
        hashGrow(t, h)
        goto again // Growing the table invalidates everything, so try again
    }
 ...
}

1.1 非扩容中

如果map正处于扩容中,就再会进行二次扩容,等待上一次扩容完成后,才进行。

func (h *hmap) growing() bool {
    return h.oldbuckets != nil
}

1.2 触发最大装填因子

在理想状态下(不包含溢出桶),将对数B的map装满则需要:count=2B8count = 2^B * 8 (每个hmap存储8个元素)。但是在现实中不可能等到数据填满再进行扩容,所以经过试验,当数值大于一定程度就需要进行扩容:count>2B6.5count > 2^B *6.5

在现实中,map是存在链桶(溢出桶),count的数量计算多了一个因素,所以除了装填因子以外,还需要对溢出桶的进行判断(1.3)。

查阅 runtime/map.go文件,可以在置顶位置看到go官方在测试装填因子的相关实验结果,能在表中看到当,装填因子为6.5的时候,map使用的整体效果是最佳的。

选取的装填因子,如果太大,我们有很多溢出桶;太小,我们浪费很多空间,下边是编写一个简单的程序来检查不同负载的一些统计数据 [官方摘录] :

loadFactor%overflowbytes/entryhitprobemissprobe
4.002.1320.773.004.00
4.504.0517.303.254.50
5.006.8514.773.505.00
5.5010.5512.943.755.50
6.0015.2711.674.006.00
6.5020.9010.794.256.50
7.0027.1410.154.507.00
7.5034.039.734.757.50
8.0041.109.405.008.00
字段含义
%overflow具有溢出桶的桶的百分比
bytes/entry每个键值对使用的开销字节
hitprobe查找当前key时要检查的条目数
missprobe查找缺少的key时要检查的条目数
/*
:params count: 预申请的元素数量
:params B: map的对数值
*/
func overLoadFactor(count int, B uint8) bool {
    return count > bucketCnt && uintptr(count) > loadFactorNum*(bucketShift(B)/loadFactorDen)
}

1.3 存在过多溢出桶

  1. map存储的元素确实过多,hash数组不大,导致哈希冲突过多。
  2. 在前面章节中,map的添加/删除操作都只是针对topmap进行标志位处理,并没有真正的去对溢出桶进行回收。所以会面临空闲空间被占用。
/*
:prams noverflow: 溢出桶数量 
:prams B: 对数值
*/
func tooManyOverflowBuckets(noverflow uint16, B uint8) bool {
    // 这里做了一个阀值控制,避免太低(需要做额外工作),避免太高(增长和收缩的映射会保留大量未使用的内存)
    // 细节看incrnoverflow
    // 解释一下太高的问题,因为目前go map并没有缩容,当map无限扩大,会导致大量的内存浪费。
    if B > 15 {
        B = 15
    }
    return noverflow >= uint16(1)<<(B&15)
}

2. 扩容

扩容有两种,针对不同情况进行选择

  1. 达到了最大装载因子,就需要更大的hash数组。
  2. 这go map是溢出桶过多导致的,就需要保持桶的数量不变,并横向“增长”。
/*
:params t: 存储了key/value和桶大小等信息
:params h: map
*/
func hashGrow(t *maptype, h *hmap) {

    bigger := uint8(1)
    if !overLoadFactor(h.count+1, h.B) {
        // 进行等量的内存扩容,所以B不变
        bigger = 0
        h.flags |= sameSizeGrow
    }
    // 将老 bucekts 挂到 buckets 上
    oldbuckets := h.buckets
    // 申请新的 buckets 空间
    newbuckets, nextOverflow := makeBucketArray(t, h.B+bigger, nil)
    // 初始化flags标志,清除其中的iterator和oldIterator标志
    flags := h.flags &^ (iterator | oldIterator)
    // 如果扩容前map的flags是存在迭代,就需要把旧的标志加上
    if h.flags&iterator != 0 {
        flags |= oldIterator
    }
    // commit the grow (atomic wrt gc)
    // 当前hash数组变旧的,添加新的进来(这里可以看到扩容是成倍的)
    h.B += bigger
    h.flags = flags
    h.oldbuckets = oldbuckets
    h.buckets = newbuckets
    // 因为是刚扩容,所以搬迁进度为0
    h.nevacuate = 0
    // 溢出桶数量也需要重置
    h.noverflow = 0

    // 同样在hash数组的溢出桶也需要做对应调整
    if h.extra != nil && h.extra.overflow != nil {
        // 将当前的溢出桶调整为旧溢出桶
        if h.extra.oldoverflow != nil {
            throw("oldoverflow is not nil")
        }
        h.extra.oldoverflow = h.extra.overflow
        h.extra.overflow = nil
    }
    if nextOverflow != nil {
        if h.extra == nil {
                h.extra = new(mapextra)
        }
        h.extra.nextOverflow = nextOverflow
    }
    // 这里只做扩容的申请空间和对应字段更新,并没有真正开始做数据迁移
    // 哈希表数据的实际复制是增量完成的:growWork() and evacuate()
}

3. 搬迁

go map的数据迁移是增量完成的,你会看到添加/删除的过程会进行迁移,每次操作涉及到两次迁移。至于为什么需要出现两次迁移,在回答这个问题前,先考虑一个问题:

go map是增量扩容,每次迁移桶确认:b = hash(key) & (2^B-1),在不影响外界操作的情况下,快速将所有桶进行全部迁移(不能保证每次key值都落在不同桶,这样就很难保证所有桶快速迁移)?

  1. 第一种方式:如果key所在桶没迁移,进行当前桶迁移,否则,向下取第一个未迁移的桶
  2. 第二种方式:迁移当前key所在桶,再选取迁移进度所在的桶(迁移进度只是最终一致性)

3.1 开始迁移

/*
:params t: 存储了key/value和桶大小等信息
:params h: map
:parmas bucket: 当前桶(代指当前桶所在hash数组索引)
*/
func growWork(t *maptype, h *hmap, bucket uintptr) {
    // 我们要确保排空即将使用的桶对应的旧桶
    evacuate(t, h, bucket&h.oldbucketmask())

    // 再搬迁一个 bucket,以加快搬迁进度
    if h.growing() {
        evacuate(t, h, h.nevacuate)
    }
}

3.2 计算新桶信息

/*
:params t: 存储了key/value和桶大小等信息
:params h: map
:params oldbucket: 旧桶(代指旧桶所在hash数组索引)
*/
func evacuate(t *maptype, h *hmap, oldbucket uintptr) {
    // 1.计算旧桶地址及数组大小
    b := (*bmap)(add(h.oldbuckets, oldbucket*uintptr(t.bucketsize)))
    // 旧桶的容量
    newbit := h.noldbuckets()
    // 有没有被搬迁过
    if !evacuated(b) {
        // TODO: 如果没有使用旧桶的迭代器,请重用溢出桶,而不是使用新桶。(如果!oldIterator),作者预留了下一次需要做的事情

        // 默认是 等量扩容,前后 bucket序号不变
        // 计算新桶前半段信息
        var xy [2]evacDst
        x := &xy[0]
        x.b = (*bmap)(add(h.buckets, oldbucket*uintptr(t.bucketsize)))
        x.k = add(unsafe.Pointer(x.b), dataOffset)
        x.e = add(x.k, bucketCnt*uintptr(t.keysize))

        // 计算新桶后半段信息
        if !h.sameSizeGrow() {
            // 如果我们越来越大,只计算y指针。否则GC会看到错误的指针。
            y := &xy[1]
            y.b = (*bmap)(add(h.buckets, (oldbucket+newbit)*uintptr(t.bucketsize)))
            y.k = add(unsafe.Pointer(y.b), dataOffset)
            y.e = add(y.k, bucketCnt*uintptr(t.keysize))
        }

'''               
}

3.3 当前链桶数据迁移

具体的迁移方案可以看:迁移方案,标志位设置看:迁移标志位。 在看源码的过程中,应该也看到 key!=keykey != key 的奇怪行为,一度以为是作者脑子给驴踢了,看着其他博主介绍,才解开存疑,具体看上述的迁移方案。

  1. 我们第一步需要确认旧桶位置到新桶位置(hash数组的索引),第二步则需要对旧桶的数据迁移到新桶。
  2. 针对旧桶中已经迁移的tophash,设置其迁移标志位
  3. 这里做了一点优化,手动gc,在没有迭代介入的情况下,将溢出桶的key/value/溢出链表做了清空操作。
  4. 最后更新一下迁移进度了
func evacuate(t *maptype, h *hmap, oldbucket uintptr) {
    
    ... 
    // 遍历旧桶中的链桶
    for ; b != nil; b = b.overflow(t) {
        k := add(unsafe.Pointer(b), dataOffset)
        e := add(k, bucketCnt*uintptr(t.keysize))
        // 遍历桶内数据
        for i := 0; i < bucketCnt; i, k, e = i+1, add(k, uintptr(t.keysize)), add(e, uintptr(t.elemsize)) {
            top := b.tophash[i]
            // 空cell,将topthsh[i]标志位设置为 evacuatedEmpty
            if isEmpty(top) {
                b.tophash[i] = evacuatedEmpty
                continue
            }
            // 正常的情况,isEmpty已经判空了,如果还出现小于minTopHash,这种只能是异常状态
            if top < minTopHash {
                throw("bad map state")
            }
            k2 := k
            if t.indirectkey() {
                k2 = *((*unsafe.Pointer)(k2))
            }
            // 用来确定旧key应该放在新桶的前半段还是后半段
            var useY uint8
            // 非等量扩容(即:两倍扩容)
            if !h.sameSizeGrow() {
                // 计算hash以做出迁移决定(是否需要将此 key/elem 发送到桶X或桶Y)
                hash := t.hasher(k2, uintptr(h.hash0))
                // 这里有存疑点是t.flags的状态定义,没有探索到这部分,但对应的条件可以解释(看章节细节推动)
                // key != key,但显现的结果又是一致,由于迭代器需要遍历,又不能随意设置,所以依托top来计算这类key的存储位置。
                if h.flags&iterator != 0 && !t.reflexivekey() && !t.key.equal(k2, k2) {
                    useY = top & 1
                    top = tophash(hash)
                } else {
                    // 这条公式决定了是将该元素存储在新桶的前半段还是后半段(符合取模规则)
                    if hash&newbit != 0 {
                        useY = 1
                    }
                }
            }
            // 在写入标志位时,需要确定是我想要的常量值(没有应该也不碍事才对)
            if evacuatedX+1 != evacuatedY || evacuatedX^1 != evacuatedY {
                throw("bad evacuatedN")
            }
            // 将旧桶的tophash标志位更改为迁移标志
            b.tophash[i] = evacuatedX + useY // evacuatedX 或 evacuatedY
            dst := &xy[useY]                 // evacuation destination
            // 如果 xi/yi 等于8,说明要溢出了,需要新建一个bucket
            if dst.i == bucketCnt {
                dst.b = h.newoverflow(t, dst.b)
                dst.i = 0
                dst.k = add(unsafe.Pointer(dst.b), dataOffset)
                dst.e = add(dst.k, bucketCnt*uintptr(t.keysize))
            }
            // 屏蔽dst.i 作为优化,以避免边界检查(在源码很多地方都存在边界值的判断)
            dst.b.tophash[dst.i&(bucketCnt-1)] = top

            // key是指针,将原key(是指针)复制到新位置,否则将原key(值)复制到新位置
            if t.indirectkey() {
                *(*unsafe.Pointer)(dst.k) = k2 // 复制指针
            } else {
                typedmemmove(t.key, dst.k, k) // 复制值
            }
            if t.indirectelem() {
                *(*unsafe.Pointer)(dst.e) = *(*unsafe.Pointer)(e) // 复制指针
            } else {
                typedmemmove(t.elem, dst.e, e)  // 复制值
            }
            dst.i++
            dst.k = add(dst.k, uintptr(t.keysize))
            dst.e = add(dst.e, uintptr(t.elemsize))
            
            // bucket搬迁完毕,如果没有协程在使用老的 buckets,就把老 buckets清除,帮助gc(在遍历的过程中进行插入的业务场景才会出现这种情况吧)
            if h.flags&oldIterator == 0 && t.bucket.ptrdata != 0 {
                b := add(h.oldbuckets, oldbucket*uintptr(t.bucketsize))
                // 保留b.tophash,因为迁移状态还保留着(剩余的key/value/溢出桶指针空间就需要清除)
                ptr := add(b, dataOffset)
                n := uintptr(t.bucketsize) - dataOffset
                // 从 ptr开始清除n个字节的类型化内存
                memclrHasPointers(ptr, n)
            }
        }
       
	// 更新搬迁进度
	if oldbucket == h.nevacuate {
            advanceEvacuationMark(h, t, newbit)
	}
}

3.4 更新搬迁进度

/*
:params h: map
:params t: 存储了key/value和桶大小等信息
:params newbit: 旧桶的hash数组长度
*/
func advanceEvacuationMark(h *hmap, t *maptype, newbit uintptr) {
    h.nevacuate++
    // 实验表明,1024是最高预设,无论如何,把它放在那里作为保证,以确保O(1)行为。(设置阈值)
    stop := h.nevacuate + 1024
    if stop > newbit {
        stop = newbit
    }
    // 判断从当前桶置后面是否需要继续迁移,不需要直接将进度+1
    for h.nevacuate != stop && bucketEvacuated(t, h, h.nevacuate) {
        h.nevacuate++
    }
    // 迁移进度和旧哈希桶长度一致,迁移完成后,将相关地址置为nil
    if h.nevacuate == newbit {
        // 旧桶设置为nil
        h.oldbuckets = nil
        // 也可以丢弃旧的溢出桶。如果它们仍然被迭代器引用,那么迭代器将保存指向切片的指针。
        if h.extra != nil {
            h.extra.oldoverflow = nil
        }
        // 移除扩容状态
        h.flags &^= sameSizeGrow
    }
}

迁移细节

在迁移的代码中,有点令人疑惑的地方,这部分单独抽离出来看,从源码的角度去分析,发现迁移进度是最终一致性(就是中间过程的迁移进度不准确,是通过下一次的迁移进行矫正)。

下图是数据插入时,桶的迁移以及进度值的变化:

image.png

func growWork(t *maptype, h *hmap, bucket uintptr) {
    // 我们要确保排空即将使用的桶对应的旧桶
    evacuate(t, h, bucket&h.oldbucketmask())

    // 再搬迁一个 bucket,以加快搬迁进度
    if h.growing() {
        evacuate(t, h, h.nevacuate)
    }
}
func evacuate(t *maptype, h *hmap, oldbucket uintptr) {
    // 1.计算旧桶地址及数组大小
    b := (*bmap)(add(h.oldbuckets, oldbucket*uintptr(t.bucketsize)))
    // 旧桶的容量
    newbit := h.noldbuckets()
    if !evacuated(b){ ... }
    // 更新搬迁进度
    if oldbucket == h.nevacuate {
        advanceEvacuationMark(h, t, newbit)
    }
}
func advanceEvacuationMark(h *hmap, t *maptype, newbit uintptr) {
    h.nevacuate++
    for h.nevacuate != stop && bucketEvacuated(t, h, h.nevacuate) {
        h.nevacuate++
    }
func bucketEvacuated(t *maptype, h *hmap, bucket uintptr) bool {
    b := (*bmap)(add(h.oldbuckets, bucket*uintptr(t.bucketsize)))
    return evacuated(b)
}
func evacuated(b *bmap) bool {
    h := b.tophash[0]
    return h > emptyOne && h < minTopHash
}

4.缩容

go map没有实现缩容的功能,这样会造成一种情况,在使用map的过程中,其申请的内存空间会越来越大(空间利用率底低),甚至会导致内存溢出。

所以在业务的实际开发中,根据经验,自己实现缩容搬迁,即创建一个比较小的map,将需要缩容的map的元素挨个搬迁过来。

事例如下:

// go map缩容代码示例
myMap := make(map[int]int, 1000000)

// TODO 假设这里我们对bigMap做了很多次插入,之后又做了很多次删除,此时bigMap的元素数量远小于hash表大小
// ...
// 接下来我们开始缩容
smallMap := make(map[int]int, len(myMap))
for k, v := range myMap {
        smallMap[k] = v
}
myMap = smallMap // 缩容完成,原来的map被我们丢弃,交给gc去清理