Go源码解析——Map篇

2,094 阅读17分钟

前言

上一篇文章《Go源码解析——Channel篇》中,我们对Channel的源码进行了详细解析,相信看过的同学已经对Channel的底层原理有了较为深入的了解。

和Channel一样,Map在我们的日常开发工作中也有着广泛的应用,了解Map的底层原理可以让我们在业务中写出更高效的代码,同样也可以避免在开发中踩坑。

本篇文章从源码角度学习golang map的底层操作,和上一篇文章一样,基于Go1.17源码进行分析。

相信大家对Hash表并不陌生,Go语言中的Map基于Hash表实现,通过链表来解决Hash的碰撞问题,主要结构如下图所示:

example_map.png

本文会从map的底层数据结构初始化Map数据的查找、写入、更新、删除以及遍历等常见操作展开,在文章末尾提出map的并发操作并与Java****中哈希表进行对比,篇幅较长,请大家耐心阅读😂。

本专栏目前已更新两篇源码文章,后续会持续更新Golang源码和编译相关的文章,对Golang源码感兴趣的同学可以持续关注。

专栏地址:[Golang源码解析]

如果对大家有帮助的话,麻烦三连哦😊😊😊!!!

hmap数据结构

首先我们对map的底层数据结构hmap进行介绍,结构如下:

代码位置:src/runtime/map.go

type hmap struct {
    count     int             // map中k-v对数
    flags     uint8           // map状态字段
    B         uint8           // 桶的个数为2^B
    noverflow uint16          // 溢出桶的个数
    hash0     uint32          // 计算key的hash值时作为hash种子使用
    buckets    unsafe.Pointer // 指向bucket首地址的指针
    oldbuckets unsafe.Pointer // map扩容后, 该字段指向扩容前buckets内存首地址
    nevacuate  uintptr        // 迁移进度
    extra *mapextra           // 可选数据
}

type mapextra struct {
    overflow    *[]*bmap  // overflow地址数组的指针
    oldoverflow *[]*bmap  // 扩容时原overflow地址数组的指针
    nextOverflow *bmap    // 下一个空闲overflow的地址
}

bucket的结构为bmap ,但这只是(src/runtime/map.go)的结构,编译期间会动态创建一个新的结构:

// 表面是以下的情况
type bmap struct {
   tophash [8]uint8
}

// 以下才是bucket实际的结构
type bmap struct {
  topbits  [8]uint8 // 存储hash值的高8位,除了存储hash值的高8位,也可以存储一些状态码
  keys     [8]keytype // key数组,隐藏字段
  values   [8]valuetype // value数组,隐藏字段​
  overflow uintptr // 溢出buceket指针,隐藏字段​
}

通过上面的讲解,大家可能只是对字段的含义有所了解,但是对map每个字段和结构在流程中起到的作用还是很迷惑,没关系,我们会在对map常见操作的原理解析中补充细节。

创建Map

由于map的创建方式不同,其对应的底层编译方式不同,按照初始化方式不同可以分为字面量初始化make初始化两种,按照编译方式的不同可以分为栈分配堆分配常规分配三种。

同一种初始化方式,可能会对应不同的内存分配方式,这里主要介绍map的常见创建方式以及对应的内存分配方式,并会对不同情况下的内存分配进行总结。

创建方式

字面量创建方式

// 栈分配
m1 := map[int]int{1: 1, 2: 1, 3: 1, 4: 1, 5: 1, 6: 1, 7: 1, 8: 1}
m1[1] = 1024
// 堆分配  
m2 := map[int]int{1: 1, 2: 1, 3: 1, 4: 1, 5: 1, 6: 1, 7: 1, 8: 1}
fmt.Println(m2)// 此处由于使用fmt.Println发生了内存逃逸
// 常规分配
m3 := map[int]int{1: 1, 2: 1, 3: 1, 4: 1, 5: 1, 6: 1, 7: 1, 8: 1, 9: 1}
m3[1] = 1024

以上三种创建方式中,分别对应着栈上分配内存堆上分配内存常规分配makemap函数)三种内存分配方式,对应的汇编结果如下:go.godbolt.org/z/MaKfdTjoo

查看与初始化相关的带有CALL 指令的如下:

CALL    runtime.fastrand(SB)
CALL    runtime.makemap_small(SB)
CALL    runtime.makemap(SB)

关键字创建方式

// 栈分配
m1 := make(map[int]int)
m1[0] = 1024
// 栈分配
m2 := make(map[int]int,8)
m2[0] = 1024
// 堆分配
m3 := make(map[int]int,8)
fmt.Println(m3)// 此处由于使用fmt.Println发生了内存逃逸
// 常规分配
m4 := make(map[int]int,9)
m4[0] = 1024

以上四种字面量创建方式中,前两种初始化方式为栈上分配内存,后面两种分别为堆上分配内存和常规分配 (makemap函数),对应的汇编结果如下:go.godbolt.org/z/Ez1bzh3fT

CALL    runtime.fastrand(SB)​
CALL    runtime.makemap_small(SB)​
CALL    runtime.makemap(SB)

