阅读 1089

深度解析golang map

基础知识

map的概念

map 的直观意思是映射,是<key, value> 对组成的抽象结构,且 key 不会重复,所有的操作也都是key/value的。map的底层实现一般有两种:

  1. 搜索树(search tree),天然有序,平均/最差的写入/查找复杂度是O(logN)
  2. 哈希表(hash table),无序存储,平均的写入/查找复杂度是O(1),最差是O(N)

在go语言中,map的底层实现是hash表。本文也将主要介绍go中是如何改进/优化hashtable的。

对于最朴素的hashtable,就是由数组+哈希函数+解决哈希冲突构成的。哈希函数,简单的讲就是将key(各种类型)转换为数组下标,从而通过array[hash[key]]实现O(1)的查找效率。哈希函数的实现有很多,其目的都是让哈希结果能够尽可能的均匀分布在0~length(array)间。一个完美的哈希函数就是将key和index一一对应;一个极坏的哈希函数就是将所有的key都映射到一个index上。

对于不同key映射到同一个index,就是所谓的hash冲突。常见的hash冲突的解决方式也有两种,分别是:

  1. 开放定址法。也称再散列,意思就是如果出现了hash冲突,那么再次进行hash,直到无冲突为止,放入对应存储。

开放寻址法中对性能影响最大的是装载因子,它是数组中元素的数量与数组大小的比值。随着装载因子的增加,线性探测的平均用时就会逐渐增加,这会影响哈希表的读写性能。当装载率超过 70% 之后,哈希表的性能就会急剧下降,而一旦装载率达到 100%,整个哈希表就会完全失效,这时查找和插入任意元素的时间复杂度都是 𝑂(𝑛)的,这时需要遍历数组中的全部元素,所以在实现哈希表时一定要关注装载因子的变化。

  1. 链地址法。意思就是将hash地址相同的key用链表形式串到一起。在查找时也要去遍历这个链来定位真正的key。而这也是go语言中map的冲突解决办法。

go map的基本操作

在go语言中对map有以下几种操作

1、声明

var m  map[T]T
复制代码

注意,此时的map为nil,对nil的map可以安全的进行读取/删除,但对nil的map写入时会导致panic。

package main



func main() {

    var a map[int64]bool

    delete(a, 10)

    _ = a

    a[10] = false

}



go run main.go



panic: assignment to entry in nil map//具体原因详见源码解析部分
复制代码

2、初始化

var m = make(map[T]T,len)

var m = map[T]T{..}
复制代码

3、新增/更新,直接赋值即可

m[key]=value
复制代码

4、查询

golang map 拥有两种 get操作,分别是带comma和不带comma,如下

 v := m[key]

 v, ok := m[key]
复制代码

对于第一种方式,未set的ket-value将得到零值;对于第二种方式,未set的ket-value,其ok=false。

5、delete

delete(m,key)
复制代码

可以对相同的key进行多次的删除操作,而不会报错

与其他 map/hashtable 的对比

1、Java map 底层是hash表+链表(拉链法)

其特色是当链表长度超过阈值(8)会转为红黑树,避免O(n)的遍历

2、C++的map是红黑树实现

其unordered_map是hashtable实现的

3、php5中的数组也是基础的hashtable

在7版本的时候,其优化成内存连续的hashtable

4、redis的hash就是一个普通hashtable,其特色在于遍历时采用了高位迭代的顺序,以避免scan时出现重复元素。但是,缩容的话依旧有可能出现重复元素。

深入源码

基于golang 1.15.3,map相关的源码都在runtime/map{xxx}.go中

在线访问: golang/go at go1.15.3 (github.com)

内存模型

runtime.hmap是map的头信息,数据结构如下

// A header for a Go map.

type hmap struct {

   // Note: the format of the hmap is also encoded in cmd/compile/internal/gc/reflect.go.

   // Make sure this stays in sync with the compiler's definition.

   count     int // # live cells == size of map.  Must be first (used by len() builtin) map中kv的个数

   flags     uint8 //标记位

   B         uint8  // log_2 of # of buckets (can hold up to loadFactor * 2^B items) bucket的个数为2^B个

   noverflow uint16 // approximate number of overflow buckets; see incrnoverflow for details 现在正在使用的溢出桶的个数

   hash0     uint32 // hash seed hash种子,随机数,在make map时进行设置,会参与到key 的hash值计算中来,这样做可以使hash结果更离散一些

   buckets    unsafe.Pointer // array of 2^B Buckets. may be nil if count==0.指向当前正在使用的bucket数组

   oldbuckets unsafe.Pointer // previous bucket array of half the size, non-nil only when growing golang map的扩容方式是渐进式扩容,oldbuckets指向的是老的bucket数组

   nevacuate  uintptr        // progress counter for evacuation (buckets less than this have been evacuated) 同上,在渐进式扩容中会用到,标识扩容进度



   extra *mapextra // optional fields 可选字段

}



// mapextra holds fields that are not present on all maps.

type mapextra struct {

   // If both key and elem do not contain pointers and are inline, then we mark bucket

   // type as containing no pointers. This avoids scanning such maps.

   // However, bmap.overflow is a pointer. In order to keep overflow buckets

   // alive, we store pointers to all overflow buckets in hmap.extra.overflow and hmap.extra.oldoverflow.

   // overflow and oldoverflow are only used if key and elem do not contain pointers.

      // 翻译一下,当k/v都不是指针的情况下,在gc时可以避免扫描整个map,但是bmap的最后一个字段是uintptr类型,为了保证gc效率同时对溢出桶进行保活,所以将overflow写入到下边这俩个数组里。

   // overflow contains overflow buckets for hmap.buckets.

   // oldoverflow contains overflow buckets for hmap.oldbuckets.

   // The indirection allows to store a pointer to the slice in hiter.



   overflow    *[]*bmap //当前桶所用到的溢出桶,map在初始化时会分配一定的溢出桶,在地址上与常规桶连续

   oldoverflow *[]*bmap //老桶所用到的溢出桶



   // nextOverflow holds a pointer to a free overflow bucket.

   nextOverflow *bmap //下一个可用的溢出桶

}
复制代码
  1. hmap.count 当前map中kv的个数,在插入时会自增,在删除时会自减。
  2. hmap.B = log2(bucket数量)。hmap.B的类型为uint8,最大值为255,但golang中bucket的最大限制为pow(2,64)-1,具体源码如下,在使用时会对b做64位防溢出处理
// bucketShift returns 1<<b, optimized for code generation.

func bucketShift(b uint8) uintptr {

   // Masking the shift amount allows overflow checks to be elided.

   return uintptr(1) << (b & (sys.PtrSize*8 - 1))

}



//在写入/读取等操作时都会用bucketMask获取bucket的数量

// bucketMask returns 1<<b - 1, optimized for code generation.

func bucketMask(b uint8) uintptr {

   return bucketShift(b) - 1

}
复制代码

在map中对key的定位会先使用hash函数获取hash值,写入位置 index = hash%N,得到真正的写入位置。但是在golang中对index的定位有一点小优化,golang使用位与操作进行bucket的定位:

index = hash & (N-1) 为了保证key的足够散列,所以要保证N是2的整幂次,所以在上述的hmap结构中 hmap.B 指的就是这个整数次幂。

取模操作相对于位与操作,cpu的开销更大,可以简单的验证一下,go代码如下

package main



import "fmt"



func main() {

    T(1000, 0b11)

}



func T(a, b int) (int, int) {

    c := a & b

    d := a % b

    fmt.Println(c,d)

    return c, d

}
复制代码

将上述的go代码使用go tool compile -S 编译成plan9汇编代码,如下

见下图,main.go:10 是位与操作,main.go:11 是取模操作,可以看到位与操作需要的汇编指令更少

  1. hmap.flags是map当前的状态,也是经典的二进制位存储,对应位的枚举值分别有以下几种:
// flags

