当前位置: 首页 > news >正文

代码走读 Go 语言 Map 的实现

序言

 在日常的开发当中,我们一定离不开一个数据结构字典。不仅可以存储关联数据对,还可以在 O(1) 的时间复杂度进行查找。很久之前在 一篇文章带你实现 哈希表 介绍了相关的原理以及简单的实现。所以这篇文章中我们就不多赘述哈希表的原理,而是聚焦于 Go 语言 是如何实现 Map 的。


一、Map 的数据结构

1. hmap 的组成

 这是 map 最主要的组成部分:

type hmap struct {count     int    // 当前键值对的数量flags     uint8  // 标志是否有写入操作,以及扩容类型B         uint8  // 表示 buckets 数量为2^Bnoverflow uint16 // 当前溢出桶的数量hash0     uint32 // 哈希种子「增加随机性」buckets    unsafe.Pointer // 桶数组指针oldbuckets unsafe.Pointer // 在扩容期间指向旧 buckets 数组nevacuate  uintptr        // 扩容进度计数器,表示小于此值的bucket已完成迁移extra *mapextra // 可选字段,用于存储扩展信息
}

看他的组成部分就知道来者不善。是的,Go 语言 对于 Map 的设计大家在学习之后肯定有所收获。直观感受下:
在这里插入图片描述
介绍一下重要的组成部分:

1.1 B 哈希表长度的对数

 如果要定位一个 key 在哈希表的位置,通常都是计算该 key 的哈希值然后对长度取余。但是在 Go 中因为哈希表的大小一定是 2^B,所以可以直接通过如下操作计算:

bucketIndex := hash & (nbuckets - 1) // nbuckets 代表哈希表长度

因为是位运算,所以速度比 % 计算要快很多,进一步提升性能。

1.2 noverflow 溢出桶的数量

 大家仔细观察该结构的组成,不能看出 一个哈希桶存储了 8 个键值对。当插入元素并且当前的桶满了的话,会创建一个新的桶然后使用 overflow 指向新的桶(类似桶链表)。
 当溢出桶的数量等于 buckets 的长度时会发生 等量扩容 提高空间的利用率,这个在扩容章节会谈到。

1.3 oldbuckets 旧的哈希表

 随着不断的插入元素,哈希表的负载因子也会不断的增涨。当达到一定的阈值(Go 所设的阈值是 6.5),便会发生增量扩容。但是如果表里的元素特别的多时,如果一次性将所有的数据全部迁移过去肯定开销特别大,所以 Go 会采用 渐进式的迁移
 因此如果 oldbuckets 不为空的的话,就代表着发生了扩容并且数据还没有全部的迁移过去。

2. bmap 的组成

 这就是实际的哈希桶的实现:

// bucketCnt = 8
type bmap struct {tophash [bucketCnt]uint8
}

刚开始懵是正常的。我们知道的他的数据结构是这样的:
在这里插入图片描述
结合实际的数据结构和组成图我刚开始有一下这几点疑问:

  • 貌似这个结构体中只包含 hash,而不包含 keyvaloverflow
  • 为什么需要存储 tophash
  • 为什么键值分开存储?

有问题就会有答案,这里就可以看出 Go 实现哈希表的方式和 C++ 有很大区别。

2.1 隐式存储数据

 虽然没有显示的表现相关的数据,但是肯定是存在的。这是因为 Go 编译器会根据 map 的类型信息(如 key 类型、value 类型)在运行时动态构造 bucket 的内存布局,而不会在源码中显式写出所有字段(tophash 的大小是确定的 8 bits)。
 看一下源码是如何访问的相关数据,首先是访问 key

// b 是 *bmap; dataOffset 是前面 8 个 tophash 的大小
k := add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize))

使用的地址偏移量访问相关的数据,下面两个其实也是相同的方式。再来看看如何访问 val 的吧:

e := add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.elemsize))

最后是如何访问 overflow 的:

// 获取当前 bucket 的 overflow
// 偏移量 = bucket 大小 - 指针大小
func (b *bmap) overflow(t *maptype) *bmap {return *(**bmap)(add(unsafe.Pointer(b), uintptr(t.bucketsize)-goarch.PtrSize))
}