总结

  • 无论是字面量初始化还是make初始化,当所需Map分配到堆上且所需长度<=8时,使用runtime.makemap_small初始化;
  • 无论是字面量初始化还是make初始化,当所需Map分配到不需要分配到堆上且所需长度<=8时,通过快速哈希方式创建;
  • 其余情况均调用runtime.makemap函数进行内存分配

由于大部分场景中都是调用makemap。下面我们对makemap函数进行详细介绍:

makemap

makemap函数主要完成以下工作:

  • 内存校验,校验map初始化所需内存是否超过内存限制
  • 创建结构体
  • 获取hash种子
  • 计算合适的B值
  • 调用makeBucketArray函数,得到桶的首地址以及溢出bmap
  • 返回hmap指针

源码如下:

代码位置:src/runtime/map.go

func makemap(t *maptype, hint int, h *hmap) *hmap {
   // 校验所需内存是否超过内存限制,即hint*size
   mem, overflow := math.MulUintptr(uintptr(hint), t.bucket.size)
   if overflow || mem > maxAlloc {
      hint = 0
   }
   // 创建结构体
   if h == nil {
      h = new(hmap)
   }
   h.hash0 = fastrand()// 获取hash种子 
   B := uint8(0)
   // 计算B值
   for overLoadFactor(hint, B) {
   // hint > 8 && uintptr(hint) > 13*(2^B/2)
      B++
   }
   h.B = B 
   if h.B != 0 {
      var nextOverflow *bmap
      // 调用makeBucketArray函数,得到桶的首地址以及溢出bmap
      h.buckets, nextOverflow = makeBucketArray(t, h.B, nil)
      // 溢出bmap不为nil
      if nextOverflow != nil {
         h.extra = new(mapextra)
         h.extra.nextOverflow = nextOverflow
      }
   }
   return h
}

此处的重点内容是,makemap函数调用overLoadFactor函数完成B值的计算,之后调用makeBucketArray获取bmap的首地址和溢出桶的地址,代码如下:

func overLoadFactor(count int, B uint8) bool {
    // count > 8 && uintptr(count) > 13*(2^B/2)
   return count > bucketCnt && uintptr(count) > loadFactorNum*(bucketShift(B)/loadFactorDen)
}
// 返回2^B
func bucketShift(b uint8) uintptr {
   return uintptr(1) << (b & (sys.PtrSize*8 - 1))
}

makeBucketArray函数代码如下:

func makeBucketArray(t *maptype, b uint8, dirtyalloc unsafe.Pointer) (buckets unsafe.Pointer, nextOverflow *bmap) {
   base := bucketShift(b) // 2^B
   nbuckets := base // buckets数量
   if b >= 4 { // 如果 b>=4,额外申请一定数量bucket 
      nbuckets += bucketShift(b - 4))// 新增bucket的数量为2^(b-4)
      sz := t.bucket.size * nbuckets // 整体大小
      up := roundupsize(sz) // 返回分配的空间
      if up != sz {
         nbuckets = up / t.bucket.size // 重新计算桶数量
      }
   }
   if dirtyalloc == nil {
      buckets = newarray(t.bucket, int(nbuckets)) // 创建底层bucket数据
   } else {
      // 对dirtyalloc的空间进行一次清空
      buckets = dirtyalloc
      size := t.bucket.size * nbuckets
      if t.bucket.ptrdata != 0 {
         memclrHasPointers(buckets, size)
      } else {
         memclrNoHeapPointers(buckets, size)
      }
   }
   if base != nbuckets {
      // 处理额外添加bucket的情况
      // step1: 额外添加的初始位置
      nextOverflow = (*bmap)(add(buckets, base*uintptr(t.bucketsize)))
      // step2: buckets的最后一个bucket
      last := (*bmap)(add(buckets, (nbuckets-1)*uintptr(t.bucketsize)))
      // step3: 将最后一个bucket的overflow指向buckets的头部
      last.setoverflow(t, (*bmap)(buckets))
   }
   return buckets, nextOverflow
}

处理额外bucket的情况看起来有点复杂,这里借助一个例子讲解,当B为5时,此时base =2 ^ 5= 32,nbuckets = 2 ^ B + 2 ^ (B-4) = 32+2 = 34,具体如图所示:

image

Map中数据的读取

Go语言中常见的读取数据的方式如下:

value := m["name"]
value, ok := m["name"]

对应的汇编结果如下:go.godbolt.org/z/cjrE99YvK

与读取相关的CALL指令如下所示:

CALL    runtime.mapaccess1_faststr(SB)
CALL    runtime.mapaccess2_faststr(SB)

两种语法对应到以下两个不同的函数,稍后我们对函数进行源码分析。