iterator = 1 // there may be an iterator using buckets 有迭代器正在使用bucket

oldIterator = 2 // there may be an iterator using oldbuckets 有迭代器正在使用old bucket

hashWriting = 4 // a goroutine is writing to the map map正在被写入,并发读写导致的panic就是去check这个标记位

sameSizeGrow = 8 // the current map growth is to a new map of the same size  当前map正在进行等量扩容
复制代码
  1. hmap.bucket和 hmap.oldbucket 实际指向的是桶切片[]bmap,存储kv的地方。

其中 bmap的数据结构在runtime中如下

// A bucket for a Go map.

type bmap struct {

   // tophash generally contains the top byte of the hash value

   // for each key in this bucket. If tophash[0] < minTopHash,

   // tophash[0] is a bucket evacuation state instead.

   tophash [bucketCnt]uint8

   // Followed by bucketCnt keys and then bucketCnt elems.

   // NOTE: packing all the keys together and then all the elems together makes the

   // code a bit more complicated than alternating key/elem/key/elem/... but it allows

   // us to eliminate padding which would be needed for, e.g., map[int64]int8.

   // Followed by an overflow pointer.

}

const (

   // Maximum number of key/elem pairs a bucket can hold.

   bucketCntBits = 3

   bucketCnt = 1 << bucketCntBits

)

//其中bucketCnt在源码中是常量8,也就是每个bucket中有8个kv
复制代码

但是在编译时,编译器会对runtime.bmap再增加一些字段,实际的bmap是这样的

type bmap struct {

   topbits  [8]uint8

   keys     [8]keytype

   elems    [8]valuetype

//   pad      uintptr

   overflow uintptr //每个bmap最多存储8个kv对,为了减少因bmap满了而导致的扩容操作,如果这时有第9个kv,则需要使用溢出桶,溢出桶通过overflow字段进行连接,所以常规桶和溢出桶间是单链表结构,溢出桶数据结构与常规桶一致。

}
复制代码

runtime.bmap增加字段的源代码:cmd/compile/internal/gc/reflect.go,核心片段如下所示

// bmap makes the map bucket type given the type of the map.

func bmap(t *types.Type) *types.Type {

//some init code...



   field := make([]*types.Field, 0, 5)



   // The first field is: uint8 topbits[BUCKETSIZE].

   arr := types.NewArray(types.Types[TUINT8], BUCKETSIZE)

   field = append(field, makefield("topbits", arr))



   arr = types.NewArray(keytype, BUCKETSIZE)

   arr.SetNoalg(true)

   keys := makefield("keys", arr)

   field = append(field, keys)



   arr = types.NewArray(elemtype, BUCKETSIZE)

   arr.SetNoalg(true)

   elems := makefield("elems", arr)

   field = append(field, elems)



   // If keys and elems have no pointers, the map implementation

   // can keep a list of overflow pointers on the side so that

   // buckets can be marked as having no pointers.

   // Arrange for the bucket to have no pointers by changing

   // the type of the overflow field to uintptr in this case.

   // See comment on hmap.overflow in runtime/map.go.

   otyp := types.NewPtr(bucket)

   if !elemtype.HasPointers() && !keytype.HasPointers() {

      otyp = types.Types[TUINTPTR]

   }

   overflow := makefield("overflow", otyp)

   field = append(field, overflow)



   // link up fields

   bucket.SetNoalg(true)

   bucket.SetFields(field[:])

   dowidth(bucket)



// some check code...

}
复制代码

来看图示吧

image.png

map的内存布局

bmap内存布局

bmap中比较特殊的是kv的存储,bmap将8个key存在一起,将8个value存在一起,是key/key/....value/value...这种存储,相对于将key/value/key/value这种存储设计,将key和value分开存储能使bmap内存布局更加紧密,只需要在最后增加padding对齐即可。

可以计算下map[int64]int8 这种类型的map在上述两种存储设计中每个bmap分别占用的空间大小。

如果将kv存在一起,总共需要的每对kv后需要填充7字节的padding,总共的padding为7*8 = 56字节

但是如果将kv分开存储,8个key是对齐的,8个value也是对齐的,不需要额外的padding填充。

bmap.tophash 标识的是每个key的hash值前8位,这样在比较时可以先比较tophash,能减少一些比较的开销,同时tophash也会存储对应key的标记位,有以下几种枚举值

const (

        emptyRest = 0 // this cell is empty, and there are no more non-empty cells at higher indexes or overflows.//当前cell为空,且后续cell都为空,在遍历时会用到,加快map遍历速度

        emptyOne = 1 // this cell is empty //当前cell为空

        evacuatedX = 2 // key/elem is valid.  Entry has been evacuated to first half of larger table.翻倍扩容时会用到,指示cell迁移到新bucket的前半部分

        evacuatedY = 3 // same as above, but evacuated to second half of larger table. 翻倍扩容时会用到,指示cell迁移到新bucket的后半部分

        evacuatedEmpty = 4 // cell is empty, bucket is evacuated.//扩容时会用到。表示当前cell为空,且bucket已经迁移完成

        minTopHash = 5 // minimum tophash for a normal filled cell.正常的tophash的最小值

)

//👇为了不与正常的top hash冲突,在计算tophash时如果小于minTopHash也会加上对应的值

// tophash calculates the tophash value for hash.

func tophash(hash uintptr) uint8 {

   top := uint8(hash >> (sys.PtrSize*8 - 8))

   if top < minTopHash {

      top += minTopHash

 }

   return top

}

//判断某个bucket是否迁移完成,也是较简单的,只需要check tophash[0]的值即可

func evacuated(b *bmap) bool {

   h := b.tophash[0]

   return h > emptyOne && h < minTopHash

}
复制代码

bmap.keys的相对bmap的位置在源码中是常量dataOffset:

// data offset should be the size of the bmap struct, but needs to be

// aligned correctly. For amd64p32 this means 64-bit alignment

// even though pointers are 32 bit.

dataOffset = unsafe.Offsetof(struct {

   b bmap

   v int64

}{}.v)

//获取v相对于struct的偏移量,也就是获取bmap的keys相对于bmap的偏移量

func (b *bmap) keys() unsafe.Pointer {

   return add(unsafe.Pointer(b), dataOffset)

}
复制代码

哈希

hash函数的原型如下,它接收两个参数,分别是我们的key和初始化时的hash seed,返回值则是对应的hash值

func(unsafe.Pointer, uintptr) uintptr
复制代码

对计算出来的hash值,

  1. 前8位作为tophash存入bmap对应位置中,在查找时可以先进行tophash的对比,如果一致再进行key的对比,如果不一致则直接跳过,这样的好处是可以减少一些比较的开销。
  2. 后B位作为bucket index存入对应的bucket中。

image.png

hash值如何使用

至于hash函数的实现,各个类型都会有自己的hash函数实现,比如string类型的hash函数如下,是一个O(N)的函数,更详细的实现在src/runtime/alg.go中,感兴趣的可以自己去看一下~

// Testing adapters for hash quality tests (see hash_test.go)

func stringHash(s string, seed uintptr) uintptr {

   return strhash(noescape(unsafe.Pointer(&s)), seed)

}
复制代码

初始化

对map的make操作是go提供的语法糖,在编译时会转变为runtime.makemap



// makemap implements Go map creation for make(map[k]v, hint).

// If the compiler has determined that the map or the first bucket

// can be created on the stack, h and/or bucket may be non-nil.

// If h != nil, the map can be created directly in h.

// If h.buckets != nil, bucket pointed to can be used as the first bucket.

//hint就是我们make时的第二个参数,代表预分配的空间大小

//maptype 是golang类型系统涉及的数据结构,感觉可以再来一次类型系统的学习😸

