map到底是什么
相信大家对Hash表并不陌生,Go语言中的Map基于Hash表实现,通过链表来解决Hash的碰撞问题,主要结构如下图所示:
本文会从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,keys,values中
如果桶已经满了,则通过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().
}
扩容之后:
- 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,那就是翻倍之后的编号,直接对应上了,不需要再做额外的操作
等量扩容
把旧桶中的数据迁移到新桶即可
意义:溢出桶比较多,而每个桶中的数据比较少的时候,我们通过等量扩容减少溢出桶,增加了数据的紧凑性