func mapaccess1(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer
func mapaccess2(t *maptype, h *hmap, key unsafe.Pointer) (unsafe.Pointer, bool)

key的定位

我们首先对key的定位原理进行介绍,这将对之后学习Map中数据的读取、写入和删除打下基础,这部分内容也可以说是Map的核心内容。

对于key的定位问题主要有两个步骤需要确定:

  • 数据落在哪个bucket中
  • 确定bucket中的具体位置

我们通过一个例子来说明,通过上面的示意图以及代码,这里我们假设B为5,key的定位流程如下图所示:

map_access.png

假定B = 5,所以bucket总数就是 2^5 = 32。

  • 首先计算出待查找key的哈希,使用低5位00100,对应4号bucket

  • 之后使用高8位10010110,对应十进制150,在6号 bucket中寻找tophash值(HOB hash)为150的 key,找到了2号槽位,那么对应的key和value也是在2号槽位,这样整个查找过程就结束了​

如果在 bucket 中没找到,并且overflow不为空,则继续去overflow bucket中寻找,直到找到key或所有的key槽位都找遍了,包括所有的overflow bucket。

定位的核定逻辑:

  • key的哈希值的低B位确定数据落在哪个桶里,key的哈希值的高8位确定数据的具体位置

需要注意的点包括:

  • 每个bucket只能存8对key-value,一旦超过就通过overflow指向下一个bmap
  • HOBHash 指的就是tophash,每个bucket中topHash唯一
  • key 和 value 是各自放在一起的,并不是key/value/...这样的形式,可以节省内存空间

上面我们完成了key定位的讲解,接下来我们对数据读取的两个函数mapaccsess1mapaccsess2进行介绍,由于mapaccsess1与mapaccsess2函数的区别在于runtime.mapaccess2多返回了一个bool值标记是否查询到数据,接下来以mapaccsess1为例对map中数据的读取原理进行解析。

mapaccsess1

mapaccsess1函数整体流程如下:

  • 判断是否并发读写,如果是,则抛出panic(map不支持并发读写);
  • 计算hash值,根据hash的低B位找到对应的bucket,根据高8位,找到对应tophash槽位进而找到key-value;
  • map迁移场景下,优先从oldbuckets中查找kv;
  • 比较key,相等则返回value,不等则返回0值;

源码如下所示:

代码位置:src/runtime/map.go

func mapaccess1(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {
   if h == nil || h.count == 0 {// 如果h为nil或无key-value,返回零值
      if t.hashMightPanic() {
         t.hasher(key, 0) // see issue 23734
      }
      return unsafe.Pointer(&zeroVal[0])
   }
   // 并发读写会产生panic
   if h.flags&hashWriting != 0 {
      throw("concurrent map read and map write")
   }
   hash := t.hasher(key, uintptr(h.hash0))// 计算hash值
   m := bucketMask(h.B)// m表示map中bucket的数量
   b := (*bmap)(add(h.buckets, (hash&m)*uintptr(t.bucketsize)))
   // 利用`hash mod m`可以计算bucket索引,b表示bucket首地址
   // 如果map正在迁移,优先从oldbuckets中查找kv
   if c := h.oldbuckets; c != nil {
      if !h.sameSizeGrow() {
         // 如果是等量扩容迁移,则oldbuckets实际的bucket数量是m的一半
         m >>= 1
      }
      // 根据hash值,查找oldbuckets中对应的bucket地址
      oldb := (*bmap)(add(c, (hash&m)*uintptr(t.bucketsize)))
       // 如果oldb的标志位不是撤离状态,则我们从oldb中查找kv
      if !evacuated(oldb) {
         b = oldb
      }
   }
    // top表示hash的高8位,如果hash高8位小于5,则top需要加上5;因为5表示`minTopHash`
    // top如果是小于等于5,都是表示特殊状态;正常的key的top值都是大于5的
   top := tophash(hash)
bucketloop:
    // 逐个查找对应bucket和其溢出bucket
   for ; b != nil; b = b.overflow(t) {
      for i := uintptr(0); i < bucketCnt; i++ {
         if b.tophash[i] != top {
            if b.tophash[i] == emptyRest {
            // 如果b.tophash[i] == emptyRest,表示剩下的kv对都是空的,所以直接跳出循环
               break bucketloop
            }
            continue
         }
         // 查找对应key的地址
         k := add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize))
         if t.indirectkey() {
            k = *((*unsafe.Pointer)(k))
         }
         if t.key.equal(key, k) {
         // 如果key相等,则找到对应的value 
            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])   // 返回对应的0值
}

Map中数据的新增和更新

Go语言中map的新增和更新操作如下所示:

m1 := make(map[int8]int)
m1[1] = 1    // 新增
m1[1] = 1024 // 更新

对应的汇编结果如下:go.godbolt.org/z/rPz6fjd4W

map 有很多种类的赋值语句:

func mapassign(mapType *byte, hmap map[any]any, key *any) (val *any)​
func mapassign_fast32(mapType *byte, hmap map[any]any, key any) (val *any)​
func mapassign_fast32ptr(mapType *byte, hmap map[any]any, key any) (val *any)​
func mapassign_fast64(mapType *byte, hmap map[any]any, key any) (val *any)​
func mapassign_fast64ptr(mapType *byte, hmap map[any]any, key any) (val *any)​
func mapassign_faststr(mapType *byte, hmap map[any]any, key any) (val *any)​

由于其原理是类似的,我们以mapassign为例进行分析:

mapassign

函数整体流程:

  • map优先检查是否有相同的key,如果有,则表示是更新操作;
  • 如果没有相同的key,则表示是插入操作

    • 如果有空位,则在第一个空位处插入;
    • 如果没有空位,则增加一个溢出bucket,在溢出bucket中插入;
    • 插入操作可能会触发扩容操作;
  • map不是一次性完成扩容的,而是逐步完成扩容的;
  • map库容分为等量迁移和加倍扩容:

    • 等量迁移是为了让稀疏数据分布紧凑(由于删除操作,map可能会很稀疏);
    • 加倍扩容是由于数据过多,申请一个加倍空间来存储kv,加倍扩容同样会删除空槽位,让数据分布紧凑。

函数源码如下所示:

代码位置:src/runtime/map.go


func mapassign(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {
   if h == nil {
      panic(plainError("assignment to entry in nil map"))
   }
   if h.flags&hashWriting != 0 {// 并发读写检测
      throw("concurrent map writes")
   }
   // 计算hash值
   hash := t.hasher(key, uintptr(h.hash0))
   // map状态设置为hashWriting
   h.flags ^= hashWriting
   if h.buckets == nil {
   // 如果map没有初始化bucket,申请bucket空间
      h.buckets = newobject(t.bucket) // newarray(t.bucket, 1)
   }

again:
    // 根据hash的低B位确定在哪个桶
   bucket := hash & bucketMask(h.B)
   // 判断是否在扩容
   if h.growing() {
   // 将对应的bucket从hmap.oldbuckets迁移到新的buckets中
      growWork(t, h, bucket)
   }
   // 目标bucket首地址
   b := (*bmap)(add(h.buckets, bucket*uintptr(t.bucketsize)))
   // hash高八位
   top := tophash(hash)

   var inserti *uint8
   var insertk unsafe.Pointer
   var elem unsafe.Pointer
bucketloop:
   for {
   // 遍历tophash查找key是否已经存在,或者是否有空位插入kv
      for i := uintptr(0); i < bucketCnt; i++ {
         if b.tophash[i] != top {
         // tophash中可能有多个空位,我们记录第一个空位的索引
            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))
            }
            // 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))
         }
         // 判断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是满的,而且没找到对应的key,溢出查找
      ovf := b.overflow(t)
      if ovf == nil {
         break
      }
      b = ovf
   }
   // 若无此key,此次操作是插入,此时需要判断map是否需要扩容;
   if !h.growing() && (overLoadFactor(h.count+1, h.B) || tooManyOverflowBuckets(h.noverflow, h.B)) {
      //  hashGrow函数主要是设置hmap.flags为扩容状态,申请新的内存空间用来扩容,同时设置hmap.oldbuckets为原来的hmap.buckets
      hashGrow(t, h)
      goto again // Growing the table invalidates everything, so try again
   }
   // inserti == nil表示没有插入的槽位,需要申请溢出bucket
   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))
   }
   // store new key/elem at insert position
   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:
// 设置flag并写入value
   if h.flags&hashWriting == 0 {
      throw("concurrent map writes")
   }
   h.flags &^= hashWriting
   if t.indirectelem() {
      elem = *((*unsafe.Pointer)(elem))
   }
   return elem
}

触发扩容的情况主要包括两种情况:

  • overLoadFactor函数用来判断map是否由于数据太多,需要增量1倍扩容​

  • tooManyOverflowBuckets函数用来判断map是否需要等量迁移,map由于删除操作,溢出bucket很多,但是数据分布很稀疏,我们可以通过等量迁移,将数据更加紧凑的存储在一起,节约空间

// 装载因子超过6.5
func overLoadFactor(count int, B uint8) bool {
  return count > bucketCnt && uintptr(count) > loadFactorNum*(bucketShift(B)/loadFactorDen)
}
// overflow buckets太多
func tooManyOverflowBuckets(noverflow uint16, B uint8) bool {
  if B > 15 {
    B = 15
  }
  return noverflow >= uint16(1)<<(B&15)
}

再来看看真正执行搬迁工作的 growWork函数:

func growWork(t *maptype, h *hmap, bucket uintptr) {
   // 这里将当前 bucketIndex 对应的oldBuckets进行迁移
   evacuate(t, h, bucket&h.oldbucketmask())
   // evacuate one more oldbucket to make progress on growing
   if h.growing() {
   // 如果还没结束迁移,则将在oldBuckets中h.nevacuate的对应bucket位置进行迁移
      evacuate(t, h, h.nevacuate)
   }
}

evacuate函数源码如下:

func evacuate(t *maptype, h *hmap, oldbucket uintptr) {
  // 定位旧bucket地址
  b := (*bmap)(add(h.oldbuckets, oldbucket*uintptr(t.bucketsize)))
  // 计算容量结果是 2^B
  newbit := h.noldbuckets()
  // 如果b没有被搬迁过
  if !evacuated(b) {
    // 默认是等size扩容,前后bucket序号不变
    var xy [2]evacDst
    // 通过x来进行搬迁
    x := &xy[0]
    x.b = (*bmap)(add(h.buckets, oldbucket*uintptr(t.bucketsize)))
    x.k = add(unsafe.Pointer(x.b), dataOffset)
    x.v = add(x.k, bucketCnt*uintptr(t.keysize))

    // 如果不是等size扩容,前后bucket序号有变
    if !h.sameSizeGrow() {
      // 通过y来进行搬迁
      y := &xy[1]
      // y 代表的bucket序号增加了2^B
      y.b = (*bmap)(add(h.buckets, (oldbucket+newbit)*uintptr(t.bucketsize)))
      y.k = add(unsafe.Pointer(y.b), dataOffset)
      y.v = add(y.k, bucketCnt*uintptr(t.keysize))
    }
    // 遍历bucket,包括overflow buckets,b是旧bucket地址
    for ; b != nil; b = b.overflow(t) {
      k := add(unsafe.Pointer(b), dataOffset)
      v := add(k, bucketCnt*uintptr(t.keysize))
      // 遍历bucket中的所有cell
      for i := 0; i < bucketCnt; i, k, v = i+1, add(k, uintptr(t.keysize)), add(v, uintptr(t.valuesize)) {
        // 当前cell的top hash值
        top := b.tophash[i]
        // 如果cell为空,即没有key
        if top == empty {
          // 那就标志它被"搬迁"过
          b.tophash[i] = evacuatedEmpty
          continue
        }
        // 未被搬迁的cell只可能是empty 或是正常的top hash(大于 minTopHash)
        if top < minTopHash {
          throw("bad map state")
        }
        // 如果key是指针,则解引用
        k2 := k
        if t.indirectkey {
          k2 = *((*unsafe.Pointer)(k2))
        }
        var useY uint8
        // 如果不是等量扩容
        if !h.sameSizeGrow() {
          // 计算hash值,和key第一次写入时一样
          hash := t.key.alg.hash(k2, uintptr(h.hash0))
          // 如果有协程正在遍历map如果出现相同的key值,算出来的hash值不同
          if h.flags&iterator != 0 && !t.reflexivekey && !t.key.alg.equal(k2, k2) {
            // useY =1 使用位置Y
            useY = top & 1
            top = tophash(hash)
          } else {
            // 第B位不是0
            if hash&newbit != 0 {
              // 使用位置Y
              useY = 1
            }
          }
        }
        if evacuatedX+1 != evacuatedY {
          throw("bad evacuatedN")
        }
        // 决定key迁移到X还是Y
        b.tophash[i] = evacuatedX + useY // evacuatedX + 1 == evacuatedY
        dst := &xy[useY]                 // evacuation destination
        // 如果xi等于8,说明要溢出了
        if dst.i == bucketCnt {
          // 新建一个bucket
          dst.b = h.newoverflow(t, dst.b)
          // xi从0开始计数
          dst.i = 0
          // key移动的位置
          dst.k = add(unsafe.Pointer(dst.b), dataOffset)
          // value移动的位置
          dst.v = add(dst.k, bucketCnt*uintptr(t.keysize))
        }
        // 设置 top hash 值
        dst.b.tophash[dst.i&(bucketCnt-1)] = top // mask dst.i as an optimization, to avoid a bounds check
        // key是指针
        if t.indirectkey {
          // 将原key(是指针)复制到新位置
          *(*unsafe.Pointer)(dst.k) = k2 // copy pointer
        } else {
          // 将原key(是值)复制到新位置
          typedmemmove(t.key, dst.k, k) // copy value
        }
        // value同上
        if t.indirectvalue {
          *(*unsafe.Pointer)(dst.v) = *(*unsafe.Pointer)(v)
        } else {
          typedmemmove(t.elem, dst.v, v)
        }
        // 定位到下一个cell
        dst.i++
        dst.k = add(dst.k, uintptr(t.keysize))
        dst.v = add(dst.v, uintptr(t.valuesize))
      }
    }
    // bucket搬迁完毕如果没有协程在使用老的buckets,就把老buckets清除
    if h.flags&oldIterator == 0 && t.bucket.kind&kindNoPointers == 0 {
      b := add(h.oldbuckets, oldbucket*uintptr(t.bucketsize))
      ptr := add(b, dataOffset)
      n := uintptr(t.bucketsize) - dataOffset
      memclrHasPointers(ptr, n)
    }
  }
  // 更新搬迁进度
  if oldbucket == h.nevacuate {
    advanceEvacuationMark(h, t, newbit)
  }
}

Map中数据的删除

Go语言中删除map中数据操作如下:

delete(m, "name")

对应编译结果如下:go.godbolt.org/z/3KPzEhvMG

由编译结果,底层调用的函数为mapdelete,接下来对其进行详细介绍:

mapdelete