func makemap(t *maptype, hint int, h *hmap) *hmap {



   

   //防溢出检测,如果有溢出,则将hint清0

   mem, overflow := math.MulUintptr(uintptr(hint), t.bucket.size)

   if overflow || mem > maxAlloc {

      hint = 0

   }



   // initialize Hmap

   if h == nil {

      h = new(hmap)

   }

   h.hash0 = fastrand()

   //fashrand获取随机数 内部实现与调度器相关,感觉可以来一次调度器的学习😁



   // Find the size parameter B which will hold the requested # of elements.

   // For hint < 0 overLoadFactor returns false since hint < bucketCnt.

   B := uint8(0)

   for overLoadFactor(hint, B) { //根据

      B++

   }

   h.B = B



   // allocate initial hash table

   // if B == 0, the buckets field is allocated lazily later (in mapassign)

   // If hint is large zeroing this memory could take a while.

   if h.B != 0 {

      var nextOverflow *bmap

      // 调用makeBucketArray函数生成常规桶和溢出桶,在map初始化时常规桶和溢出桶在内存上是连续的一块空间,只不过常规桶用了前边,溢出桶则用了后边

      h.buckets, nextOverflow = makeBucketArray(t, h.B, nil)

      // 如果初始化时生成了溢出桶,会放置到map的extra字段里去

      if nextOverflow != nil {

         h.extra = new(mapextra)

         h.extra.nextOverflow = nextOverflow

      }

   }

   return h

}



// 根据传入的 B 计算出的需要创建的桶数量,并在内存中分配一片连续的空间用于常规桶和溢出桶

func makeBucketArray(t *maptype, b uint8, dirtyalloc unsafe.Pointer) (buckets unsafe.Pointer, nextOverflow *bmap) { 

   base := bucketShift(b) 

   nbuckets := base 

   // For small b, overflow buckets are unlikely.Avoid the overhead of the calculation. 

   if b >= 4 { // bmap的数量大于了2^4,在初始化的时候才会生成溢出桶。溢出桶的大小为2^(b-4),b为常规桶的大小 

      nbuckets += bucketShift(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)) 

   } else { 

... 

   } 



  // preallocated some overflow buckets.

   if base != nbuckets { 

      nextOverflow = (*bmap)(add(buckets, base*uintptr(t.bucketsize))) 

      last := (*bmap)(add(buckets, (nbuckets-1)*uintptr(t.bucketsize))) 

      last.setoverflow(t, (*bmap)(buckets)) 

   } 

   return buckets, nextOverflow 

} 
复制代码

读取

读取有两种形式,即返回值有两种形式,但是Go语言没有函数重载的概念,这种动态返回值很显然是go提供的语法糖,在编译时会根据用户使用的返回值的个数,转换为runtime的对应函数,源码位置依然位与runtime/map.go中,对应的函数分别是

  • runtime.mapaccess1,对应的是一个返回值的形式

  • runtime.mapaccess2,对应的是两个返回值的形式

  • runtime.mapaccessk,对应的是同时返回kv的形式,在遍历map时会用到

这3个函数的逻辑基本是一致的,下面我们以runtime.mapccess1为例来分析一下map

的读取流程。

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

//这还有一些gc相关的代码块,我们暂时可以忽略



// mapaccess1 我们拆成两部分来看

   //1. 参数校验,数据准备

   

   //2. 数据查找

} 
复制代码
  1. 参数校验,数据准备
   if h == nil || h.count == 0 { //这就是上文中提到的,对nil的map做读取操作是安全的,会返回0值

       if t.hashMightPanic() {

          t.hasher(key, 0) // see issue 23734

       }

       return unsafe.Pointer(&zeroVal[0])

   }

   if h.flags&hashWriting != 0 { //并发读写标记位check,如果已设置写标记位,则打印错误信息,并退出进程

       throw("concurrent map read and map write")

   }

  // 通过hash函数计算当前key的哈希值,不同类型的key会有不同的hash函数

   hash := t.hasher(key, uintptr(h.hash0)) 

   m := bucketMask(h.B) // 计算出当前桶的个数,即(1<<B)-1

   

  //t.bucketsize为bmap的大小,通过对哈希值和buckets起始地址进行运算,获取哈希值对应的bmap地址

   b := (*bmap)(add(h.buckets, (hash&m)*uintptr(t.bucketsize))) 



  //当map的oldbuckets存在时,证明map正在处于扩容中,会先去check旧桶中的数据

   if c := h.oldbuckets; c != nil { 

      if !h.sameSizeGrow() { 

      // 如果oldbuckets不为nil且未设置sameSize标记位,证明map的扩容为翻倍扩容(具体在扩容一章会介绍到),老的bucket的数量为m>>1

      // There used to be half as many buckets; mask down one more power of two.

         m >>= 1 

      } 

      oldb := (*bmap)(add(c, (hash&m)*uintptr(t.bucketsize))) 

      if !evacuated(oldb) { //判断旧桶是否已被迁移

         b = oldb // 因为旧桶尚未迁移,所以需要在旧桶中查找数据

      } 

   } 



  // 获取当前key的哈希的高8位,和bmap中的topbits作比较,可以减轻直接比较key带来的开销

   top := tophash(hash) 
复制代码

其中上述用到的几个工具函数如下

//因为unsafe.Pointer是不支持加法运算的,所以需要将unsafe.Pointer进行强转

func add(p unsafe.Pointer, x uintptr) unsafe.Pointer {

   return unsafe.Pointer(uintptr(p) + x)

}



// bucketShift returns 1<<b, optimized for code generation.

func bucketShift(b uint8) uintptr {

   // Masking the shift amount allows overflow checks to be elided.

   return uintptr(1) << (b & (sys.PtrSize*8 - 1))

}



// bucketMask returns 1<<b - 1, optimized for code generation.

func bucketMask(b uint8) uintptr {

   return bucketShift(b) - 1

}



//判断入参bmap是否已完成迁移

func evacuated(b *bmap) bool {

   h := b.tophash[0]

   return h > emptyOne && h < minTopHash

}



// sameSizeGrow reports whether the current growth is to a map of the same size.

func (h *hmap) sameSizeGrow() bool {

   return h.flags&sameSizeGrow != 0

}



// tophash calculates the tophash value for hash.

func tophash(hash uintptr) uint8 {

   top := uint8(hash >> (sys.PtrSize*8 - 8))

   if top < minTopHash { 

   //上文中已提到,避免标记位与正常的hashvalue冲突,需要加上minTopHash

      top += minTopHash

 }

   return top

}
复制代码
  1. 数据查找

分为两层循环,分别是:

  • 第一层遍历对应hash所有的bucket

  • 第二层遍历bucket的8个cell

bucketloop: 

  // 遍历当前hash的常规桶及全部溢出桶

   for ; b != nil; b = b.overflow(t) { 

   // 遍历每一个桶的8个cell

      for i := uintptr(0); i < bucketCnt; i++ { 

      // 对比tophash 

         if b.tophash[i] != top { 

            if b.tophash[i] == emptyRest { 

            // emptyRest 标志位:表示当前cell及当前cell后续的cell及bucket都为空,可以退出整个大循环,查找失败

               break bucketloop 

            } 

            continue 

         } 

      

         k := add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize)) 

         //上述运算定位到当前bucket的第i个key的地址  

         //如果key的类型是指针,则需要进行解引用再进行下边的equal对比

         if t.indirectkey() { 

            k = *((*unsafe.Pointer)(k)) 

         } 

      //对比key是否一致,因为tophash也会存在冲突的情况,所以需要再次对比两个key是否一致

         if t.key.equal(key, k) { 

         //key是一致,则获取对应索引位置的值 

            e := add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.elemsize)) 

            if t.indirectelem() { 

            //如果value是指针,则需要对value进行解引用,拿到value真正的值

               e = *((*unsafe.Pointer)(e)) 

            } 

            return e 

         } 

      } 

   } 

   return unsafe.Pointer(&zeroVal[0]) 
复制代码

