[深入理解Go map | 青训营笔记]

87 阅读9分钟

map到底是什么

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

example_map.png

本文会从map的底层数据结构初始化Map数据的查找、写入、更新、删除以及遍历等常见操作展开,

map的底层数据结构

Go中的map主要为两个数据结构

  • hmap
  • bmap

通过结构不难看出,map是基于拉链法实现hash表,解决hash表中hash碰撞的方法主要有:开放寻址法和拉链法,不了解的可以百度一下二者的区别,毕竟不知这一篇文章的重点

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种子使用,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的创建

函数位置src/runtime/map.go

 func makemap(t *maptype, hint int, h *hmap) *hmap {
     
     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值
     B := uint8(0)
     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的首地址和溢出桶的地址

这是源码中的创建函数,我们可以分为四步

  • 第一步:创建生成一个hmap结构体对象
  • 第二部:生成一个哈希因子hash0并赋值到hmap对象中
 if h == nil {
     h = new(hmap)
 }
 h.hash0 = fastrand()
  • 第三步:根据hint,并根据算法生成B
 B := uint8(0)
 for overLoadFactor(hint, B) {
     B++
 }
 h.B = B
 ...
 ​
 // overLoadFactor reports whether count items placed in 1<<B buckets is over loadFactor.
 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))
 }

最后一步,获得bmap的首地址和溢出桶的地址:

代码如下:

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

根据上面的总结我们可以得出,当hint > 8 && uintptr(hint) > 13*(2^B/2)时,B 就会一直 ++

说人话,就是当我们的map的cap小于8的时候 B = 0,那么就是桶数组有 2^B个,即一个,当然我们每个桶数组中的bmap的key和value都是八个元素数组,

当我们创建map传入的cap大于8的时候 只要uintptr(hint) > 13*(2^B/2) => uintptr(hint) > 13*(2^(B - 1))那么B会一直++直到不满足这个

接下来就是计算需要多少桶的函数

  • B < 4

当我们的B < 4 的时候,桶的数量为2^B

  • B >= 4

然而当我们B>=4的时候就会额外申请一批溢出桶,溢出桶又是干什么的呢?

因为桶越多,做扩容就越麻烦,不能为了多了一两个数据去做扩容,所以就有了溢出桶

此时的总桶数为2^B + 2^(B-4)

这么看可能有点抽象,我们举两个例子

 test := make(map[string]string, 10)

上面这句代码定义了 hint = 10,首先我们计算 B

10 > 8 && 10 > 13*(2^B/2)

得到当 B = 1循环就可以结束了,所以我们的B = 1,

下面计算桶的大小,B < 4,数量为 21

 test := make(map[string]string, 100)

上面这句代码定义了 hint = 10,首先我们计算 B

100 > 8 && 100 > 13*(2^(B - 1))

计算得 B = 4,

下面计算桶的数量,B >= 4,数量为 2^B + 2^(B-4)为16 + 1 = 17

.......

map数据的存入

在map中写入数据的时候,内部执行流程为:

  • 第一步:根据放入的key与hash因子进行hash生成哈希值
  • 第二步:获取哈希值的后8位,并根据后B位的值来决定将此键值对存放到哪个桶中(bmap)。
hash值与桶掩码(B个1的二进制数)进行&操作,最终得到hash值的后B位,假如当B位1的时候,其结果为0:
hash值:010100000111100110
桶掩码: 000000000000000001
结果:0

会发现找桶的原理其实就是找到索引位置,然后根据索引在hmap中的buckets数组索引找到目标桶(bmap)。
  • 第三步:确定桶位置之后直接写入就行了,
获取hash值的高八位,存入到tophash中,把数据分别写入tophash,keysvalues中
如果桶已经满了,则通过overflow找到溢出桶,并在溢出桶中写入

注意:以后查找数据会基于tophash去寻找,tophash相同则会再去寻找key
  • 最后一步:hmap中的count++(map中的元素个数加一)

map数据的读取

数据的读取就很简单了,

  • 第一步:结合哈希因子和key生成哈希值
  • 第二步:确定哈希值的后B位之后,根据B的值来查找此键值对在哪个桶中
  • 第三步:确定桶之后,再根据哈希值的高八位,去tophash和key中寻找数据。
如果桶中未找到,则根据overflow去溢出桶中找,均未找到则表示key不存在

map扩容

在向map中添加数据时,达到某个条件,则会引发扩容机制

扩容条件:

  • map中数据总数 / 桶的个数 > 6.5 引发翻倍扩容

  • 使用了太多的溢出桶(溢出桶使用太多会导致map处理速度降低)(让数据更加的紧凑)

    • B <= 15,已使用的溢出桶个数 >= 2B 时,引发等量扩容。
    • B > 15,已使用的溢出桶个数 >= 215,引发等量扩容。

等量扩容:相当于桶的个数不变,只不过把原本溢出桶中的数据给取出来,放到没有放数据的bmap

底层源码:

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)
	if h.flags&iterator != 0 {
		flags |= oldIterator
	}
	// commit the grow (atomic wrt gc)
	h.B += bigger
	h.flags = flags
	h.oldbuckets = oldbuckets
	h.buckets = newbuckets
	h.nevacuate = 0
	h.noverflow = 0

	if h.extra != nil && h.extra.overflow != nil {
		// 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
	}

	// the actual copying of the hash table data is done incrementally
	// by growWork() and evacuate().
}

扩容之后:

  1. B会根据扩容之后桶的个数进行增加(翻倍扩容 新B = 旧B + 1, 等量扩容B = 旧B)
  2. oldbuckets指向原来旧的桶
  3. buckets指向新的桶
  4. nevacute设置为0(表示还没有开始数据迁移),应该从原桶的第0个开始迁移
  5. noverflow设置为0,因为还没有使用溢出桶
  6. extra.oldoverflow设置为原来的桶所有已使用的溢出桶,
  7. extra.overflow设置为nil,因为新桶中还没有使用溢出桶
  8. extra.nextOverflow设置为新桶中第一个溢出桶

map迁移

map扩容之后要进行数据的迁移

翻倍扩容

如果是翻倍扩容,那么迁移就是将旧桶中的数据分流到两个新的桶中(比例不定),同位置编号和翻倍之后的编号

迁移之后的数据要根据其key重新分配哈希值,因为其桶的位置发生了变化,再根据其哈希值的第 B 位进行分流,

如果是 0 则放入原来的桶,如果是 1 则放入原桶翻倍之后(原来的编号加上桶翻倍的个数)的编号

我们通过思考二进制可以发现一个事情

如果我们原来是32个桶,我们翻倍扩容,扩到64个桶

我们重新计算扩容之后的哈希值,后 B 位只能有两种情况

  • 扩容之后 000111【7】,迁移到原来的桶
  • 翻倍之后的编号 100111【39】,迁移到翻倍之后对应编号的桶

其实就是二进制的原因,我们翻倍了,就是相当于原来的数乘以 2,我们的二进制数相当于左移一位,多了一个最高位,扩容之后的 B = 旧B + 1,我们的第 B 位不是0,就是1,如果为 0 就相当于原来的编号,如果等于 1 是不是相当于在原来编号的基础上加上 2B-1,那就是翻倍之后的编号,直接对应上了,不需要再做额外的操作

等量扩容

把旧桶中的数据迁移到新桶即可

意义:溢出桶比较多,而每个桶中的数据比较少的时候,我们通过等量扩容减少溢出桶,增加了数据的紧凑性