mapdelete函数的流程如下:

  • 首先会检查 h.flags 标志,如果发现写标位是 1,直接 panic
  • 计算 key 的哈希,找到对应的bucket,如果正在扩容中,触发一次搬迁操作;
  • 查找key操作同样是两层循环,核心还是找到key的具体位置。寻找过程都是类似的,首先查找bucket,之后查找具体的位置。
  • 找到对应位置后,对key或者value进行“清除”操作。

详细代码如下:

func mapdelete(t *maptype, h *hmap, key unsafe.Pointer) {
    ...
    if h == nil || h.count == 0 { // 如果hmap为nil或者为空
        if t.hashMightPanic() {
            t.hasher(key, 0) // see issue 23734
        }
        return
    }
    if h.flags&hashWriting != 0 { // 如果正在进行写入,则throw
        throw("concurrent map writes")
    }
    hash := t.hasher(key, uintptr(h.hash0)) // 算出当前key的hash值
    h.flags ^= hashWriting // 将“写入位”置1
    bucket := hash & bucketMask(h.B) // 算出对应的 bucketIndex
    if h.growing() { 
        growWork(t, h, bucket)// 参考上文growwork函数的解析
    }
    b := (*bmap)(add(h.buckets, bucket*uintptr(t.bucketsize))) 
    // 寻找对应的bucket
    bOrig := b
    top := tophash(hash)
search:
    for ; b != nil; b = b.overflow(t) { // 遍历当前的bucket以及overflow
        for i := uintptr(0); i < bucketCnt; i++ {
            if b.tophash[i] != top {
                if b.tophash[i] == emptyRest {
                // 如果emptyReset,说明之后无数据,break
                    break search
                }
                continue
            }
            // 找到了对应的位置 
            k := add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize))
            k2 := k
            if t.indirectkey() {
                k2 = *((*unsafe.Pointer)(k2))
            }
            if !t.key.equal(key, k2) { // hash冲突
                continue
            }
            // 这里清理空间key的空间
            if t.indirectkey() {
                *(*unsafe.Pointer)(k) = nil
            } else if t.key.ptrdata != 0 { 
                memclrHasPointers(k, t.key.size) 
            }
            //  elem与上面key同理
            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 // emptyOne表示曾经有过,然后被清空了
        
            // 判断当前位置之后是否还有数据存储过
            if i == bucketCnt-1 {
                if b.overflow(t) != nil && b.overflow(t).tophash[0] != emptyRest {
                    goto notLast
                }
            } else {
                if b.tophash[i+1] != emptyRest {
                    goto notLast
                }
            }
            // 到这里说明当前的bucket的topIndex 以及之后索引包括overflow都没有过数据,准备从后向前进行整理
            for { 
                b.tophash[i] = emptyRest // 则将当前的top设置为emptyRest
                if i == 0 {// 由于是i==0 ,则要寻找到上一个bucket
                    if b == bOrig {
                        break // beginning of initial bucket, we're done.
                    }
                    c := b
                    for b = bOrig; b.overflow(t) != c; b = b.overflow(t) {//找到当前 bucket 的上一个 bucket
                    }
                    i = bucketCnt - 1
                } else {// 寻找上一个top
                    i--
                }
                if b.tophash[i] != emptyOne {
                    break
                }
            }
        notLast:
            h.count--
            break search
        }
    }
    if h.flags&hashWriting == 0 {
        throw("concurrent map writes")
    }
    h.flags &^= hashWriting // 将“写入位”置0
}


func mapclear(t *maptype, h *hmap) {
    ...

    if h == nil || h.count == 0 {
        return
    }

    if h.flags&hashWriting != 0 {
        throw("concurrent map writes")
    }

    h.flags ^= hashWriting // "写入位"置1
    h.flags &^= sameSizeGrow // 将 sameSizeGrow 清0
    h.oldbuckets = nil
    h.nevacuate = 0
    h.noverflow = 0
    h.count = 0// Keep the mapextra allocation but clear any extra information.if h.extra != nil { // 这里直接数据清空
        *h.extra = mapextra{}
    }

    _, nextOverflow := makeBucketArray(t, h.B, h.buckets)// 将其中buckets数据清空,并拿到nextOverFlow
    if nextOverflow != nil {
        h.extra.nextOverflow = nextOverflow // 重新更新h.extra.nextOverflow
    }

    if h.flags&hashWriting == 0 {
        throw("concurrent map writes")
    }
    h.flags &^= hashWriting // 将“写入位”置0
}

Map的遍历操作

Go语言中map的遍历操作如下所示:

for k, v := range m {
    ……
}

对应的汇编结果如下:go.godbolt.org/z/3dM69W1cG

由编译结果,底层调用的函数为mapiterinit,接下来对其进行详细介绍:

mapiterinit

首先,我们知道golang中的map是无序的,但是有没有想过为什么golang中的map是无序的?

map无序主要是有两个原因:

  • 首先是map的迁移;在遍历中,首先遍历bucket,之后遍历key,但是由于map的迁移,key的位置有很大改变;
  • 如果不发生迁移,是不是遍历就是有序的呢? golang为了保证遍历的无序性,每次都是从一个随机值序号的 bucket 开始遍历,并且是从这个 bucket 的一个随机序号的 cell 开始遍历;