值得一提的是,上述流程中,可以看到在对比前有一个t.indirectkey(),这个我们在下文也能看到大量的t.indirectkey()和t.indirectelem(),在这里做一个解释,这个函数的作用是判断是否有对类型进行指针优化,如果有,则需要解引用才能拿到对应的key/value真实的值。map会对大于128B的key和elem进行优化,在bmap中并不会直接存储对应的数据,而是会存指针,这样做的好处是在map初始化时可以不至于一次性申请过大的内存,而是将大内存的申请分配到每一次的写入操作中。

写入

//通过函数原型可以看到,对map的写入操作其实是会返回对应value的地址,再通过返回的地址进行简介写入

func mapassign(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer { 

  // 赋值操作,map不能为nil,这也是上述对nil的map进行写操作会panic的源码

   if h == nil { 

      panic(plainError("assignment to entry in nil map")) 

   }

   ... 

   //暂时忽略gc和race操作

   

  // 检测是否写标记位已被设置,若已设置则进程直接crash 

   if h.flags&hashWriting != 0 { 

      throw("concurrent map writes") 

   } 



   hash := t.hasher(key, uintptr(h.hash0)) 



   // Set hashWriting after calling t.hasher, since t.hasher may panic, in which case we have not actually done a write. 

   h.flags ^= hashWriting // 设置写标记位



  // 如果当前正在使用的桶为nil,则新建一个 

   if h.buckets == nil { 

      h.buckets = newobject(t.bucket) // newarray(t.bucket, 1) 

   } 



again: 

  // 找到key对应的桶 

   bucket := hash & bucketMask(h.B) 

  // 如果map在扩容,则会触发一次搬迁操作

   if h.growing() { 

      growWork(t, h, bucket) 

   } 


   b := (*bmap)(unsafe.Pointer(uintptr(h.buckets) + bucket*uintptr(t.bucketsize))) 

   top := tophash(hash) 

   var inserti *uint8          //tophash的地址

   var insertk unsafe.Pointer  //key的地址

   var elem unsafe.Pointer     //elem的地址

bucketloop: 

   for { 

      for i := uintptr(0); i < bucketCnt; i++ { 

         if b.tophash[i] != top { 

        // 写入时会写入到第一个空cell

            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)) 

            } 

            //emptyRest 标志位:表示当前cell及当前cell后续的cell及bucket都为空,

            //可以退出整个大循环,查找失败

            if b.tophash[i] == emptyRest { 

               break bucketloop 

            } 

            continue 

         } 

         k := add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize)) 

         if t.indirectkey() {

            k = *((*unsafe.Pointer)(k)) 

         } 

         if !t.key.equal(key, k) { 

            continue 

         } 

         // already have a mapping for key. Update it. 

         if t.needkeyupdate() { 

            typedmemmove(t.key, k, key) 

         } 

         elem = add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.elemsize)) 

         goto done 

      } 

      ovf := b.overflow(t) // 当前bucket未找到,继续便利下一个溢出桶

      if ovf == nil { 

         break 

      } 

      b = ovf 

   } 



  // 插入新元素前,先判断Map是否需要扩容,扩容相关的细节我们在扩容一章再介绍

   // If we hit the max load factor or we have too many overflow buckets, 

   // and we're not already in the middle of growing, start growing. 

   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 

   } 



  // all current buckets are full, allocate a new one. 

  // 桶链上已无空cell,需要申请新的溢出桶

   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) 

   // 通过 runtime.typedmemmove 将键移动到对应的内存空间中并返回键对应值的地址 val 

   *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 

} 
复制代码

上述用到的一些工具函数如下:

// growing reports whether h is growing. The growth may be to the same size or bigger.判断map是否处于扩容中,方法也很简单,hmap.oldbucket不为nil则正在扩容

func (h *hmap) growing() bool {

   return h.oldbuckets != nil

}
复制代码

删除

删除逻辑中,比较特殊的就是:对于空位置会置为emptyOne标示位;对于后面都是空位置会置为emptyRest标示位。这个标示位也是用于在查找的时候进行continue和break。

func mapdelete(t *maptype, h *hmap, key unsafe.Pointer) { 

... 

   if h.flags&hashWriting != 0 { // 删除key也属于写操作,禁止并发 

      throw("concurrent map writes") 

   } 

  h.flags ^= hashWriting 

  if h.growing() { // 触发扩容 

      growWork(t, h, bucket) 

   } 



   hash := t.hasher(key, uintptr(h.hash0)) 

   bucket := hash & bucketMask(h.B) 

   b := (*bmap)(add(h.buckets, bucket*uintptr(t.bucketsize))) 

   bOrig := b 

   top := tophash(hash) 

search: 

   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 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) { 

            continue 

         } 

         // Only clear key if there are pointers in it.

         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

         // If the bucket now ends in a bunch of emptyOne states, 

         // change those to emptyRest states.

         // It would be nice to make this a separate function, but

         // for loops are not currently inlineable.

         // 如果发现当前元素的下一个元素的tophash值不是emptyRest,则直接退出

         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 

            } 

         } 

         // 否则,将当前元素的tophash设置为emptyRest,且向前check并设置emptyRest标识

         for { 

            b.tophash[i] = emptyRest 

            if i == 0 { 

               if b == bOrig { 

                  break // beginning of initial bucket, we're done. 

               } 

               // Find previous bucket, continue at its last entry. 

               c := b 

               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-- 

         break search 

      } 

   } 

   if h.flags&hashWriting == 0 { 

      throw("concurrent map writes") 

   } 

   h.flags &^= hashWriting 
} 
复制代码

扩容

golang map不会缩容。除非将map中有效内容拷贝到小容量的map,这样才可能释放掉老map占用的大量空间(ps:golang slice同理)

  1. map的扩容有两种方式,分别是:翻倍扩容和等量扩容

翻倍扩容

负载因子代表的是bmap的使用程度,当map中元素的个数大于8且负载因子大小超过6.5(81.25%),触发翻倍扩容,bucket的数量翻倍。因为每个bmap的容量为8,所以在loadFactor过大时,会导致查找性能急速下降,golang中将loadfactor的阈值设置为13/2=6.5。

const (

    loadFactorNum = 13

    loadFactorDen = 2

)

// overLoadFactor reports whether count items placed in 1<<B buckets is over loadFactor.

func overLoadFactor(count int, B uint8) bool {

   return count > bucketCnt && uintptr(count) > loadFactorNum*(bucketShift(B)/loadFactorDen)

}
复制代码

至于装载因子为什么选择6.5,以下是go源码中对不同装载因子的测试,其中有四个重要的指标

  • %overflow : hmap中拥有溢出桶的bucket数量

  • bytes/entry:平均每对key/elem使用的内存数量

  • hitprobe:查找一个存在的keys所需要的检查的kv数量

  • missprobe:查找一个不存在的key需要检查的kv数量

可以看到,当负载因子过大时会导致查找性能急速下降,但是负载因子太小时又会导致有大量内存被浪费,所以go team最终选择了6.5做负载因子。

// Picking loadFactor: too large and we have lots of overflow

// buckets, too small and we waste a lot of space. I wrote

// a simple program to check some stats for different loads:

// (64-bit, 8 byte keys and elems)

//  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   = percentage of buckets which have an overflow bucket

// bytes/entry = overhead bytes used per key/elem pair

// hitprobe    = # of entries to check when looking up a present key

// missprobe   = # of entries to check when looking up an absent key
复制代码

等量扩容

