序言
在日常的开发当中,我们一定离不开一个数据结构字典。不仅可以存储关联数据对,还可以在 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
,而不包含key
,val
,overflow
? - 为什么需要存储
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 的消耗,这里规定如果 key
和 val
都是非指针类型(存在于栈上)那么 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. 读一个键
Go 的 Map 读取一个键需要关注的是,这个键现在在新的 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
,再来看如何寻找的:
- 比较 tophash 如果相同才比较 key
- 比较 key 如果不相同继续循环,反之获取 val 返回
- 当前 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
的同时就可以判断后续是否还有数据,没有的话就直接终止了遍历。
现在我们来看看具体的更新细节:
- 如果当前是
bucket
最后一个元素并且下一个bucket
不为空且还有元素,更新结束:
- 如果当前位置的下一个元素不为空,更新结束:
除去当前两种情况,那么当前的位置肯定就是emptyRest
。但是还不至于此,我们需要向前判断,如果前一个元素是emptyOne
那么也需要更新为emptyRest
,直至遇到非空的或者是到达当前bucket
的起点位置。 - 向前更新直至遇到非空元素,更新结束:
假如现在更新到了起点位置并且也为空,那么我们还需要前往前一个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
如果本次迁移的 bucket
和 nevacuate
(该下标之前的 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等因素来提升性能,使得在维护大量数据的情况下依然抗打!