上集回顾:map的增删改查
1. 触发条件
go map的扩容是增量扩容,发生的动作是元素的插入或更新,即:mapssign函数中处理
func mapassign(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {
...
if !h.growing() && (overLoadFactor(h.count+1, h.B) || tooManyOverflowBuckets(h.noverflow, h.B)) {
hashGrow(t, h)
goto again // Growing the table invalidates everything, so try again
}
...
}
1.1 非扩容中
如果map正处于扩容中,就再会进行二次扩容,等待上一次扩容完成后,才进行。
func (h *hmap) growing() bool {
return h.oldbuckets != nil
}
1.2 触发最大装填因子
在理想状态下(不包含溢出桶),将对数B的map装满则需要: (每个hmap存储8个元素)。但是在现实中不可能等到数据填满再进行扩容,所以经过试验,当数值大于一定程度就需要进行扩容:
在现实中,map是存在链桶(溢出桶),count的数量计算多了一个因素,所以除了装填因子以外,还需要对溢出桶的进行判断(1.3)。
查阅 runtime/map.go文件,可以在置顶位置看到go官方在测试装填因子的相关实验结果,能在表中看到当,装填因子为6.5的时候,map使用的整体效果是最佳的。
选取的装填因子,如果太大,我们有很多溢出桶;太小,我们浪费很多空间,下边是编写一个简单的程序来检查不同负载的一些统计数据 [官方摘录] :
| loadFactor | %overflow | bytes/entry | hitprobe | missprobe |
|---|---|---|---|---|
| 4.00 | 2.13 | 20.77 | 3.00 | 4.00 |
| 4.50 | 4.05 | 17.30 | 3.25 | 4.50 |
| 5.00 | 6.85 | 14.77 | 3.50 | 5.00 |
| 5.50 | 10.55 | 12.94 | 3.75 | 5.50 |
| 6.00 | 15.27 | 11.67 | 4.00 | 6.00 |
| 6.50 | 20.90 | 10.79 | 4.25 | 6.50 |
| 7.00 | 27.14 | 10.15 | 4.50 | 7.00 |
| 7.50 | 34.03 | 9.73 | 4.75 | 7.50 |
| 8.00 | 41.10 | 9.40 | 5.00 | 8.00 |
| 字段 | 含义 |
|---|---|
| %overflow | 具有溢出桶的桶的百分比 |
| bytes/entry | 每个键值对使用的开销字节 |
| hitprobe | 查找当前key时要检查的条目数 |
| missprobe | 查找缺少的key时要检查的条目数 |
/*
:params count: 预申请的元素数量
:params B: map的对数值
*/
func overLoadFactor(count int, B uint8) bool {
return count > bucketCnt && uintptr(count) > loadFactorNum*(bucketShift(B)/loadFactorDen)
}
1.3 存在过多溢出桶
- map存储的元素确实过多,hash数组不大,导致哈希冲突过多。
- 在前面章节中,map的添加/删除操作都只是针对topmap进行标志位处理,并没有真正的去对溢出桶进行回收。所以会面临空闲空间被占用。
/*
:prams noverflow: 溢出桶数量
:prams B: 对数值
*/
func tooManyOverflowBuckets(noverflow uint16, B uint8) bool {
// 这里做了一个阀值控制,避免太低(需要做额外工作),避免太高(增长和收缩的映射会保留大量未使用的内存)
// 细节看incrnoverflow
// 解释一下太高的问题,因为目前go map并没有缩容,当map无限扩大,会导致大量的内存浪费。
if B > 15 {
B = 15
}
return noverflow >= uint16(1)<<(B&15)
}
2. 扩容
扩容有两种,针对不同情况进行选择
- 达到了最大装载因子,就需要更大的hash数组。
- 这go map是溢出桶过多导致的,就需要保持桶的数量不变,并横向“增长”。
/*
:params t: 存储了key/value和桶大小等信息
:params h: map
*/
func hashGrow(t *maptype, h *hmap) {
bigger := uint8(1)
if !overLoadFactor(h.count+1, h.B) {
// 进行等量的内存扩容,所以B不变
bigger = 0
h.flags |= sameSizeGrow
}
// 将老 bucekts 挂到 buckets 上
oldbuckets := h.buckets
// 申请新的 buckets 空间
newbuckets, nextOverflow := makeBucketArray(t, h.B+bigger, nil)
// 初始化flags标志,清除其中的iterator和oldIterator标志
flags := h.flags &^ (iterator | oldIterator)
// 如果扩容前map的flags是存在迭代,就需要把旧的标志加上
if h.flags&iterator != 0 {
flags |= oldIterator
}
// commit the grow (atomic wrt gc)
// 当前hash数组变旧的,添加新的进来(这里可以看到扩容是成倍的)
h.B += bigger
h.flags = flags
h.oldbuckets = oldbuckets
h.buckets = newbuckets
// 因为是刚扩容,所以搬迁进度为0
h.nevacuate = 0
// 溢出桶数量也需要重置
h.noverflow = 0
// 同样在hash数组的溢出桶也需要做对应调整
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() and evacuate()
}
3. 搬迁
go map的数据迁移是增量完成的,你会看到添加/删除的过程会进行迁移,每次操作涉及到两次迁移。至于为什么需要出现两次迁移,在回答这个问题前,先考虑一个问题:
go map是增量扩容,每次迁移桶确认:b = hash(key) & (2^B-1),在不影响外界操作的情况下,快速将所有桶进行全部迁移(不能保证每次key值都落在不同桶,这样就很难保证所有桶快速迁移)?
- 第一种方式:如果key所在桶没迁移,进行当前桶迁移,否则,向下取第一个未迁移的桶
- 第二种方式:迁移当前key所在桶,再选取迁移进度所在的桶(迁移进度只是最终一致性)
3.1 开始迁移
/*
:params t: 存储了key/value和桶大小等信息
:params h: map
:parmas bucket: 当前桶(代指当前桶所在hash数组索引)
*/
func growWork(t *maptype, h *hmap, bucket uintptr) {
// 我们要确保排空即将使用的桶对应的旧桶
evacuate(t, h, bucket&h.oldbucketmask())
// 再搬迁一个 bucket,以加快搬迁进度
if h.growing() {
evacuate(t, h, h.nevacuate)
}
}
3.2 计算新桶信息
/*
:params t: 存储了key/value和桶大小等信息
:params h: map
:params oldbucket: 旧桶(代指旧桶所在hash数组索引)
*/
func evacuate(t *maptype, h *hmap, oldbucket uintptr) {
// 1.计算旧桶地址及数组大小
b := (*bmap)(add(h.oldbuckets, oldbucket*uintptr(t.bucketsize)))
// 旧桶的容量
newbit := h.noldbuckets()
// 有没有被搬迁过
if !evacuated(b) {
// TODO: 如果没有使用旧桶的迭代器,请重用溢出桶,而不是使用新桶。(如果!oldIterator),作者预留了下一次需要做的事情
// 默认是 等量扩容,前后 bucket序号不变
// 计算新桶前半段信息
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))
}
'''
}
3.3 当前链桶数据迁移
具体的迁移方案可以看:迁移方案,标志位设置看:迁移标志位。 在看源码的过程中,应该也看到 的奇怪行为,一度以为是作者脑子给驴踢了,看着其他博主介绍,才解开存疑,具体看上述的迁移方案。
- 我们第一步需要确认旧桶位置到新桶位置(hash数组的索引),第二步则需要对旧桶的数据迁移到新桶。
- 针对旧桶中已经迁移的tophash,设置其迁移标志位
- 这里做了一点优化,手动gc,在没有迭代介入的情况下,将溢出桶的key/value/溢出链表做了清空操作。
- 最后更新一下迁移进度了
func evacuate(t *maptype, h *hmap, oldbucket uintptr) {
...
// 遍历旧桶中的链桶
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.elemsize)) {
top := b.tophash[i]
// 空cell,将topthsh[i]标志位设置为 evacuatedEmpty
if isEmpty(top) {
b.tophash[i] = evacuatedEmpty
continue
}
// 正常的情况,isEmpty已经判空了,如果还出现小于minTopHash,这种只能是异常状态
if top < minTopHash {
throw("bad map state")
}
k2 := k
if t.indirectkey() {
k2 = *((*unsafe.Pointer)(k2))
}
// 用来确定旧key应该放在新桶的前半段还是后半段
var useY uint8
// 非等量扩容(即:两倍扩容)
if !h.sameSizeGrow() {
// 计算hash以做出迁移决定(是否需要将此 key/elem 发送到桶X或桶Y)
hash := t.hasher(k2, uintptr(h.hash0))
// 这里有存疑点是t.flags的状态定义,没有探索到这部分,但对应的条件可以解释(看章节细节推动)
// key != key,但显现的结果又是一致,由于迭代器需要遍历,又不能随意设置,所以依托top来计算这类key的存储位置。
if h.flags&iterator != 0 && !t.reflexivekey() && !t.key.equal(k2, k2) {
useY = top & 1
top = tophash(hash)
} else {
// 这条公式决定了是将该元素存储在新桶的前半段还是后半段(符合取模规则)
if hash&newbit != 0 {
useY = 1
}
}
}
// 在写入标志位时,需要确定是我想要的常量值(没有应该也不碍事才对)
if evacuatedX+1 != evacuatedY || evacuatedX^1 != evacuatedY {
throw("bad evacuatedN")
}
// 将旧桶的tophash标志位更改为迁移标志
b.tophash[i] = evacuatedX + useY // evacuatedX 或 evacuatedY
dst := &xy[useY] // evacuation destination
// 如果 xi/yi 等于8,说明要溢出了,需要新建一个bucket
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.i 作为优化,以避免边界检查(在源码很多地方都存在边界值的判断)
dst.b.tophash[dst.i&(bucketCnt-1)] = top
// key是指针,将原key(是指针)复制到新位置,否则将原key(值)复制到新位置
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.elemsize))
// bucket搬迁完毕,如果没有协程在使用老的 buckets,就把老 buckets清除,帮助gc(在遍历的过程中进行插入的业务场景才会出现这种情况吧)
if h.flags&oldIterator == 0 && t.bucket.ptrdata != 0 {
b := add(h.oldbuckets, oldbucket*uintptr(t.bucketsize))
// 保留b.tophash,因为迁移状态还保留着(剩余的key/value/溢出桶指针空间就需要清除)
ptr := add(b, dataOffset)
n := uintptr(t.bucketsize) - dataOffset
// 从 ptr开始清除n个字节的类型化内存
memclrHasPointers(ptr, n)
}
}
// 更新搬迁进度
if oldbucket == h.nevacuate {
advanceEvacuationMark(h, t, newbit)
}
}
3.4 更新搬迁进度
/*
:params h: map
:params t: 存储了key/value和桶大小等信息
:params newbit: 旧桶的hash数组长度
*/
func advanceEvacuationMark(h *hmap, t *maptype, newbit uintptr) {
h.nevacuate++
// 实验表明,1024是最高预设,无论如何,把它放在那里作为保证,以确保O(1)行为。(设置阈值)
stop := h.nevacuate + 1024
if stop > newbit {
stop = newbit
}
// 判断从当前桶置后面是否需要继续迁移,不需要直接将进度+1
for h.nevacuate != stop && bucketEvacuated(t, h, h.nevacuate) {
h.nevacuate++
}
// 迁移进度和旧哈希桶长度一致,迁移完成后,将相关地址置为nil
if h.nevacuate == newbit {
// 旧桶设置为nil
h.oldbuckets = nil
// 也可以丢弃旧的溢出桶。如果它们仍然被迭代器引用,那么迭代器将保存指向切片的指针。
if h.extra != nil {
h.extra.oldoverflow = nil
}
// 移除扩容状态
h.flags &^= sameSizeGrow
}
}
迁移细节
在迁移的代码中,有点令人疑惑的地方,这部分单独抽离出来看,从源码的角度去分析,发现迁移进度是最终一致性(就是中间过程的迁移进度不准确,是通过下一次的迁移进行矫正)。
下图是数据插入时,桶的迁移以及进度值的变化:
func growWork(t *maptype, h *hmap, bucket uintptr) {
// 我们要确保排空即将使用的桶对应的旧桶
evacuate(t, h, bucket&h.oldbucketmask())
// 再搬迁一个 bucket,以加快搬迁进度
if h.growing() {
evacuate(t, h, h.nevacuate)
}
}
func evacuate(t *maptype, h *hmap, oldbucket uintptr) {
// 1.计算旧桶地址及数组大小
b := (*bmap)(add(h.oldbuckets, oldbucket*uintptr(t.bucketsize)))
// 旧桶的容量
newbit := h.noldbuckets()
if !evacuated(b){ ... }
// 更新搬迁进度
if oldbucket == h.nevacuate {
advanceEvacuationMark(h, t, newbit)
}
}
func advanceEvacuationMark(h *hmap, t *maptype, newbit uintptr) {
h.nevacuate++
for h.nevacuate != stop && bucketEvacuated(t, h, h.nevacuate) {
h.nevacuate++
}
func bucketEvacuated(t *maptype, h *hmap, bucket uintptr) bool {
b := (*bmap)(add(h.oldbuckets, bucket*uintptr(t.bucketsize)))
return evacuated(b)
}
func evacuated(b *bmap) bool {
h := b.tophash[0]
return h > emptyOne && h < minTopHash
}
4.缩容
go map没有实现缩容的功能,这样会造成一种情况,在使用map的过程中,其申请的内存空间会越来越大(空间利用率底低),甚至会导致内存溢出。
所以在业务的实际开发中,根据经验,自己实现缩容搬迁,即创建一个比较小的map,将需要缩容的map的元素挨个搬迁过来。
事例如下:
// go map缩容代码示例
myMap := make(map[int]int, 1000000)
// TODO 假设这里我们对bigMap做了很多次插入,之后又做了很多次删除,此时bigMap的元素数量远小于hash表大小
// ...
// 接下来我们开始缩容
smallMap := make(map[int]int, len(myMap))
for k, v := range myMap {
smallMap[k] = v
}
myMap = smallMap // 缩容完成,原来的map被我们丢弃,交给gc去清理