根据map的大小会做区分,如果有过多的溢出桶,则触发等量扩容,bucket的数量不变。因为如果溢出桶的数量过多,也会导致map的查找效率下降。所以触发等量扩容但可以减少溢出桶的使用且使bmap的排列更加紧密(github.com/golang/go/c…

等量扩容的触发条件有2个

  • 如果hmap.B<=15,一旦溢出桶的个数超过常规桶,则触发等量扩容

  • 如果hmap.B>15,只要溢出桶的个数超过pow(2,15),即触发等量扩容

// tooManyOverflowBuckets reports whether noverflow buckets is too many for a map with 1<<B buckets.

// Note that most of these overflow buckets must be in sparse use;

// if use was dense, then we'd have already triggered regular map growth.

func tooManyOverflowBuckets(noverflow uint16, B uint8) bool {

   // If the threshold is too low, we do extraneous work.

   // If the threshold is too high, maps that grow and shrink can hold on to lots of unused memory.

   // "too many" means (approximately) as many overflow buckets as regular buckets.

   // See incrnoverflow for more details.

   if B > 15 {

      B = 15

   }

   // The compiler doesn't see here that B < 16; mask B to generate shorter shift code.

   return noverflow >= uint16(1)<<(B&15)

}
复制代码
  1. 扩容前准备

在数据写入前会进行扩容条件的判断,如果当前hmap的状态符合上述的翻倍扩容或等量扩容,则触发扩容操作,注意golang map的扩容并不是一个原子过程,是渐进式的,会将扩容操作增量到每一次写操作中去,runtime.hashGrow就是扩容前的准备操作,会将map标记为扩容中状态。

渐进式扩容也是一种分治的思想,意思是golang不会一次完成整个map的迁移,而是会离散到每一次的写入操作,至于golang为什么选择渐进式扩容,好处也是很明显的,渐进式扩容可以降低全量扩容带来的瞬时计算/存储开销,而弊端也会有一些,因为渐进式扩容是要维护中间状态的,所以在使用上也会带来一定的额外成本,这个我们下文会介绍到

func hashGrow(t *maptype, h *hmap) { 

   // If we've hit the load factor, get bigger. Otherwise, there are too many overflow buckets, 

   // so keep the same number of buckets and "grow" laterally. 

   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) //清楚iterator标记位

   //这里指的一提的是 &^操作,这个操作符的意思是按位置0,通常用于标记位的清楚操作

   //x=01011001

   //y=01010111

   //z = x &^ y = 00001000

   //如果y对应的位为1,则z对应的位置0,z的其余位与x一致

   

   //这里的意思是如果hmap存在iterator标记位,因为已经触发了扩容,所以需要转移到oldIterator标志位

   if h.flags&iterator != 0 { 

      flags |= oldIterator 

   } 

   // commit the grow (atomic wrt gc) 

   h.B += bigger 

   h.flags = flags 

   h.oldbuckets = oldbuckets // 旧桶会存储到oldbuckets字段 

   h.buckets = newbuckets // buckets赋值为新桶 



  // 转移溢出桶 

   h.nevacuate = 0 

   h.noverflow = 0 

   if h.extra != nil && h.extra.overflow != nil { 

      // Promote current overflow buckets to the old generation. 

      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 

   } 

} 
复制代码
  1. bucket搬迁

扩容的时候并没有将旧桶数据转移到新桶。数据转移遵循了写时复制(copy on write)的规则。在真正赋值的时候,会选择是否需要进行数据转移。即写时的“if h.growing() {growWork(t, h, bucket)}”代码段,也可以发现,只会转移当前需要操作的旧桶。

先来几个图示

  • hash

因为我们的B是2的正整数次幂,所以翻倍扩容对hash值的影响是只需要向前多取一位即可

image.png

  • biggerSizeGrow(翻倍扩容)

image.png

  • sameSizeGrow(等量扩容)

image.png

再来看一下具体的源码

//执行桶的迁移,每次迁移桶的数量为1或2

func growWork(t *maptype, h *hmap, bucket uintptr) {

   // make sure we evacuate the oldbucket corresponding

   // to the bucket we're about to use

   // 迁移当前正在访问的旧桶

   evacuate(t, h, bucket&h.oldbucketmask())



   // evacuate one more oldbucket to make progress on growing

   // 为了加快搬迁进度,如果依旧处于扩容中,则再迁移一个桶

   if h.growing() {

      evacuate(t, h, h.nevacuate)

   }

}



// evacDst is an evacuation destination.

type evacDst struct {

    b *bmap          // current destination bucket

    i int            // key/elem index into b

    k unsafe.Pointer // pointer to current key storage

    e unsafe.Pointer // pointer to current elem storage

}



func evacuate(t *maptype, h *hmap, oldbucket uintptr) { 

   b := (*bmap)(add(h.oldbuckets, oldbucket*uintptr(t.bucketsize))) 

   newbit := h.noldbuckets()//旧桶的个数

   if !evacuated(b) { 

   // TODO: reuse overflow buckets instead of using new ones, if there

        // is no iterator using the old buckets.  (If !oldIterator.)

        

   //如果是翻倍扩容,一个旧桶会被迁移到两个新桶,分别位于新buckets的前半部分和后半部分。xy[0]指向前半部分,xy[1]指向后半部分

   // xy contains the x and y (low and high) evacuation destinations.

      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() {  // 如果是f是翻倍扩容,也需要用到ypart,计算ypart地址

         // y 代表的 bucket 序号增加了 2^oldB

         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)) 

      } 



      //以bucket index维度进行迁移,每一次迁移需要迁移对应index的常规桶和全部溢出桶

      for ; b != nil; b = b.overflow(t) { 

         k := add(unsafe.Pointer(b), dataOffset) 

         e := add(k, bucketCnt*uintptr(t.keysize)) 

         // 遍历 bucket 中的所有 cell

         for i := 0; i < bucketCnt; i, k, e = i+1, add(k, uintptr(t.keysize)), add(e, uintptr(t.elemsize)) { 

            top := b.tophash[i] 

    

            k2 := k 

            if t.indirectkey() {

               k2 = *((*unsafe.Pointer)(k2)) 

            } 

    

            var useY uint8 //如果是等量扩容则迁移到xpart。如果是翻倍扩容,则会根据高1位来决定迁移到xpart还是ypart

            if !h.sameSizeGrow() {

               hash := t.hasher(k2, uintptr(h.hash0)) 

               if h.flags&iterator != 0 && !t.reflexivekey() && !t.key.equal(k2, k2) {

               //这里的if条件只有key是math.NAN会进入,因为math.NAN!=math.NAN,我们暂时先忽略这里的特殊逻辑处理,后续详细介绍一下math.NAN

                        useY = top & 1

                        top = tophash(hash)

               } else { 

             // 取决于新哈希值的 oldB+1 位是 0 还是 1

                  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]                 // evacuation destination 



       // 如果新桶已满,则需要使用溢出桶

            if dst.i == bucketCnt { 

               dst.b = h.newoverflow(t, dst.b) // 新建一个 bucket 

               dst.i = 0 // xi 从 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 // mask dst.i as an optimization, to avoid a bounds check 

            if t.indirectkey() { // key 是指针 

               *(*unsafe.Pointer)(dst.k) = k2 // copy pointer 

            } else { // 将原 key(是值)复制到新位置 

               typedmemmove(t.key, dst.k, k) // copy elem 

            } 

            if t.indirectelem() { // value 是指针,操作同 key 

               *(*unsafe.Pointer)(dst.e) = *(*unsafe.Pointer)(e) 

            } else { 

               typedmemmove(t.elem, dst.e, e) 

            } 

 

           // 使用下一个 cell

            dst.i++ 

            dst.k = add(dst.k, uintptr(t.keysize)) 

            dst.e = add(dst.e, uintptr(t.elemsize)) 

         } 

      } 



      // Unlink the overflow buckets & clear key/elem to help GC.

        if h.flags&oldIterator == 0 && t.bucket.ptrdata != 0 {

            b := add(h.oldbuckets, oldbucket*uintptr(t.bucketsize))

            // Preserve b.tophash because the evacuation

            // state is maintained there.

            ptr := add(b, dataOffset)

            n := uintptr(t.bucketsize) - dataOffset

            memclrHasPointers(ptr, n)

        }

   } 



  // bucket迁移并不是按序迁移的,map在写入时会优先迁移当前正在写入的bucket,如果当前迁移的bucket是hmap中记录的待迁移的下一个bmap,则更新迁移进度。

   if oldbucket == h.nevacuate { 

      advanceEvacuationMark(h, t, newbit) 

   } 

} 



