Go语言中的map | 青训营笔记

124 阅读9分钟

Go语言中的Map基于Hash表实现,通过链表来解决Hash的碰撞问题,主要结构如下图所示:

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

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

hmap bmap

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

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

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)的结构,编译期间会动态创建一个新的结构: 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

scss复制代码 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对象中

ini复制代码 if h == nil {      h = new(hmap)  }  h.hash0 = fastrand()

第三步:根据hint,并根据算法生成B

go复制代码 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的首地址和溢出桶的地址: 代码如下: go复制代码 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, baseuintptr(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)

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

go复制代码 test := make(map[string]string, 10)

上面这句代码定义了 hint = 10,首先我们计算 B 10 > 8 && 10 > 13*(2^B/2) 得到当 B = 1循环就可以结束了,所以我们的B = 1, 下面计算桶的大小,B < 4,数量为 21 go复制代码 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)。

bash复制代码将hash值与桶掩码(B个1的二进制数)进行&操作,最终得到hash值的后B位,假如当B位1的时候,其结果为0: hash值:010100000111100110 桶掩码: 000000000000000001 结果:0

会发现找桶的原理其实就是找到索引位置,然后根据索引在hmap中的buckets数组索引找到目标桶(bmap)。

第三步:确定桶位置之后直接写入就行了,

perl复制代码获取hash值的高八位,存入到tophash中,把数据分别写入tophash,keys,values中 如果桶已经满了,则通过overflow找到溢出桶,并在溢出桶中写入

注意:以后查找数据会基于tophash去寻找,tophash相同则会再去寻找key

最后一步:hmap中的count++(map中的元素个数加一)

map数据的读取 数据的读取就很简单了,

第一步:结合哈希因子和key生成哈希值 第二步:确定哈希值的后B位之后,根据B的值来查找此键值对在哪个桶中 第三步:确定桶之后,再根据哈希值的高八位,去tophash和key中寻找数据。

arduino复制代码如果桶中未找到,则根据overflow去溢出桶中找,均未找到则表示key不存在

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

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

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

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

等量扩容:相当于桶的个数不变,只不过把原本溢出桶中的数据给取出来,放到没有放数据的bmap中 底层源码: go复制代码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().

}

扩容之后:

B会根据扩容之后桶的个数进行增加(翻倍扩容 新B = 旧B + 1, 等量扩容B = 旧B) oldbuckets指向原来旧的桶 buckets指向新的桶 nevacute设置为0(表示还没有开始数据迁移),应该从原桶的第0个开始迁移 noverflow设置为0,因为还没有使用溢出桶 extra.oldoverflow设置为原来的桶所有已使用的溢出桶, extra.overflow设置为nil,因为新桶中还没有使用溢出桶 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,那就是翻倍之后的编号,直接对应上了,不需要再做额外的操作

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