所以说,了解了也感觉没有那么难嘛。

2.2 tophash 的作用

 首先第一个重要的作用是加快查找键的速度,众所周知因为哈希冲突的原因所以存在不同的键被映射到同一个位置。Go 在查找 key 时,并不会直接比较 key 的内容,而是先使用 tophash 做一次快速筛选(因为这是位运算会快很多),进一步提升了性能。
 其次的话 Go 使用一些特殊的 tophash 值来标记 slot 的状态,这些值定义在运行时:

emptyRest      = 0  // 当前 slot 为空,且后续所有 slot 都为空
emptyOne       = 1  // 当前 slot 为空
evacuatedX     = 2  // bucket 已迁移到新表的 X 部分
evacuatedY     = 3  // bucket 已迁移到新表的 Y 部分
evacuatedEmpty = 4  // bucket 已迁出,且为空
minTopHash     = 5  // 正常哈希值必须 >= minTopHash

这些值的含义我们在后面具体的场景中会详细介绍。

2.3 分开存储的好处

 分开存储的好处主要体现在两点,首先是缓存友好,只要一次加载就可以处理多个数据;其次是减少空间碎片,不同的数据结构有不同的对齐规则,将相同的类型存储在一起可以减少对齐带来的空间碎片。

3. mapextra 的组成

 首先我们来看看他的组成部分:

type mapextra struct {overflow    *[]*bmapoldoverflow *[]*bmapnextOverflow *bmap
}

第一个参数用于存储新 buckets 中涉及到的 overflow;第二个参数用于存储旧 buckets 中涉及到的 overflow;最后一个参数是指向下一个可用的预分配 overflow bucket,当需要时直接分配减少新开辟带来的开销。但是前两个参数不是一定会使用的,需要取决于 kv 的是否是指针类型。
Go 是不用我们显示的管理内存的,他有自己的一套 GC 机制。GC 虽然简化了我们对内存的管理,但是也会带来额外的开销 — 每隔一段时间就会扫描一次,将不再利用的空间给回收了。为了减轻 GC 的消耗,这里规定如果 keyval 都是非指针类型(存在于栈上)那么 GC 会标记整个 bucket 类型为 no pointers,从而跳过对它们的扫描。但是别忘了虽然 bucket 中的 key/value 不需要 GC 扫描,但 overflow 指针是有效的指针,为了防止被回收 ,Go 必须记录这些 overflow bucket


二、Map 的使用

1. 初始化相关操作

1.1 makemap

 我们在使用的时候,声明初始化 map 无非分为两种方式:

func main() {var mp1 map[int]int         // 声明但未初始化mp2 := make(map[int]int, 5) // 初始化// 赋值mp1[1] = 1mp2[1] = 1// 打印fmt.Println(mp1)fmt.Println(mp2)
}

这段程序会发生 panic ,是因为 mp1 只是声明了,内部并没有分配对应的空间,直接使用的话就会报错,所以大家一定要谨慎第一种情况。
 那现在我们看看在底层是怎么实现的:

// t: 存储类型信息
// hint: 传入的预分配的大小
// h: 当前的 map 指针
func makemap(t *maptype, hint int, h *hmap) *hmap {// 计算预分配的大小mem, overflow := math.MulUintptr(uintptr(hint), t.bucket.size)if overflow || mem > maxAlloc { // 如果预分配的空间太大,将 hint 置为 0hint = 0}// 如果为 nil, 创建一个新的 hmapif h == nil {h = new(hmap)}// 初始一个 hash 种子h.hash0 = fastrand()// 计算 B 的初始值B := uint8(0)for overLoadFactor(hint, B) {B++}h.B = B// 预分配空间操作if h.B != 0 {var nextOverflow *bmaph.buckets, nextOverflow = makeBucketArray(t, h.B, nil)if nextOverflow != nil {h.extra = new(mapextra)h.extra.nextOverflow = nextOverflow}}return h
}

整体来说就是对各项值进行初始化以及预分配空间,我们现在额外再关注下另外两个函数。