//记录迁移进度

func advanceEvacuationMark(h *hmap, t *maptype, newbit uintptr) {

    h.nevacuate++

    // Experiments suggest that 1024 is overkill by at least an order of magnitude.

    // Put it in there as a safeguard anyway, to ensure O(1) behavior.

    stop := h.nevacuate + 1024

    if stop > newbit {

        stop = newbit

    }

    for h.nevacuate != stop && bucketEvacuated(t, h, h.nevacuate) {

        h.nevacuate++

    }

    //全部迁移完成,需要将hmap.flags,hmap.oldbuckets,hmap.extra.oldoverflow清除掉

    if h.nevacuate == newbit { // newbit == # of oldbuckets

        // Growing is all done. Free old main bucket array.

        h.oldbuckets = nil

        // Can discard old overflow buckets as well.

        // If they are still referenced by an iterator,

        // then the iterator holds a pointers to the slice.

        if h.extra != nil {

            h.extra.oldoverflow = nil

        }

        h.flags &^= sameSizeGrow

    }

}
复制代码

遍历

先说结论:map的遍历过程会随机取开始的bucket和cell,之后依次遍历全部bucket和cell,因为map是渐进式扩容的,所以如果map处于扩容中且当前遍历到的bucket尚未迁移,就会去从oldbucket中遍历将要迁移加入到当前bucket的cell

注意:在遍历过程中去insert/delete一个kv对,结果是未定义的,会导致遍历出的结果集出现异常

对应到用户侧就是使用range去遍历map,range也是面向用户的,我们先来看一下range在运行时会调用哪些函数。

go源代码:

package main



func main() {



   //a := map[int]int{1: 1, 2: 2, 3: 3, 4: 4, 5: 5, 6: 6, 7: 7, 8: 8, 9: 9}

   //for k, v := range a {

   // fmt.Println(k, v)

   //delete(a, 3)

   //a[k+10] = v + 10

   //a[k+2_000000] = v + 2_000000

   //}

}



func Range(mp map[int64]int64) {

   var k, v int64

   for k, v = range mp {

      _, _ = k, v

   }

}
复制代码

使用go tool compile -N -S -l将上述代码编译成汇编代码,如下,其中核心的函数已用绿色标出

...

0x002f 00047 (main.go:15)  MOVQ   $0, "".k+32(SP)

0x0038 00056 (main.go:15)  MOVQ   $0, "".v+24(SP)

0x0041 00065 (main.go:16)  MOVQ   "".mp+160(SP), AX

0x0049 00073 (main.go:16)  MOVQ   AX, ""..autotmp_3+40(SP)

0x004e 00078 (main.go:16)  LEAQ   ""..autotmp_4+48(SP), DI

0x0053 00083 (main.go:16)  XORPS  X0, X0

0x0056 00086 (main.go:16)  PCDATA $0, $-2

0x0056 00086 (main.go:16)  LEAQ   -32(DI), DI

0x005a 00090 (main.go:16)  NOP

0x0060 00096 (main.go:16)  DUFFZERO   $273

0x0073 00115 (main.go:16)  PCDATA $0, $-1

0x0073 00115 (main.go:16)  MOVQ   ""..autotmp_3+40(SP), AX

0x0078 00120 (main.go:16)  LEAQ   type.map[int64]int64(SB), CX

0x007f 00127 (main.go:16)  MOVQ   CX, (SP)

0x0083 00131 (main.go:16)  MOVQ   AX, 8(SP)

0x0088 00136 (main.go:16)  LEAQ   ""..autotmp_4+48(SP), AX

0x008d 00141 (main.go:16)  MOVQ   AX, 16(SP)

0x0092 00146 (main.go:16)  PCDATA $1, $1

0x0092 00146 (main.go:16)  CALL runtime.mapiterinit(SB)

0x0097 00151 (main.go:16)  JMP 153

0x0099 00153 (main.go:16)  CMPQ   ""..autotmp_4+48(SP), $0 //sp+48是我们当前遍历到的key的地址

0x009f 00159 (main.go:16)  NOP

0x00a0 00160 (main.go:16)  JNE    164

0x00a2 00162 (main.go:16)  JMP 212

0x00a4 00164 (main.go:16)  MOVQ   ""..autotmp_4+48(SP), AX

0x00a9 00169 (main.go:16)  TESTB  AL, (AX)

0x00ab 00171 (main.go:16)  MOVQ   (AX), AX

0x00ae 00174 (main.go:16)  MOVQ   AX, "".k+32(SP)

0x00b3 00179 (main.go:16)  MOVQ   ""..autotmp_4+56(SP), AX

0x00b8 00184 (main.go:16)  TESTB  AL, (AX)

0x00ba 00186 (main.go:16)  MOVQ   (AX), AX

0x00bd 00189 (main.go:16)  MOVQ   AX, "".v+24(SP)

0x00c2 00194 (main.go:17)   JMP 196

0x00c4 00196 (main.go:16)  LEAQ   ""..autotmp_4+48(SP), AX

0x00c9 00201 (main.go:16)  MOVQ   AX, (SP)

0x00cd 00205 (main.go:16)  CALL runtime.mapiternext(SB)

0x00d2 00210 (main.go:16)  JMP 153

0x00d4 00212 (main.go:16)  PCDATA $1, $-1

0x00d4 00212 (main.go:16)  MOVQ   144(SP), BP

0x00dc 00220 (main.go:16)  ADDQ   $152, SP

0x00e3 00227 (main.go:16)  RET

0x00e4 00228 (main.go:16)  NOP
复制代码

遍历的整体流程图如下:

image.png

遍历会使用runtime.hiter结构体作为迭代器,使用runtime.mapiterinit进行迭代器初始化,使用runtime.mapiternext进行map的迭代。

如果扩容的过程是原子的,那么其实遍历可以非常简单,只需按序遍历bucket及cell即可。但是,由于golang中map的扩容是一个渐进式的操作,所以也会存在map处于扩容中的状态,在这种情况下,部分oldbucket数据尚未迁移到bucket,为了保证所有的key都被遍历到,在遍历时就会需要在oldbuckts和buckets间来回跳转。来看一个遍历的图示:

假设当前有一个map的内存布局如下,当前map正处于扩容中,且已完成了一个bucket的迁移,对应到下图中红色oldbuckets[0]已迁移到buckets[0]和buckets[2]。

image.png

假设我们的startBucket = 2且offset = 1。

所以bmap和key的遍历顺序是这样的:

bmap2301
key3->7->1110->1->2->9

bmap: 2 ----> 3----->0------>1

key: 3 -> 7 -> 10 -> 1 -> 2 --> 9

下面我们再来分析一下遍历的源码

