Goland的map源码解读(二)---put过程及扩容

241 阅读8分钟

这篇文章,主要查看goland中map的put函数的大致源码及流程,废话不多说,直接上代码

//这个就是put过程中最重要的函数
func mapassign(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {
   //一些安全验证,注释略过,不看
   /**
   if h == nil {
      panic(plainError("assignment to entry in nil map"))
   }
   if raceenabled {
      callerpc := getcallerpc()
      pc := abi.FuncPCABIInternal(mapassign)
      racewritepc(unsafe.Pointer(h), callerpc, pc)
      raceReadObjectPC(t.key, key, callerpc, pc)
   }
   if msanenabled {
      msanread(key, t.key.size)
   }
   if asanenabled {
      asanread(key, t.key.size)
   }*/
    
   //判断是否并发写
   if h.flags&hashWriting != 0 {
      throw("concurrent map writes")
   }
   //调用hash函数获取key对应的hash值
   hash := t.hasher(key, uintptr(h.hash0))
​
   //修改flags状态,标志为正在写,避免后续线程并发写,所以map只是单线程使用的,多线程环境用syn.map
   h.flags ^= hashWriting
​
   //如果还没创建桶数组,就创建
   if h.buckets == nil {
      h.buckets = newobject(t.bucket) // newarray(t.bucket, 1)
   }
​
again:
   //算出应该存到那个数组,h.B是跟数组数量相关的数(先了解hmap结构)
   bucket := hash & bucketMask(h.B)
   //判断map是否处于扩容状态--(go的map使用的扩容机制的rehash而不是一次性hash)
   if h.growing() {
      growWork(t, h, bucket)
   }
   //确定key要存放到那一个bmap
   b := (*bmap)(add(h.buckets, bucket*uintptr(t.bucketsize)))
   //hash值的高八位
   top := tophash(hash)
​
   var inserti *uint8 //key在bmap里的位置
   var insertk unsafe.Pointer //key的地址
   var elem unsafe.Pointer //value的地址
bucketloop:
   for {
      //遍历选中的bmap
      for i := uintptr(0); i < bucketCnt; i++ {
         //对比key值的高8位,如果不相等,进入if
         if b.tophash[i] != top {
            //如果该位置未被使用且key还未找到位置,使用该位置
            if isEmpty(b.tophash[i]) && inserti == nil {
               //设置key在数组里的位置,通过位移找到对应的key和value的地址
               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))
            }
            /**
            这里比较有意思,在前面的isEmpty判断中,是判断是否<=1
            而这里的emptyRest的值其实是0
            0和1其实都表示了当前位置是空的,当是0还表示了,这个位置之后没有空位置了(包括后续的溢出桶)
            所以在跳出循环时,标记的最后的一个位置
            那为什么要找最后一个位置而不是找到一个能用的位置就退出呢?当然是为了确认key是否已经存在
            */
            if b.tophash[i] == emptyRest {
               break bucketloop
            }
            continue
         }
          
         //高8位比较相等的情况
          
         //找到对应key的位置
         k := add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize))
         if t.indirectkey() {
            k = *((*unsafe.Pointer)(k))
         }
         //比较该位置上的key和传入的key是否相同
         if !t.key.equal(key, k) {
            continue
         }
         //比较相同,也就是key已经存在于map,进行赋值操作
         if t.needkeyupdate() {
            typedmemmove(t.key, k, key)
         }
         elem = add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.elemsize))
         goto done //跳转到结束位置
      }
       
      //执行到这里,说明在当前的bmap中,没有找到key,当不确定key是否在溢出桶中
      //获取溢出桶的位置,如果没有溢出桶,跳出循环,否则,遍历溢出桶,再次执行遍历流程
      ovf := b.overflow(t)
      if ovf == nil {
         break
      }
      b = ovf
   }
    
    /**
    到了这里,有两种状态:一是找到了一个空位置,并记录了地址,二是bmap全是满的,变量现在还是空的
    注意的是,如果key是已存在的,已经goto done了,不会来到这里
    */
​
   //判断map的负载因子是否达到6.5,溢出桶是否太多了。如果是,进行扩容
   if !h.growing() && (overLoadFactor(h.count+1, h.B) || tooManyOverflowBuckets(h.noverflow, h.B)) {
      hashGrow(t, h)
      goto again // 回到起点,重新走一次流程
   }
​
   
   //没有找到可能使用的桶的情况,重新创建一个桶使用
   //其实就是多创建了一个bmap做溢出桶
   if inserti == nil {
      newb := h.newoverflow(t, b)
      inserti = &newb.tophash[0]
      insertk = add(unsafe.Pointer(newb), dataOffset)
      elem = add(insertk, bucketCnt*uintptr(t.keysize))
   }
​
   //一些列的赋值操作,没什么好说的
   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 {
      throw("concurrent map writes")
   }
   h.flags &^= hashWriting
   if t.indirectelem() {
      elem = *((*unsafe.Pointer)(elem))
   }
   return elem
}

流程大致总结:

1、 安全性检查、并发写检查

2、标记正在写,计算hash值,找到对应的bmap

3、遍历对应的bmap及其溢出桶,寻找合适的位置

4、如果key已经存在,更新对应的value

5、如果key不存在,将最后一个位置分配给key,若已无位置,创建一个bmap做溢出桶

接下来再来看看扩容的一个流程:

当负载因子到了6.5或者溢出桶太多时,会进入hashGrow函数:

func hashGrow(t *maptype, h *hmap) {
   bigger := uint8(1)
   //这里判断这次扩容是因为负载因子到了6.5还是因为溢出桶太多
   if !overLoadFactor(h.count+1, h.B) {
      //溢出桶太多了,负载因子还没到6.5
      bigger = 0
      h.flags |= sameSizeGrow
   }
   oldbuckets := h.buckets
   //bigger是1或者0,也就是说,扩容到2倍或者不变,对应负载因子和溢出桶两种情况
   newbuckets, nextOverflow := makeBucketArray(t, h.B+bigger, nil)
​
   //判断当前是不是迭代的状态,功能略过
   flags := h.flags &^ (iterator | oldIterator)
   if h.flags&iterator != 0 {
      flags |= oldIterator
   }
    
   // 对hmap进行属性值的更新
   h.B += bigger
   h.flags = flags
   h.oldbuckets = oldbuckets
   h.buckets = newbuckets
   h.nevacuate = 0
   h.noverflow = 0
​
   //对extra进行nil判断,进行移值
   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
   }
}

可以看出,hashGrow并没有干多少事,我们再来看看put好delete过程中都出现的growWork函数:

func growWork(t *maptype, h *hmap, bucket uintptr) {
   //将bmap搬迁到新的bucket数组
   evacuate(t, h, bucket&h.oldbucketmask())
​
   // 搬迁未完成,再搬迁一个
   if h.growing() {
      evacuate(t, h, h.nevacuate)
   }
    
   //可以看到,每次growWork其实最多可以搬迁两个bmap到新数组
}

接下来详细看看evacuate函数:

// 先了解一个结构体,官方注释称它是一个撤离地点
type evacDst struct {
    b *bmap          // 当前目标桶
    i int            // key-value在b中的索引位置
    k unsafe.Pointer // key的地址
    e unsafe.Pointer // value的地址
}
​
func evacuate(t *maptype, h *hmap, oldbucket uintptr) {
   //获取要搬迁的bmap
   b := (*bmap)(add(h.oldbuckets, oldbucket*uintptr(t.bucketsize)))
   //算一下之前有多少个桶
   newbit := h.noldbuckets()
   //判断bmap里面有没有东西,也就是要不要搬迁
   if !evacuated(b) {
      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 := &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))
      }
​
      //遍历bmap及其溢出桶
      for ; b != nil; b = b.overflow(t) {
         //bmap中第一个key和value的地址
         k := add(unsafe.Pointer(b), dataOffset)
         e := add(k, bucketCnt*uintptr(t.keysize))
         //遍历当前的bmap
         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
            }
            //错误问题,不深究
            if top < minTopHash {
               throw("bad map state")
            }
            k2 := k
            if t.indirectkey() {
               k2 = *((*unsafe.Pointer)(k2))
            }
             
            var useY uint8
            //如果是翻倍扩容,需要计算key-value应该存那个bmap
            /**
            这里简单的说一下通过hash寻找下标的算法,其实跟java一样,知道的直接跳过
            1、通过hash函数获取到key对应的hash值
            2、要注意一点是,hmap里的bmap数组长度是2的幂次方,二进制表示计算00...0010..00
            3、数组长度n是一个只有一位是1的数字,而我们知道实际范围是0-(n-1)
            4、再了解&(位与)操作,n&m<=Math.min(n,m)
            5、所以如果计算hash&(n-1)就可以获得一个在0-(n-1)范围内的值
            6、扩容是扩到原来的两倍,也就是n左移一位,那(n-1)其实就是多了一个1
            7、那只要判断hash值对应位是1还是0就可以知道key在新数组是在原位置或是加了n/2
            */
            if !h.sameSizeGrow() {
               hash := t.hasher(k2, uintptr(h.hash0))
                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")
            }
​
            //开始搬迁,主要这里是双重循环内部
            //第一层循环是遍历bmap及其溢出桶
            //第二层循环是遍历一个bmap里的数组
            //下面就是就原bmap数组里的一个key-value转移新的bmap里
            b.tophash[i] = evacuatedX + useY 
            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 
            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))
         }
      }
       
      // 对应迁移完的bmap,进行标记,便于GC的清理
      if h.flags&oldIterator == 0 && t.bucket.ptrdata != 0 {
         b := add(h.oldbuckets, oldbucket*uintptr(t.bucketsize))
         ptr := add(b, dataOffset)
         n := uintptr(t.bucketsize) - dataOffset
         memclrHasPointers(ptr, n)
      }
   }
​
   //如果这次搬迁是按照h.nevacuate做的,需要标记更新nevacuate
   if oldbucket == h.nevacuate {
      advanceEvacuationMark(h, t, newbit)
   }
}

扩容搬迁小总结:

1、 在进行put和delete时,都会进行gorwWork函数进行搬迁

2、gorwWork会搬迁两次,一次是搬迁当前使用到的bmap,一次是按照进度搬迁(可能已经被搬迁了或为空)

3、evacuate函数是真正的搬迁函数,它每次都会搬运一个bmap及其溢出桶到新的数组

最后再讲一下golang的渐进式rehash:

1、 说到渐进式rehash,redis的hashmap跑不了,为了避免一次性hash的停顿,redis使用了rehash将搬迁分摊给每一次操作

2、与只对比的就是JAVA的hashmap了,java的hashmap使用一次性的hash

3、所以就有这样一道面试题:为什么java不使用渐进式的rehash呢?

先了解一下java的hashmap底层,它用的是一个数组,每个数组元素是一个链表或红黑树

如果使用渐进式的rehash,首先的问题是,原数组无法立即得到释放,多占了一部分空间,而且可能会滞后很久

其次就是rehash会带来比较高的复杂性,总体下来rehash并不完全优于一次性hash

那问题来了,为什么golang的map可以rehash?

这其实是设计上的问题,goland使用hmap和bmap两种结构,让map天然就是分开存储的,转移完一个bmap,就可以标记让GC清理了,这样就不存在长时间占用要释放的空间的问题了。而且对应evacuate函数来说,每次搬运的都是一个整体(bmap),也不存在高复杂度。