代码走读 Go 语言 Map 的实现

news2025/5/23 8:21:28

序言

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


一、Map 的数据结构

1. hmap 的组成

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

type hmap struct {
	count     int    // 当前键值对的数量
	flags     uint8  // 标志是否有写入操作,以及扩容类型
	B         uint8  // 表示 buckets 数量为2^B
	noverflow 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    *[]*bmap
	oldoverflow *[]*bmap

	nextOverflow *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] = 1
	mp2[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 置为 0
		hint = 0
	}

	// 如果为 nil, 创建一个新的 hmap
	if 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 *bmap
		h.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 buckets
	if 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))
			}
			// 如果相同,返回对应的 value
			if 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 ^= hashWriting

	if 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 {
		// 遍历 tophash
		for 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)  // 分配一个新的 bucket
		inserti = &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) = kmem
		insertk = kmem
	}
	if t.indirectelem() {
		vmem := newobject(t.elem)
		*(*unsafe.Pointer)(elem) = vmem
	}
	// 将值拷贝过去
	typedmemmove(t.key, insertk, key)
	*inserti = top
	h.count++

done:
	if h.flags&hashWriting == 0 {
		fatal("concurrent map writes")
	}
	h.flags &^= hashWriting
	if t.indirectelem() {
		elem = *((*unsafe.Pointer)(elem))
	}
	return elem

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

if t.indirectkey() {
	kmem := newobject(t.key)
	*(*unsafe.Pointer)(insertk) = kmem
	insertk = 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
			// 寻找前一个 bucket
			for 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 = 0
		h.flags |= sameSizeGrow
	}
	oldbuckets := h.buckets
	// 申请新的空间
	newbuckets, nextOverflow := makeBucketArray(t, h.B+bigger, nil)
	// ...
	// 更新数据
	h.B += bigger
	h.flags = flags
	h.oldbuckets = oldbuckets
	h.buckets = newbuckets
	h.nevacuate = 0
	h.noverflow = 0

	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
	}
	
	// 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          // 当前的 bucket
		i int            // 插入当前 bucket 的具体位置
		k unsafe.Pointer // 指向的 key
		e unsafe.Pointer // 指向的 val
	}
	...
	var xy [2]evacDst
	x := &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] = evacuatedEmpty
			continue
		}
		// 如果小于则不合法,因为小于 minTopHash 的都是标记位
		if top < minTopHash {
			throw("bad map state")
		}
		k2 := k
		if t.indirectkey() {
			k2 = *((*unsafe.Pointer)(k2))
		}
		var useY uint8
		if !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 bucket
			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))
		}
		// 插入 tophash
		dst.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++
	// 一次最多更新 1024
	stop := h.nevacuate + 1024
	if stop > newbit {
		stop = newbit
	}
	// 不断增加并且判断对应 bucket 是否真正过期
	for h.nevacuate != stop && bucketEvacuated(t, h, h.nevacuate) {
		h.nevacuate++
	}
	// 如果相同则代表全部迁移成功
	if h.nevacuate == newbit { 
		h.oldbuckets = nil
		if h.extra != nil {
			h.extra.oldoverflow = nil
		}
		// 去除标记为
		h.flags &^= sameSizeGrow
	}
}

三、总结

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

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2383750.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

PostgreSQL14 +patroni+etcd+haproxy+keepalived 集群部署指南

使用postgresql etcd patroni haproxy keepalived可以实现PG的高可用集群&#xff0c;其中&#xff0c;以postgresql做数据库&#xff0c;Patroni监控本地的PostgreSQL状态&#xff0c;并将本地PostgreSQL信息/状态写入etcd来存储集群状态&#xff0c;所以&#xff0c;patr…

数据结构知识点汇总

1、在数据结构中&#xff0c;随机访问是指能够直接访问任一元素&#xff0c;而不需要从特定的起始位置开始&#xff0c;也不需要按顺序访问其他元素。这种访问方式通常不涉及遍历。例如&#xff0c;数组&#xff08;array&#xff09;支持随机访问&#xff0c;你可以直接通过索…

基于YOLO11深度学习的变压器漏油检测系统【Python源码+Pyqt5界面+数据集+安装使用教程+训练代码】【附下载链接】

文章目录 引言软件主界面源码目录文件说明一、环境安装(1)安装python(2)安装软件所需的依赖库 二、软件核心功能介绍及效果演示(1)软件核心功能(2)软件效果演示 三、模型的训练、评估与推理(1)数据集准备与训练(2)训练结果评估(3)使用训练好的模型识别 四、完整相关文件及源码下…

Python 包管理工具核心指令uvx解析

uvx 是 Python 包管理工具 uv 的重要组成部分&#xff0c;主要用于在隔离环境中快速运行 Python 命令行工具或脚本&#xff0c;无需永久安装工具包。以下是其核心功能和使用场景的详细解析&#xff1a; 一、uvx 的定位与核心功能 工具执行器的角色 uvx 是 uv tool run 的别名&a…

苍穹外卖05 Redis常用命令在Java中操作Redis_Spring Data Redis使用方式店铺营业状态设置

2-8 Redis常用命令 02 02-Redis入门 ctrlc :快捷结束进程 配置密码&#xff1a; 以后再启动客户端的时候就需要进行密码的配置了。使用-a 在图形化界面中创建链接&#xff1a; 启动成功了。 03 03-Redis常用数据类型 04 04-Redis常用命令_字符串操作命令 05 05-Redis常用命令…

AI工程师系列——面向copilot编程

前言 ​ 笔者已经使用copilot协助开发有一段时间了,但一直没有总结一个协助代码开发的案例,特别是怎么问copilot,按照什么顺序问,哪些方面可以高效的生成需要的代码,这一次,笔者以IP解析需求为例,沉淀一个实践案例,供大家参考 当然,其实也不局限于copilot本身,类似…

