深度解析GoLand map扩容机制,手撕源码!

本篇涉及到一些前面文章的知识,推荐大家先阅读前几篇文章(直接往下看也行,涉及的不多):
深度解析GoLand map原理及实现,手撕源码!(一)——基本介绍,初始化,读
深度解析GoLand map原理及实现,手撕源码!(二)——写入、删除、遍历

一、扩容类型

1. 增量扩容

扩容后,桶数组的长度增长为原长度的 2 倍
目的: 降低每个桶中 key-value 对的数量,优化 map 操作的时间复杂度

2. 等量扩容

扩容后,桶数组的长度和之前保持一致;但是溢出桶的数量会下降
目的: 提高桶主体结构的数据填充率,减少溢出桶数量,避免发生内存泄漏

二、扩容时机

只有写入操作会触发扩容模式

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

判断是否有旧桶存在—— 是否正在开启过扩容模式

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

判断键值对的数量和溢出桶的长度,确定开启何种扩容

const(
   loadFactorNum = 13
   loadFactorDen = 2
   bucketCnt = 8
)
// overLoadFactor 报告在 1<<B 个桶中放置 count 个项目是否超过了负载因子。
func overLoadFactor(count int, B uint8) bool {
	return count > bucketCnt && uintptr(count) > loadFactorNum*(bucketShift(B)/loadFactorDen)
}

// tooManyOverflowBuckets 报告对于一个有 1<<B 个桶的映射表来说,noverflow 个溢出桶是否过多。
// 注意,这些溢出桶大多数应该是稀疏使用的;
// 如果使用是密集的,那么我们早就应该触发了常规的映射表增长。
func tooManyOverflowBuckets(noverflow uint16, B uint8) bool {
	// 如果阈值太低,我们会做额外的工作。
	// 如果阈值太高,那么增长和收缩的映射表可能会保留很多未使用的内存。
	// “tooMany”意味着(大约)和常规桶一样多的溢出桶。
	// 有关更多细节,请参见 incrnoverflow。
	if B > 15 {
		B = 15
	}
	// 编译器在这里看不到 B < 16;掩码 B 以生成更短的移位代码。
	return noverflow >= uint16(1)<<(B&15)
}

三、开启扩容 —— hashGrow

源码翻译如下:

// hashGrow 是一个用于扩展哈希表的函数。
// 如果我们达到了负载因子,就增大哈希表。
// 否则,如果溢出桶太多,就保持相同数量的桶,并且“横向增长”。
func hashGrow(t *maptype, h *hmap) {
	// 如果我们达到了负载因子,就增大哈希表。
	// 否则,如果溢出桶太多,就保持相同数量的桶,并且“横向增长”。
	bigger := uint8(1)
	if !overLoadFactor(h.count+1, h.B) {
		bigger = 0
		h.flags |= sameSizeGrow
	}
	oldbuckets := h.buckets
	newbuckets, nextOverflow := makeBucketArray(t, h.B+bigger, nil)

	flags := h.flags &^ (iterator | oldIterator)
	if h.flags&iterator != 0 {
		flags |= oldIterator
	}
	// 提交增长(相对于垃圾回收是原子的)
	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
	}

	// 实际的哈希表数据复制是通过 growWork() 和 evacuate() 增量完成的。
}
  1. 通过bigger的值来确定开启何种扩容:1-增量 0-等量
	bigger := uint8(1)
	if !overLoadFactor(h.count+1, h.B) {
		bigger = 0
		h.flags |= sameSizeGrow

解释一下 |= 这个符号是按位或赋值,是个缩写
可以写成 : h.flags = h.flags | sameSizeGrow

  1. 将原桶数组赋值给 oldBuckets,并创建新的桶数组和一批新的溢出桶
oldbuckets := h.buckets
newbuckets, nextOverflow := makeBucketArray(t, h.B+bigger, nil)
  1. 更新 hmap 的桶数组长度指数 B,flag 标识
flags := h.flags &^ (iterator | oldIterator)
	if h.flags&iterator != 0 {
		flags |= oldIterator
	}
	// 提交增长(相对于垃圾回收是原子的)
	h.B += bigger
	h.flags = flags
	h.oldbuckets = oldbuckets
	h.buckets = newbuckets
	h.nevacuate = 0
	h.noverflow = 0

四、扩容规则

  • 在等量扩容中,新桶数组长度与原桶数组相同
  • key-value 对在新桶数组和老桶数组的中的索引号保持一致
  • 在增量扩容中,新桶数组长度为原桶数组的两倍
  • 实际上,一个 key 属于哪个桶,取决于其 hash 值对桶数组长度取模得到的结果,因此依赖于其低位的 hash 值结果
  • 在增量扩容流程中,新桶数组的长度会扩展一位,假定 key 原本从属的桶号为 i,则在新桶数组中从属的桶号只可能是 i 或者 i + 老桶数组长度
  • 当 key 低位 hash 值向左扩展一位的 bit 位为 0,则应该迁往 i 位置;倘若该 bit 位为 1,应该迁往对应的 i + 老桶数组长度的位置

五、渐进扩容

渐进扩容避免了因为一次性迁移数据造成的性能抖动
每次出发渐进扩容的时候,都会为map完成两组桶的迁移

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

	// 清空另一个旧桶以促进增长
	if h.growing() {
		evacuate(t, h, h.nevacuate)
	}
}