mapiterinit核心部分代码如下:

func mapiterinit(t *maptype, h *hmap, it *hiter) {
  ...
  it.t = t
  it.h = h
  it.B = h.B
  it.buckets = h.buckets
  if t.bucket.kind&kindNoPointers != 0 {
    h.createOverflow()
    it.overflow = h.extra.overflow
    it.oldoverflow = h.extra.oldoverflow
  }
  r := uintptr(fastrand())// 生成随机数
  if h.B > 31-bucketCntBits {
    r += uintptr(fastrand()) << 31
  }
  it.startBucket = r & bucketMask(h.B)// 找到随机开始的bucketIndex
  it.offset = uint8(r >> h.B & (bucketCnt - 1))// 找到随机开始的bucket的topHash索引
  it.bucket = it.startBucket
  ...
  mapiternext(it)
}

Map的并发操作

上面我们说了map是不支持并发读写的,如果想要进行并发操作可以参考以下方法:

  • 解决方法1:读写锁sync.RWMutex
type TestMap struct {
  M    map[int]string
  Lock sync.RWMutex
}

func main() {
  testMap := TestMap{}
  testMap.M = map[int]string{1: "lili"}
  go func() {
    i := 0
    for i < 10000 {
      testMap.Lock.RLock()
      fmt.Println(i, testMap.M[1])
      testMap.Lock.RUnlock()
      i++
    }
  }()

  go func() {
    i := 0
    for i < 10000 {
      testMap.Lock.Lock()
      testMap.M[1] = "lily"
      testMap.Lock.Unlock()
      i++
    }
  }()
    for {
    runtime.GC()
  }
}
  • 解决方法2:使用golang提供的sync.Map
func main() {
  m := sync.Map{}
  m.Store(1, 1)
  i := 0
  go func() {
    for i < 1000 {
      m.Store(1, 1)
      i++
    }
  }()
  go func() {
    for i < 1000 {
      m.Store(2, 2)
      i++
    }
  }()
  go func() {
    for i < 1000 {
      fmt.Println(m.Load(1))
      i++
    }
  }()
  for {
    runtime.GC()
  }
}

以上,我们完成了对于golang中map的介绍,接下来,我们将介绍Java****中map的设计,看看有哪些不同

Java中map的设计

HashMap内部结构

Java中Map同样是采用数组+链表的结构,但细节上有明显不同。数组(Entry[] table)的长度为capacity,Entry<key,Value>的数目被称为HashMap的大小(size),结构如下图所示:

java_map.png

基本工作流程

基本的工作流程同样分为两步:

  1. key的定位 :根据key的hashCode,可以直接定位到存储这个Entry<key,Value>的桶所在的位置,这个时间的复杂度为O(1)
  1. 具体key-value的定位 :在桶中查找对应的Entry<key,Value>对象节点,需要遍历这个桶的Entry<key,Value>链表,时间复杂度为O(n) ;

数组是内存中连续的存储单元,空间代价是很大的,但是随机存取的速度是Java集合中最快的。我们可以增加桶的数量,减少Entry<key,Value>链表长度,来提高读取数据的速度(空间换时间)。

但不能刚开始就给HashMap分配过多的桶,因为数组的创建代价很大,况且不能确定HashMap的实际使用空间,为解决这一个问题,HashMap采用动态分配桶数量的方法。

HashMap的扩容策略

如果HashMap 的大小(size)> HashMap的capacity*加载因子(经验值0.75)时容量扩充一倍;然后重新将原桶中的Entry<key,Value>进行分配。

源码解析

这里,我们同样对常见操作的源码进行讲解,主要原理是上文中提到的定位方式以及扩容策略,对Java源码不感兴趣的同学可以跳过。

Put

Put方法整体流程:

  • 获取hashcode:获取key的hashcode,根据此值确定要存放桶的索引;
  • 遍历:遍历桶中的Entry<key,Value>链表,查找对应key的对象是否已存在;

    • 若已存在,对数据进行更新
    • 若不存在,则创建一个新的Entry<key,Value>对象,然后添加到链表的头部
  • 阈值判断:当前的HashMap的大小(size)是否超过了阀值,若超过了阀值(threshold),则增大HashMap的容量(capacity),并且重新组织各个Entry<key,Value>排列。
public V put(K key, V value) {  
    // 1.如果key为null,那么将此value放置到table[0]
    if (key == null){
        return putForNullkey(value);
    }
    // 2.重新计算hashcode
    int hash = hash(key.hashCode());
    // 3.获取桶的索引
    int i = indexFor(hash, table.length);
    // 4.循环遍历Entry列表
    for (Entry<K,V> e = table[i]; e != null; e = e.next) {
        Object k;
        // 5.若已存在,则将Value值更新
        if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
            V oldValue = e.value;
            e.value = value;
            e.recordAccess(this);
            return oldValue;
        }
    }
    modCount++;
    // 6.若不存在,创建新的Entry<key,Value>对象,添加到链表头部。
    addEntry(hash, key, value, i);
    return null;
}

