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,那就是翻倍之后的编号,直接对应上了,不需要再做额外的操作
等量扩容 把旧桶中的数据迁移到新桶即可 意义:溢出桶比较多,而每个桶中的数据比较少的时候,我们通过等量扩容减少溢出桶,增加了数据的紧凑性