具体的迁移逻辑在 evacuate 函数中

func evacuate(t *maptype, h *hmap, oldbucket uintptr) {
	b := (*bmap)(add(h.oldbuckets, oldbucket*uintptr(t.BucketSize)))
	newbit := h.noldbuckets()
	if !evacuated(b) {
		// TODO: 如果没有迭代器使用旧桶,则重用溢出桶而不是使用新桶。
		// xy 包含 x 和 y(低和高)撤离目的地。
		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))
		}

		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.ValueSize)) {
				top := b.tophash[i]
				if isEmpty(top) {
					b.tophash[i] = evacuatedEmpty
					continue
				}
				if top < minTopHash {
					throw("bad map state")
				}
				k2 := k
				if t.IndirectKey() {
					k2 = *((*unsafe.Pointer)(k2))
				}
				var useY uint8
				if !h.sameSizeGrow() {
					// 计算哈希以做出撤离决策(我们是否需要
					// 将这个键/值对发送到桶 x 或桶 y)。
					hash := t.Hasher(k2, uintptr(h.hash0))
					if h.flags&iterator != 0 && !t.ReflexiveKey() && !t.Key.Equal(k2, k2) {
						// 如果键不等于键(NaNs),那么哈希可能会(并且很可能会)
						// 与旧哈希完全不同。此外,它不可复制。在迭代器存在时,
						// 我们需要可复制性,因为我们的撤离决策必须
						// 与迭代器做出的决策相匹配。
						// 幸运的是,我们可以自由地将这些键发送到任何一方。
						// 此外,对于这类键,tophash 没有意义。
						// 我们让 tophash 的低位驱动撤离决策。
						// 我们为下一级重新计算一个新的随机 tophash,所以
						// 这些键在多次扩容后会均匀分布在所有桶中。
						useY = top & 1
						top = tophash(hash)
					} else {
						if hash&newbit != 0 {
							useY = 1
						}
					}
				}

				if evacuatedX+1 != evacuatedY || evacuatedX^1 != evacuatedY {
					throw("bad evacuatedN")
				}

				b.tophash[i] = evacuatedX + useY // evacuatedX + 1 == evacuatedY
				dst := &xy[useY]                 // 撤离目的地

				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.b.tophash[dst.i&(bucketCnt-1)] = top // 掩码 dst.i 作为优化,以避免边界检查
				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.ValueSize))
			}
		}
		// 断开溢出桶的链接并清除键/值以帮助 GC。
		if h.flags&oldIterator == 0 && t.Bucket.PtrBytes != 0 {
			b := add(h.oldbuckets, oldbucket*uintptr(t.BucketSize))
			// 保留 b.tophash 因为撤离
			// 状态维护在那里。
			ptr := add(b, dataOffset)
			n := uintptr(t.BucketSize) - dataOffset
			memclrHasPointers(ptr, n)
		}
	}

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

简单来说主要包含了以下几方面的内容:

  1. 获取到待迁移的桶b
  2. 获取到老桶长度newbit
  3. 判断是否已经完成了迁移
  4. 创建一个二元数组,将老桶数组的区域称为 x 区域,新扩展的区域称为 y 区域
  5. 双for循环,遍历每个键值对
  6. 获取每个位置的hashtop值,查找是否有空位
  7. 寻找到迁移的目的桶,下面是目的桶的定义
type evacDst struct {
	b *bmap          // 当前目标桶
	i int            // 键/元素在 b 中的索引
	k unsafe.Pointer // 指向当前键的存储位置的指针
	e unsafe.Pointer // 指向当前元素的存储位置的指针
}
  1. 迁移键值对,并且对桶结构中的指针进行更新
  2. 倘若当前迁移的桶是旧桶数组未迁移的桶中索引最小的一个,则 hmap.nevacuate 累加 1;倘若已经迁移完所有的旧桶,则会确保 hmap.flags 中,等量扩容的标识位被置为 0
  • 8
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值