1.2 overLoadFactor

 首先来看看怎么确定 B 的初始值的:

// bucketCnt = 8
func overLoadFactor(count int, B uint8) bool {return count > bucketCnt && uintptr(count) > loadFactorNum*(bucketShift(B)/loadFactorDen)
}

当预分配的值大于 8 时才会真正进行预分配的操作,其次预分配的大小需要满足:

2^B * 6.5 > 预分配大小

这里的 6.5 是前面提到过的负载因子,如果不满足的话会持续增加 B 的值。

1.3 makeBucketArray

 这个函数用于与分配空间,具体细节如下:

func makeBucketArray(t *maptype, b uint8, dirtyalloc unsafe.Pointer) (buckets unsafe.Pointer, nextOverflow *bmap) {base := bucketShift(b) // 计算 buckets 的数量nbuckets := base// 如果 b >= 4,预分配一些 overflow bucketsif b >= 4 {nbuckets += bucketShift(b - 4)sz := t.bucket.size * nbuckets...}// 申请对应的空间if dirtyalloc == nil {buckets = newarray(t.bucket, int(nbuckets))} else {...}// 如果预分配了空间if base != nbuckets {// 指向预分配开始节点nextOverflow = (*bmap)(add(buckets, base*uintptr(t.bucketsize)))// 指向预分配结束节点last := (*bmap)(add(buckets, (nbuckets-1)*uintptr(t.bucketsize)))// 指向 buckets(作为哨兵值),表示链表结束last.setoverflow(t, (*bmap)(buckets))}return buckets, nextOverflow
}

我们假设现在有一个长度为 4 的 buckets,并且预分配 2 个空间大小:
在这里插入图片描述
预分配的最后一个节点的指针指向 buckets 来代表结束。

2. 读一个键

GoMap 读取一个键需要关注的是,这个键现在在新的 buckets 中还是老的中呢?怎么来判断呢,请看代码:

func mapaccess1(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {...// 返回一个全局的零值if h == nil || h.count == 0 {if t.hashMightPanic() {t.hasher(key, 0) // see issue 23734}return unsafe.Pointer(&zeroVal[0])}// 并发读写情况if h.flags&hashWriting != 0 {fatal("concurrent map read and map write")}// 计算 hash,并找到属于哪一个位置hash := t.hasher(key, uintptr(h.hash0))m := bucketMask(h.B)b := (*bmap)(add(h.buckets, (hash&m)*uintptr(t.bucketsize)))// 判断属于扩容前还是扩容之后if c := h.oldbuckets; c != nil {if !h.sameSizeGrow() {m >>= 1}oldb := (*bmap)(add(c, (hash&m)*uintptr(t.bucketsize)))if !evacuated(oldb) {b = oldb}}top := tophash(hash)
bucketloop:for ; b != nil; b = b.overflow(t) {for i := uintptr(0); i < bucketCnt; i++ {if b.tophash[i] != top {if b.tophash[i] == emptyRest {break bucketloop}continue}// 获取 key 判断是否相同k := add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize))if t.indirectkey() {k = *((*unsafe.Pointer)(k))}// 如果相同,返回对应的 valueif t.key.equal(key, k) {e := add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.elemsize))if t.indirectelem() {e = *((*unsafe.Pointer)(e))}return e}}}return unsafe.Pointer(&zeroVal[0])
}

这个函数有一点点长,但是肯定不复杂。前面的逻辑无非是计算该键值对的 hash 然后再计算映射到哪一个位置,但是这里没有直接进行 % 操作,而是:

m := bucketMask(h.B)
b := (*bmap)(add(h.buckets, (hash&m)*uintptr(t.bucketsize)))

前面提到过,buckets 的长度一定是 2 的整数次方所以可以直接使用位运算(hash & (2^B - 1)) 来计算。
 我们还需要关注一个逻辑判断该键在哪个 buckets,首先判断 oldbuckets 是否为空并且是否是增量扩容,如果都满足的话获取该键对应在 oldbuckets 的位置(可以发现了使用了很多位运算加快速度)。我们还需要引入一个关键函数:

// 判断是否迁移
func evacuated(b *bmap) bool {h := b.tophash[0]return h > emptyOne && h < minTopHash // 2 3 4代表已经迁移
}

这个值怎么来的会在后面的扩容章节介绍,大家现在知道有这么个用法就行。
 现在也知道了在哪一个 buckets,也知道了具体的 tophash,再来看如何寻找的:
在这里插入图片描述

  1. 比较 tophash 如果相同才比较 key
  2. 比较 key 如果不相同继续循环,反之获取 val 返回
  3. 当前 bucket 没有目标 key,前往下一个 bucket

3. 写入一个键

 这里的代码实在有些长,我们就不一口气全放出来了,尝试分为多个部分来一一攻克:

3.1 定位和迁移
	// 判断是否存在写操作if h.flags&hashWriting != 0 {fatal("concurrent map writes")}hash := t.hasher(key, uintptr(h.hash0))// 设置写的标识位h.flags ^= hashWritingif h.buckets == nil {h.buckets = newobject(t.bucket) // newarray(t.bucket, 1)}again:// 定位bucket := hash & bucketMask(h.B)// 迁移if h.growing() {growWork(t, h, bucket)}b := (*bmap)(add(h.buckets, bucket*uintptr(t.bucketsize)))top := tophash(hash)

首先获取 key 所对应的位置,并且判断当前是否迁移完成了,没有的话就会产生迁移操作。前面我们提到了 Go 采用的是渐进式的迁移来避免性能抖动,所以在 增删改 操作中都会出现将该 key 所对应的哈希桶给全部迁移到新的 bucksets 中。

3.2 寻找键的位置
bucketloop:for {// 遍历 tophashfor i := uintptr(0); i < bucketCnt; i++ {if b.tophash[i] != top {// 寻找第一个空槽「后续没找到 hash 就插入在这里」if isEmpty(b.tophash[i]) && inserti == nil {inserti = &b.tophash[i]insertk = add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize))elem = add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.elemsize))}// 后面都是空的if b.tophash[i] == emptyRest {break bucketloop}continue}// tophash 相同k := add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize))if t.indirectkey() {k = *((*unsafe.Pointer)(k))}// 在判断 key 是否相同if !t.key.equal(key, k) {continue}// 记录更新if t.needkeyupdate() {typedmemmove(t.key, k, key)}elem = add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.elemsize))goto done}// 当前 bucket 没有,下一个ovf := b.overflow(t)// 到最后都没有,直接退出if ovf == nil {break}b = ovf}

这里和读取一个键差不多的寻找方式,但是多了一些细节:在遍历的同时寻找一个空的位置,以便遍历结束之后能够将该值新插入。但是如果找到了最后没有找到,并且也没有空的位置了呢?

3.3 新分配 bucket
	// 没找到插入位置if inserti == nil {newb := h.newoverflow(t, b)  // 分配一个新的 bucketinserti = &newb.tophash[0]   // 插入第一个位置insertk = add(unsafe.Pointer(newb), dataOffset)   elem = add(insertk, bucketCnt*uintptr(t.keysize))}// 如果是指针类型,给插入位置创建一个 key 类型if t.indirectkey() {kmem := newobject(t.key)*(*unsafe.Pointer)(insertk) = kmeminsertk = kmem}if t.indirectelem() {vmem := newobject(t.elem)*(*unsafe.Pointer)(elem) = vmem}// 将值拷贝过去typedmemmove(t.key, insertk, key)*inserti = toph.count++done:if h.flags&hashWriting == 0 {fatal("concurrent map writes")}h.flags &^= hashWritingif t.indirectelem() {elem = *((*unsafe.Pointer)(elem))}return elem

首先分配一个 bucket 和然后将分别插入到具体的位置,大家可能对这个部分有疑惑:

if t.indirectkey() {kmem := newobject(t.key)*(*unsafe.Pointer)(insertk) = kmeminsertk = kmem
}

这里的含义是:

  • bucket 中预留一个指针位置;
  • 然后为其分配堆内存,把这个堆地址写入 bucket
  • 最后让 insertk 指向堆内存,以便后续写入 key 的具体内容。

4 删除一个键

 这个过程大家不仅可以了解到如何删除一个键,还知道两个字段 emptyRest 以及 emptyOne 怎么来的了。代码过长,但是很多过程都和 写入一个键 类似。你删除一个键,你也要 定位和迁移 以及 寻找键的位置 吧。所以我们就不过多赘述了,我们直接看核心的代码:

	// 清除数据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)}// 标记为置空b.tophash[i] = emptyOne// 如果是当前 bucket 最后一个if i == bucketCnt-1 {// 如果 overflow 不为空且下一个 bucket 不都是空的if b.overflow(t) != nil && b.overflow(t).tophash[0] != emptyRest {	goto notLast // 结束}} else {// 之后还存在值if b.tophash[i+1] != emptyRest {goto notLast}}for {// 当前位置往后都为空b.tophash[i] = emptyRest// 如果是第一个则继续找到前一个 bucket 判断if i == 0 {// 如果已经是第一个了,遍历完成结束if b == bOrig {break }c := b// 寻找前一个 bucketfor b = bOrig; b.overflow(t) != c; b = b.overflow(t) {}i = bucketCnt - 1} else {i--}// 判断前一个是否为空if b.tophash[i] != emptyOne {break}}
notLast:h.count--if h.count == 0 {h.hash0 = fastrand()}break search
}

可以看到这里不仅只是涉及到把对应的键删除了,还涉及到对 emptyRest 以及emptyOne 字段的更改,这两个字段的含义如下:

  • emptyOne:当前字段为空
  • emptyRest:当前位置之后的都为空

这里煞费苦心的更新这两个字段,都是为了加快键的寻找。在比较 tophash 的同时就可以判断后续是否还有数据,没有的话就直接终止了遍历。
 现在我们来看看具体的更新细节:

  1. 如果当前是 bucket 最后一个元素并且下一个 bucket 不为空且还有元素,更新结束:
    在这里插入图片描述
  2. 如果当前位置的下一个元素不为空,更新结束:
    在这里插入图片描述
    除去当前两种情况,那么当前的位置肯定就是 emptyRest 。但是还不至于此,我们需要向前判断,如果前一个元素是 emptyOne 那么也需要更新为 emptyRest,直至遇到非空的或者是到达当前 bucket 的起点位置。
  3. 向前更新直至遇到非空元素,更新结束:
    在这里插入图片描述
    假如现在更新到了起点位置并且也为空,那么我们还需要前往前一个 bucket 继续更新,直至遇到非空元素或者全部更新完成:

4.向前更新直至所有元素更新完成,结束:
在这里插入图片描述
这基本就是更新的所有情况,所以说删除不只是删除,还需要更新 tophash 来加速后续的 key 搜索。

5. 扩容操作

Go 语言 Map 的扩容操作分为了 等量扩容 和 增量扩容,并且两者都采用了渐进式的迁移方式。

5.1 扩容时机

 当插入元素后就会进行如下的判断来选择是否扩容:

	if !h.growing() && (overLoadFactor(h.count+1, h.B) || tooManyOverflowBuckets(h.noverflow, h.B)) {hashGrow(t, h)...}

如果当前没有在扩容,先判断负载因子是否大于了 6.5,如果大于则触发增量扩容:

func overLoadFactor(count int, B uint8) bool {return count > bucketCnt && uintptr(count) > loadFactorNum*(bucketShift(B)/loadFactorDen)
}

如果没有触发增量扩容,再判断当前 溢出桶 的数量是否大于了 buckets 的长度:

func tooManyOverflowBuckets(noverflow uint16, B uint8) bool {// 避免在 B >= 16 时,1 << B 超出 uint16 的表示范围,导致判断失效if B > 15 {B = 15}return noverflow >= uint16(1)<<(B&15)
}

也许你会感到疑惑 为什么需要等量扩容呢? 主要是为了优化 bucket 分布结构,使数据更加的紧凑节省空间。举个例子:buckets 数量为 2^3 = 8,当前已有 20 个 key,已经分配了 8 个 overflow bucket,负载因子:count / buckets = 20 / 8 = 2.5,远低于 6.5;但每个 bucket 平均都有一个 overflow bucket;所以 bucket 链过长,影响查找效率,所以需要等量扩容去除额外的 overflow bucket。

5.2 扩容操作

 这里的扩容操作 只是申请了对应的空间并没有实际的数据迁移操作

func hashGrow(t *maptype, h *hmap) {bigger := uint8(1)// 不是增量扩容就不增加 B 的大小if !overLoadFactor(h.count+1, h.B) {bigger = 0h.flags |= sameSizeGrow}oldbuckets := h.buckets// 申请新的空间newbuckets, nextOverflow := makeBucketArray(t, h.B+bigger, nil)// ...// 更新数据h.B += biggerh.flags = flagsh.oldbuckets = oldbucketsh.buckets = newbucketsh.nevacuate = 0h.noverflow = 0if h.extra != nil && h.extra.overflow != nil {if h.extra.oldoverflow != nil {throw("oldoverflow is not nil")}h.extra.oldoverflow = h.extra.overflowh.extra.overflow = nil}if nextOverflow != nil {if h.extra == nil {h.extra = new(mapextra)}h.extra.nextOverflow = nextOverflow}// the actual copying of the hash table data is done incrementally// by growWork() and evacuate().
}

代码中也只包资源申请的操作,真正的迁移操作是没有的。

5.3 迁移操作

 在增删改操作的时候都会触发数据的迁移操作,也就是 growWork

func growWork(t *maptype, h *hmap, bucket uintptr) {// 实际的迁移操作evacuate(t, h, bucket&h.oldbucketmask())// 再迁移一次,推进目前的迁移进度if h.growing() {evacuate(t, h, h.nevacuate)}
}

使用一个图片来介绍一个场景:
在这里插入图片描述

当涉及到增删改的操作时就会迁移当前 key 所对应的 bucket,在迁移完之后,会主动迁移当前 nevacuate(定义:当前位置之前的已完成迁移)所指向的 bucket 推进整体的迁移进度。
 知道了扩容的策略,现在我们来看看实际的扩容操作是怎么样的。代码篇幅过长,所以分为多个部分来一一介绍。

1. 设置 X/Y 迁移的地址

 首先先介绍什么是 X/Y 迁移 呢?Go 确定一个 hash 映射到 buckets 的哪一个位置,没有直接使用 % 运算。而是使用 hash & (2^B - 1) 这样的位运算来加快运算速度。确认一个 key 在迁移前后在哪一个位置,最快速的方式就是判断 hash & (2^B_old) 是 0 还是 1:

  • 0: 则在原来的位置
  • 1: 则在原来的位置 + (2^B_old)

举个例子:
在这里插入图片描述
5 在扩容前在下标为 1 的位置,扩容后在下标为 5 的位置。计算的方法上面也介绍过,可以看出 & 的值前后的区别就在于 7 比 3 大了一个原来的容量,所以决定扩容后在哪一个位置的关键在于 hash & (2^B_old) 是 0 还是 1。0 则是 X 迁移(位置不变),1 则是 Y迁移(比起原来的位置多了原来的容量大小)。

Go 预设置了迁移的目标:

	type evacDst struct {b *bmap          // 当前的 bucketi int            // 插入当前 bucket 的具体位置k unsafe.Pointer // 指向的 keye unsafe.Pointer // 指向的 val}...var xy [2]evacDstx := &xy[0]// x 迁移;oldbucket 代表原来 bucket 的位置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 := &xy[1]// y 迁移;oldbucket + newbit = 原来 bucket 的位置 + 原来 bucket 的大小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))}

等量迁移的话只能发生 X迁移,也就是只能迁移到原来的位置,因为容量大小无变化。

2. 遍历并迁移

 接下来就是遍历整个 bucket 的数据然后迁移了,详细看代码:

// 遍历 bucket 链表
for ; b != nil; b = b.overflow(t) {// 获取当前 bucket 的 k/v 起始地址k := add(unsafe.Pointer(b), dataOffset)e := add(k, bucketCnt*uintptr(t.keysize))// 遍历 bucket 内的数据for i := 0; i < bucketCnt; i, k, e = i+1, add(k, uintptr(t.keysize)), add(e, uintptr(t.elemsize)) {top := b.tophash[i]// 判断是否为空if isEmpty(top) {b.tophash[i] = evacuatedEmptycontinue}// 如果小于则不合法,因为小于 minTopHash 的都是标记位if top < minTopHash {throw("bad map state")}k2 := kif t.indirectkey() {k2 = *((*unsafe.Pointer)(k2))}var useY uint8if !h.sameSizeGrow() {...} else {// 判断 X/Y 迁移if hash&newbit != 0 {useY = 1}}}...// 把老的位置标记为迁移类型,便于后续 key 查找b.tophash[i] = evacuatedX + useY // evacuatedX + 1 == evacuatedY// 获取迁移地址dst := &xy[useY]                 // evacuation destination// 如果相同则代表当前新的 bucket 满了if dst.i == bucketCnt {// 设置下一个 oveflow bucketdst.b = h.newoverflow(t, dst.b)// 从第一个位置开始插入dst.i = 0dst.k = add(unsafe.Pointer(dst.b), dataOffset)dst.e = add(dst.k, bucketCnt*uintptr(t.keysize))}// 插入 tophashdst.b.tophash[dst.i&(bucketCnt-1)] = top// 拷贝值 if t.indirectkey() {*(*unsafe.Pointer)(dst.k) = k2 // copy pointer} else {typedmemmove(t.key, dst.k, k) // copy elem}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))}
}...
}
3. 更新 nevacuate

 如果本次迁移的 bucketnevacuate(该下标之前的 bucket 都已完成迁移)相同,则需要更新 nevacuate

	if oldbucket == h.nevacuate {advanceEvacuationMark(h, t, newbit)}

更新的细节如下:

func advanceEvacuationMark(h *hmap, t *maptype, newbit uintptr) {h.nevacuate++// 一次最多更新 1024stop := h.nevacuate + 1024if stop > newbit {stop = newbit}// 不断增加并且判断对应 bucket 是否真正过期for h.nevacuate != stop && bucketEvacuated(t, h, h.nevacuate) {h.nevacuate++}// 如果相同则代表全部迁移成功if h.nevacuate == newbit { h.oldbuckets = nilif h.extra != nil {h.extra.oldoverflow = nil}// 去除标记为h.flags &^= sameSizeGrow}
}

三、总结

 这就是 Go 语言 Map 的设计,可以说花了很多技巧比如位引入大量运算,渐进式扩容,topHash等因素来提升性能,使得在维护大量数据的情况下依然抗打!

相关文章:

  • PyInstaller入门
  • 阿里云服务器 篇十三(加更):Web书签(链接共享和迷你导航):改为使用宿主机DB等优化
  • 第八天 搭建车辆状态监控平台(Docker+Kubernetes) OTA升级服务开发(差分升级、回滚机制)
  • 训练一个线性模型
  • halcon 三维点直线拟合
  • 角度回归——八参数检测四边形RSDet
  • 单例模式的运用
  • Spring Boot与Kafka集成实践:实现高效消息队列
  • 角度回归——八参数检测四边形Gliding Vertex
  • 微服务中的 AKF 拆分原则:构建可扩展系统的核心方法论
  • 鸿蒙Flutter实战:25-混合开发详解-5-跳转Flutter页面
  • HarmonyOS学习——UIAbility组件(上)
  • 交换机工作原理解析与网络安全实践
  • 【计算机网络】TCP如何保障传输可靠性_笔记
  • C++:关联容器set容器,multiset容器
  • ss、lsof 命令
  • git:The following paths are ignored by one of your
  • 单片机如何快速实现查看实时数据
  • OpenCV图像平移示例
  • RocketMQ 事务消息详解及生产使用场景
  • 郑州建设网站制作公司/长春网站建设公司
  • 什么网站可以做旅行行程单/域名服务器ip地址查询
  • 网站 内容建设需要进一步加强/阿里云建站费用
  • 怎么做国外游戏下载网站/商丘seo优化
  • 搜索网站制作教程/百度seo服务方案
  • 音乐网站前台模板/百度网页版网址