【竖排繁体识别】如何将竖排繁体图片文字识别转横排繁体,转横排简体导出文本文档,基于WPF和腾讯OCR的实现方案

一、应用场景 在古籍数字化、繁体文档处理、两岸三地文化交流等场景中,经常需要将竖排繁体文字转换为横排文字。例如: 古籍研究人员需要将竖排繁体文献转换为现代横排简体格式以便编辑和研究出版行业需要将繁体竖排排版转换为简体横排格式两岸三地交流中需要将繁体竖排文档转…

NFS服务器实验

实验要求 架设一台NFS服务器&#xff0c;并按照以下要求配置 1、开放/nfs/shared目录&#xff0c;供所有用户查询资料 2、开放/nfs/upload目录&#xff0c;为192.168.xxx.0/24网段主机可以上传目录&#xff0c;并将所有用户及所属的组映射为nfs-upload,其UID和GID均为210 3…

Java进阶之新特性

Java新特性 参考 官网&#xff1a;https://docs.oracle.com/en/ JDK5新特性 1.自动装箱与拆箱 自动装箱的过程&#xff1a;每当需要一种类型的对象时&#xff0c;这种基本类型就自动地封装到与它相同类型的包装类中。 自动拆箱的过程&#xff1a;每当需要一个值时&#xf…

AIGC工具平台-卡通图片2D转绘3D

本模块是一款智能化的2D转3D图像处理工具&#xff0c;能够将卡通风格的2D图片自动转换为高质量3D渲染模型&#xff0c;让平面图像焕发立体生机。借助先进的AI深度学习算法&#xff0c;该工具可以精准识别角色轮廓、光影关系、材质纹理等关键元素&#xff0c;自动生成逼真的3D形…

Java虚拟机 -方法调用

方法调用 方法调用静态链接动态链接案例虚方法与非虚方法虚方法&#xff08;Virtual Method&#xff09;非虚方法&#xff08;Non-Virtual Method&#xff09; 方法返回地址 方法调用 我们编写Java程序的时候&#xff0c;我们自己写的类通常不仅仅是调用自己本类的方法。调用别…

JMeter JDBC请求Query Type实测(金仓数据库版)

文章目的 在实际性能测试中&#xff0c;JMeter的JDBC Request组件常用于模拟数据库操作。但许多用户对Query Type参数的具体行为存在疑惑。 本文将以金仓数据库KingbaseES为例&#xff0c;通过实测验证每种Query Type的行为&#xff0c;帮助用户明确其使用场景和限制&#xff…

【内部教程】ISOLAR-AB配置以太网栈|超详细实战版

目录 往期推荐 缩写与定义 关于系统描述&#xff08;System Description&#xff09; 1.1 EthCommunicationController 1.2 EthCommunicationConnector 1.2.1 Ports&#xff08;端口&#xff09; 1.3 EthPhysicalChannel&#xff08;以太网物理通道&#xff09; 1.3.1…

Nginx 核心功能

目录 一&#xff1a;正向代理 1&#xff1a;编译安装 Nginx &#xff08;1&#xff09;安装支持软件 &#xff08;2&#xff09;创建运行用户、组和日志目录 &#xff08;3&#xff09;编译安装 Nginx &#xff08;4&#xff09;添加 Nginx 系统服务 2&#xff1a;配置正…

【Canvas与图标】圆角方块蓝星CSS图标

【成图】 120*120的png图标 大小图&#xff1a; 【代码】 <!DOCTYPE html> <html lang"utf-8"> <meta http-equiv"Content-Type" content"text/html; charsetutf-8"/> <head><title>圆角方块蓝星CSS Draft1</…

机器学习 day05

文章目录 前言一、模型选择与调优1.交叉验证2.超参数搜索 前言 通过今天的学习&#xff0c;我掌握了机器学习中模型的选择与调优&#xff0c;包括交叉验证&#xff0c;超参数搜索的概念与基本用法。 一、模型选择与调优 模型的选择与调优有许多方法&#xff0c;这里主要介绍较…

C#新建打开文件对话框

这是Winform直接封装好的打开文件对话框 using System.Windows.Forms; public static string OpenFile(string path) {OpenFileDialog openFileDialog new OpenFileDialog();// 设置对话框属性openFileDialog.Title "选择文件";openFileDialog.InitialDirectory …

汇川PLC通过开疆智能Profinet转ModbusTCP网关读取西门子PLC数据案例

本案例是客户通过开疆智能Profient转ModbusTCP网关连接汇川PLC的配置案例 Modbus TCP主站即Modbus TCP客户端&#xff0c;Modbus TCP主站最多支持同时与31个Modbus TCP从站 。&#xff08;Modbus TCP服务器&#xff09;进行通信。 第一步设置PLC IP地址&#xff1b; 默认PLC…

零基础入门:MinerU 和 PyTorch、CUDA的关系

&#x1f4a1;一句话总结&#xff1a;MinerU 是一个用 PyTorch 跑模型的程序&#xff0c;PyTorch 支持多种加速方式&#xff08;如 CUDA、MPS&#xff09;&#xff0c;让它跑得快就需要依赖这些加速工具。 PyTorch官网安装教程&#xff08;可根据系统情况选择不同版本&#xf…

借助IEDA ,Git版本管理工具快速入门

01 引言 一直使用SVN作为版本管理工具&#xff0c;直到公司新来的一批同事&#xff0c;看到我们使用的SVN都纷纷吐槽&#xff0c;什么年代了&#xff0c;还使用SVN。聊下来&#xff0c;才知道人家公司早早就将SVN切成了Git工具&#xff0c;并吐槽SVN的各种弊端。 既然新的技术…