HashMap的大小大于阈值时,HashMap的扩充算法:

HashMap的大小大于阀值时,HashMap会对此HashMap的容量进行扩充:

  • 容量的大小应当是 2的N次幂;
  • 当容量大小超过阀值时,容量扩充为当前的一倍;
  • 这里容量扩充的操作可以分为以下几个步骤:
    • 申请一个大小为当前容量两倍的数组;
    • 将旧数组的Entry[] table中的链表重新计算hash值,然后均匀地分配到新的扩充数组;
    • 释放旧数组;

由于容量扩充的代价非常大,所以每次扩充的比例为当前的一倍,这样可以减少扩充次数。

为了提高HashMap的性能:

  • 使用HashMap时,若容量已知,则应该在创建HashMap的时候指定它的容量;
  • 如果你确定HashMap使用的过程中,size会非常大,那么你应该控制好加载因子的大小,尽量将它设置得大些,避免Entry[] table过大。
void resize(int newCapacity) {
    Entry[] oldTable = table;
    int oldCapacity = oldTable.length;
    if (oldCapacity == MAXIMUM_CAPACITY) {
        threshold = Integer.MAX_VALUE;
        return;
    }

    Entry[] newTable = new Entry[newCapacity];
    transfer(newTable);
    table = newTable;
    threshold = (int)(newCapacity * loadFactor);
}

void transfer(Entry[] newTable) {
    Entry[] src = table;
    int newCapacity = newTable.length;
    for (int j = 0; j < src.length; j++) {
        Entry<K,V> e = src[j];
        if (e != null) {
            src[j] = null;
            do {
                Entry<K,V> next = e.next;
                int i = indexFor(e.hash, newCapacity);
                e.next = newTable[i];
                newTable[i] = e;
                e = next;
            } while (e != null);
        }
    }
}

Get

Get方法整体流程:

  • 获取这个key的hashcode值,根据此hashcode值确定桶的索引;
  • 遍历链表,查找Entry<key,Value>对;

    • 若已存在,定位到对应的Entry<key,Value>,返回value;
    • 若不存在,返回null;
public V get(Object key) {
    if (key == null)
        return getForNullkey();
    int hash = hash(key.hashCode());
    // 遍历列表
    for (Entry<K,V> e = table[indexFor(hash, table.length)];
         e != null;
         e = e.next) {
        Object k;
        if (e.hash == hash && ((k = e.key) == key || key.equals(k)))
            return e.value;
    }
    return null;
}

HashMap允许key以null的形式存取,Hashmap会将key为null组成的Entry<null,Value>放置到table[0],即第一个桶中,在put()和get()操作时,会先对key 为null的值特殊处理:

private V getForNullkey() {
    for (Entry<K,V> e = table[0]; e != null; e = e.next) {
        if (e.key == null)
            return e.value;
    }
    return null;
}
private V putForNullkey(V value) {
    for (Entry<K,V> e = table[0]; e != null; e = e.next) {
        if (e.key == null) {
            V oldValue = e.value;
            e.value = value;
            e.recordAccess(this);
            return oldValue;
        }
    }
    modCount++;
    addEntry(0, null, value, 0);
    return null;
}

Remove

移除键值对的操作的流程分为两步:

  1. 定位对象;
  1. 完成链表中节点的删除;移除键值对的操作的流程分为两步:

  2. 定位对象;

  1. 完成链表中节点的删除;
public V remove(Object key) {
    Entry<K,V> e = removeEntryForkey(key);
    return (e == null ? null : e.value);
}

final Entry<K,V> removeEntryForkey(Object key) {
    int hash = (key == null) ? 0 : hash(key.hashCode());
    int i = indexFor(hash, table.length);
    Entry<K,V> prev = table[i];
    Entry<K,V> e = prev;

    while (e != null) {
        Entry<K,V> next = e.next;
        Object k;
        if (e.hash == hash &&
            ((k = e.key) == key || (key != null && key.equals(k)))) {
            modCount++;
            size--;
            if (prev == e)
                table[i] = next;
            else
                prev.next = next;
            e.recordRemoval(this);
            return e;
        }
        prev = e;
        e = next;
    }

    return e;
}

总结

  • HashMap是线程不安全的,如果想使用线程安全的,可以使用ConcurrentHashMap;
  • HashMap的查找效率很高,可直接定位到key值所在的桶;
  • 使用HashMap时,要注意HashMap容量和加载因子的关系,这将直接影响到HashMap的性能问题;

    • 加载因子过小,会提高HashMap的查找效率,但消耗大量的内存空间;
    • 加载因子过大,节省了空间,但是会降低查找效率;

总结

以上,我们完成了对map数据结构,map中key的定位数据的读取新增更新删除以及遍历操作,并在最后介绍了golang中map的并发操作以及与java****中map设计的对比,篇幅较长,能看到这里真的很不容易。

map的结构上围绕着数组链表展开,流程上围绕着key的定位扩容迁移等操作展开,个人认为核心部分主要在于key的定位以及扩容思想