本篇涉及到一些前面文章的知识,推荐大家先阅读前几篇文章(直接往下看也行,涉及的不多):
深度解析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() 增量完成的。
}
- 通过bigger的值来确定开启何种扩容:1-增量 0-等量
bigger := uint8(1)
if !overLoadFactor(h.count+1, h.B) {
bigger = 0
h.flags |= sameSizeGrow
解释一下 |= 这个符号是按位或赋值,是个缩写
可以写成 : h.flags = h.flags | sameSizeGrow
- 将原桶数组赋值给 oldBuckets,并创建新的桶数组和一批新的溢出桶
oldbuckets := h.buckets
newbuckets, nextOverflow := makeBucketArray(t, h.B+bigger, nil)
- 更新 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)
}
}
简单来说主要包含了以下几方面的内容:
- 获取到待迁移的桶b
- 获取到老桶长度newbit
- 判断是否已经完成了迁移
- 创建一个二元数组,将老桶数组的区域称为 x 区域,新扩展的区域称为 y 区域
- 双for循环,遍历每个键值对
- 获取每个位置的hashtop值,查找是否有空位
- 寻找到迁移的目的桶,下面是目的桶的定义
type evacDst struct {
b *bmap // 当前目标桶
i int // 键/元素在 b 中的索引
k unsafe.Pointer // 指向当前键的存储位置的指针
e unsafe.Pointer // 指向当前元素的存储位置的指针
}
- 迁移键值对,并且对桶结构中的指针进行更新
- 倘若当前迁移的桶是旧桶数组未迁移的桶中索引最小的一个,则 hmap.nevacuate 累加 1;倘若已经迁移完所有的旧桶,则会确保 hmap.flags 中,等量扩容的标识位被置为 0