type hiter struct { 

    key         unsafe.Pointer // 当前遍历到的key

    elem        unsafe.Pointer // 当前遍历到的value

    t           *maptype // map 类型,包含如 key size 大小等 

    h           *hmap    // 迭代器初始化时设置,指向当前hmap

    buckets     unsafe.Pointer   // 初始化时指向的 buckets 

    bptr        *bmap   // 当前遍历到的 bmap 

    overflow    *[]*bmap       // keeps overflow buckets of hmap.buckets alive 

    oldoverflow *[]*bmap       // keeps overflow buckets of hmap.oldbuckets alive 

    startBucket uintptr // 起始遍历的 bucket 编号 

    offset      uint8 // 迭代过程,从每一个桶的开始cell编号

    wrapped     bool // already wrapped around from end of bucket array to beginning 是否已经遍历到[]bmap结尾,需要从头开始遍历,用于结束条件的判断

    B           uint8 // hmap.B

    i           uint8 // 当前 相对offset的cell序号,真正遍历到的cell序号其实是(i+offset) & (bucketCnt - 1) 

    bucket      uintptr // 指向当前的 bucket 

    checkBucket uintptr  // 因为map在迭代时也可能发生扩容,所以在扩容也需要检查bucket是否已经迁移

} 
复制代码
func mapiterinit(t *maptype, h *hmap, it *hiter) { 

//忽略race相关的操作

...

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

       return

    }

    

    if unsafe.Sizeof(hiter{})/sys.PtrSize != 12 {

       throw("hash_iter size incorrect") // see cmd/compile/internal/gc/reflect.go

    }

   it.t = t 

   it.h = h 

   it.B = h.B // grab snapshot of bucket state 保存桶的当前状态

   it.buckets = h.buckets 

   if t.bucket.ptrdata == 0 {

      // Allocate the current slice and remember pointers to both current and old. 

      // This preserves all relevant overflow buckets alive even if 

      // the table grows and/or overflow buckets are added to the table 

      // while we are iterating. 

      h.createOverflow() 

      it.overflow = h.extra.overflow 

      it.oldoverflow = h.extra.oldoverflow 

   } 



  // 生成随机数 r 

   r := uintptr(fastrand()) 

   if h.B > 31-bucketCntBits { 

      r += uintptr(fastrand()) << 31 

   } 

   it.startBucket = r & bucketMask(h.B) // 从哪个 bucket 开始遍历 

   it.offset = uint8(r >> h.B & (bucketCnt - 1)) // 从 bucket 的哪个 cell 开始遍历 

  

   // iterator state 

   it.bucket = it.startBucket 



    // Remember we have an iterator.

    // Can run concurrently with another mapiterinit().

    //将map标记为正在迭代中

   if old := h.flags; old&(iterator|oldIterator) != iterator|oldIterator { 

      atomic.Or8(&h.flags, iterator|oldIterator) 

   } 



   mapiternext(it) 

} 



func mapiternext(it *hiter) { 

  // 检测当前是不是有 groutine 在进行写入,有的话直接抛出错误 

   if h.flags&hashWriting != 0 { 

      throw("concurrent map iteration and map write") 

   } 

   h := it.h 

   t := it.t 

   bucket := it.bucket 

   b := it.bptr 

   i := it.i 

   checkBucket := it.checkBucket 



next: 

  // 当前遍历到的bmap为 nil,需要继续遍历下一个bmap

   if b == nil { 

    // 当前遍历到的bmap是开始的bmap 并且已经遍历过尾部了,则标识key为nil,

    // 在汇编中会用key==nil作为遍历结束条件

      if bucket == it.startBucket && it.wrapped {

         it.key = nil 

         it.elem = nil 

         return 

      } 

      //开始迭代时,hmap已经在扩容中,并且扩容尚未结束

      if h.growing() && it.B == h.B { 

        // Iterator was started in the middle of a grow, and the grow isn't done yet.

        // If the bucket we're looking at hasn't been filled in yet (i.e. the old

        // bucket hasn't been evacuated) then we need to iterate through the old

        // bucket and only return the ones that will be migrated to this bucket.

         oldbucket := bucket & it.h.oldbucketmask() //当前bucket对应的old bucket

         b = (*bmap)(add(h.oldbuckets, oldbucket*uintptr(t.bucketsize))) //对应的old bmap

         if !evacuated(b) { // 如果old bmap尚未完成迁移,则需要去遍历oldbucket

            checkBucket = bucket //标记,当前bucket需要检查,因为当前bucket对应的oldbucket尚未完成迁移

         } else { //old bucket已经完成了迁移,则正常遍历当前bucket即可

            b = (*bmap)(add(it.buckets, bucket*uintptr(t.bucketsize))) 

            checkBucket = noCheck 

         } 

      } else { 

         b = (*bmap)(add(it.buckets, bucket*uintptr(t.bucketsize))) 

         checkBucket = noCheck 

      } 



      bucket++ // 下一个桶 

      if bucket == bucketShift(it.B) { // 已经遍历到尾部了,需要继续从头部开始

         bucket = 0 

         it.wrapped = true //标识已经遍历过尾部了,在退出条件时会用到

      } 

      i = 0 

   } 

  

  // 遍历当前 bucket 的 cell 

   for ; i < bucketCnt; i++ { 

      offi := (i + it.offset) & (bucketCnt - 1) 

      if isEmpty(b.tophash[offi]) || b.tophash[offi] == evacuatedEmpty { 

         // TODO: emptyRest is hard to use here, as we start iterating 

         // in the middle of a bucket. It's feasible, just tricky. 

         continue 

      } 

   // key/value 地址 

      k := add(unsafe.Pointer(b), dataOffset+uintptr(offi)*uintptr(t.keysize)) 

      if t.indirectkey() { 

         k = *((*unsafe.Pointer)(k)) 

      } 

      e := add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+uintptr(offi)*uintptr(t.elemsize)) 





      //如果map处于翻倍扩容的场景,

      if checkBucket != noCheck && !h.sameSizeGrow() { 

          // Special case: iterator was started during a grow to a larger size

         // and the grow is not done yet. We're working on a bucket whose

         // oldbucket has not been evacuated yet. Or at least, it wasn't

         // evacuated when we started the bucket. So we're iterating

         // through the oldbucket, skipping any keys that will go

         // to the other new bucket (each oldbucket expands to two

         // buckets during a grow).

         if t.reflexivekey() || t.key.equal(k, k) { 

            // If the item in the oldbucket is not destined for

            // the current new bucket in the iteration, skip it.

            // 因为迭代时在迭代新桶,但是旧桶中的数据可以迁移到2个新桶,

            // 如果检查旧桶中的数据不是迁移到当前这个新桶,则跳过,会在另一个新桶遍历到

            hash := t.hasher(k, uintptr(h.hash0)) 

            if hash&bucketMask(it.B) != checkBucket { 

               continue 

            } 

         } else { 

      // math.NAN的特殊逻辑,暂时忽略

            if checkBucket>>(it.B-1) != uintptr(b.tophash[offi]&1) { 

               continue 

            } 

         } 

      } 



      if (b.tophash[offi] != evacuatedX && b.tophash[offi] != evacuatedY) || 

         !(t.reflexivekey() || t.key.equal(k, k)) { 

         // This is the golden data, we can return it. 

         // OR 

         // key!=key, so the entry can't be deleted or updated, so we can just return it. 

         // That's lucky for us because when key!=key we can't look it up successfully. 

         it.key = k 

         if t.indirectelem() { 

            e = *((*unsafe.Pointer)(e)) 

         } 

         it.elem = e 

      } else { 

         // The hash table has grown since the iterator was started. 

         // The golden data for this key is now somewhere else. 

         // Check the current hash table for the data. 

         // This code handles the case where the key 

         // has been deleted, updated, or deleted and reinserted. 

         // NOTE: we need to regrab the key as it has potentially been 

         // updated to an equal() but not identical key (e.g. +0.0 vs -0.0). 

         rk, re := mapaccessK(t, h, k) 

         if rk == nil { 

            continue // key has been deleted 

         } 

         it.key = rk 

         it.elem = re 

      } 

      it.bucket = bucket 

      if it.bptr != b { // avoid unnecessary write barrier; see issue 14921 

         it.bptr = b 

      } 

      it.i = i + 1 

      it.checkBucket = checkBucket 

      return 

   } 

   b = b.overflow(t) 

   i = 0 

   goto next 

} 
复制代码

常见的坑点

1、为了防止开发人员依赖map的顺序,对于每一次map遍历(range),得到结果的顺序都是不同的,因为在初始化迭代器(runtime.mapiterinit)时都对startBucket和startCell进行了random操作。

func mapiterinit(t *maptype, h *hmap, it *hiter) {

   //some code ...



   // decide where to start

   r := uintptr(fastrand()) //fastrand 是一个快速生成随机数的函数

   if h.B > 31-bucketCntBits {

      r += uintptr(fastrand()) << 31

   }

   it.startBucket = r & bucketMask(h.B) 

   //bucketMask返回bucket的个数-1,也就是全1,将r和bucketMask做与操作,获得开始的桶编号

   it.offset = uint8(r >> h.B & (bucketCnt - 1))

   //bucketCnt是常量8代表一个桶内的cell数,将r右移B位后与7做与运算,可以获得开始的cell编号



   //some code ...

}
复制代码

2、无法比较的类型无法作为key

关于Go语言中的可比较性,参考官方文档golang.org/ref/spec#Co…

  • 布尔值是可比较的
  • 整数值可比较的
  • 浮点值是可比较的(其中比较特殊的是math.NAN,math.NAN != math.NAN)
  • 复数值是可比较的
  • 字符串值是可比较
  • 指针值是可比较的。如果两个指针值指向相同的变量,或者两个指针的值均为nil,则它们相等。
  • channel是可比较的。如果两个channel值是由相同的make调用创建的,或者两个值都为nil,则它们相等。
  • 接口值是可比较的。如果两个接口值具有相同的动态类型和相等的动态值,或者两个接口值都为nil,则它们相等。
  • 如果结构的所有字段都是可比较的,则它们的值是可比较的。
  • 如果array元素类型的值可比较,则array值可比较。如果两个数组的对应元素相等,则它们相等。
  • slice、function value、map是不可比较的(但是这三种类型都可以和nil进行对比)

3、map的value是无法寻址的,也就是说无法获取map value的地址。

常见的case为:当value是struct类型,其值无法修改(最简单的解决方法就是value存成*T类型即可);对map[key]取地址时报错。

package main



type T struct {

   Id int64

}



func main() {

   var a = map[int64]T{}

   a[10].Id = 20  

   var m map[int]int{1:1}

   b := &m[1]

}

./main.go:9:11: cannot assign to struct field a[10].Id in map

./main.go:11:11: cannot take the address of
复制代码

设计成不可寻址是因为map内部的存储位置未必是固定的。当插入时,若数据空间不够,就需要进行扩容,扩容要重新哈希,此时key被rehash到另外的桶里。

4、map不支持并发读写,只能并发读

package main



func main() {

   mp := make(map[int]int)

   go func() {

      for i := 0; i < 10_0000; i++ {

         _ = mp[i]

      }

   }()

   go func() {

      for i := 0; i < 10_0000; i++ {

         mp[i] = i

      }

   }()

   select {}

}
复制代码

网上都说对并发读写map会抛出panic,其实是不对的,并不是抛出panic,只是会打印错误及对应的信息并直接调用runtime.exit(2)函数退出进行。执行上述的代码会有

throw:fatal error: concurrent map read and map write。

值得一提的是对map的并发读写导致的throw是unrecoverable,无法通过内置的recover函数进行捕获,进程会直接crash掉,退出码是2。

可以在运行时加上race检测,可以检测出并发竞争操作,但race开销较大,不要在生产环境中开启。

go run -race main.go

其本质原因也是因为key/value的内存地址是可变的。如果在写时改变了地址,并发线程对旧地址进行读写都会导致内存崩溃/不安全。

具体代码的做法就是设置并check write标示位:

// 写时:

h.flags ^= hashWriting



// 在读/写map时会check标记位,如果发现了并发读写,会抛出错误信息

if h.flags & hashWriting != 0 {

   throw("concurrent map read and map write")

}





//输出错误信息

//go:nosplit

func throw(s string) {

   // Everything throw does should be recursively nosplit so it

   // can be called even when it's unsafe to grow the stack.

   systemstack(func() {

      print("fatal error: ", s, "\n")

   })

   gp := getg()

   if gp.m.throwing == 0 {

      gp.m.throwing = 1

   }

   fatalthrow()

   *(*int)(nil) = 0 // not reached

}



// fatalthrow implements an unrecoverable runtime throw. It freezes the

// system, prints stack traces starting from its caller, and terminates the

// process.

//(输出函数堆栈信息,并退出进程)

//go:nosplit
func fatalthrow() {
   pc := getcallerpc()
   sp := getcallersp()
   gp := getg()
   // Switch to the system stack to avoid any stack growth, which
   // may make things worse if the runtime is in a bad state.
   systemstack(func() {
      startpanic_m()
      if dopanic_m(gp, pc, sp) {
         // crash uses a decent amount of nosplit stack and we're already
         // low on stack in throw, so crash on the system stack (unlike
         // fatalpanic).
         crash()
      }
      exit(2)
   })
   *(*int)(nil) = 0 // not reached
}
复制代码

对于需要并发读写map的场景,常见的解决方案如下:

  • map + sync.RWMutex
  • 采用 sync.map,实现是小粒度的锁+读写分离+原子操作

5、len(map)返回的是map中元素的个数,不是map的容量

package main



import "fmt"



func main() {

   mp := make(map[int64]int64,10)

   fmt.Println(len(mp))

}

//上面这段程序会输出0
复制代码

6、new出来的map是无法使用的,因为new只是malloc了8字节大小的内存(new返回指针)。

相对的,make 是个语法糖,最终被编译器翻译成runtime.makemap 函数调用,会进行内存开辟和对象初始化操作。

7、通过fmt打印map时,空map和nil map结果是一样的,都为map[]。所以,这个时候别断定map是空还是nil,而应该通过map == nil来判断。

8、超出容量时会自动扩容,但尽量提供一个合理的初始值

9、map作为函数的出参时不需要以指针形式进行传递,因为map本身就是指针,上文中map初始化一节也可以看到runtime.makemap返回的是*runtime.hmap。

10、delete是不会真正地把map的内存释放的,要回收map还是需要设为nil

var intMap map[int]int



func main() {

   printMemStats("初始化")



   // 添加1w个map值

   intMap = make(map[int]int, 10000)

   for i := 0; i < 10000; i++ {

      intMap[i] = i

   }



   // 手动进行gc操作

   runtime.GC()

   // 再次查看数据

   printMemStats("增加map数据后")



   log.Println("删除前数组长度:", len(intMap))

   for i := 0; i < 10000; i++ {

      delete(intMap, i)

   }

   log.Println("删除后数组长度:", len(intMap))



   // 再次进行手动GC回收

   runtime.GC()

   printMemStats("删除map数据后")



   // 设置为nil进行回收

   intMap = nil

   runtime.GC()

   printMemStats("设置为nil后")

}



func printMemStats(mag string) {

   var m runtime.MemStats

   runtime.ReadMemStats(&m)

   log.Printf("%v:分配的内存 = %vKB, GC的次数 = %v\n", mag, m.Alloc/1024, m.NumGC)

}
复制代码

总结

本文我们首先介绍了map的基本概念,了解到golang的map是基于hash表实现的且冲突解决方式采用链地址法,并简单介绍了map的基本操作。

接下来我们深入源码,分别研究了map的内存模型、哈希函数、初始化、读取、写入、删除、扩容和遍历过程。map中非常核心的概念是桶,桶作为map数据查找和存储的基本单位,每个桶中有8个cell,key的hash值的低位决定了落入哪个bucket,高位则决定了落入哪个cell中,对于超过8个元素的桶,会在后边链上溢出桶做额外存储,而如果有太多的溢出桶或map的空闲容量较小的情况下,则会触发map的扩容,为了防止一次扩容带来的内存抖动和时延开销,map的扩容是渐进式的,会将扩容过程离散到每一次的数据写入操作中。

最终结合网上的一些参考资料和我们平常中的实践,介绍了map在日常使用中常见的一些坑点。

参考

  1. draveness.me/golang/docs…

  2. blog.csdn.net/wade3015/ar…

  3. cch123/golang-notes

  4. lessisbetter.site/2018/09/29/…

文章分类
后端
文章标签