GoLang之map底层系列四(视频版)

GoLang之map底层系列四(理论版)

1.map结构

Go语言中map用来存储键值对,可以分别指定键和值的类型

var a map[string]string

Go语言中Map类型的底层实现就是哈希表,这些键值对实际上是通过哈希表来存储的,一个哈希表里可以有多个哈希表节点,即bucket,而每个bucket就保存了map中的一个或一组键值对;
Map类型指向hmap结构体;
map类型的变量本质上是个指针,

image-20220304151429658

2.map时间复杂度

为什么要用哈希表?能够存储键值对的数据结构有很多种,可以是数组,也可以是链表,但是能用不代表好用。如果每次查找一个键都要从第一个元素开始遍历,直到发现匹配的键或者遍历完所有元素,这样的时间复杂度就是O(n);
而哈希表理论上可以实现O(1)

在这里插入图片描述

3.设计简单哈希表

下面我们一起设计一个简单的哈希表,看看它是怎样工作的;
哈希表首先要有一段内存用作存储键值对的桶(bucket),每个桶里存什么?暂时就先存一个键值对外加一个哈希值;
这个哈希值就是对键值对的键经过哈希函数处理后得到的结果,但是我们只把它当作一个数值来用,用来干什么呢?选择桶
bucket很多时候被翻译为桶,所谓的哈希桶实际上就是bucket

image-20220304151801147

4.hmap结构体

//hmap结构体源码位于runtime/map.go里
type hmap struct {
	count     int //元素个数、调用len(map)时,直接返回此值
	flags     uint8//
	B         uint8  // buckets 的对数 log_2
	noverflow uint16   //overflow 的 bucket 近似数
	hash0     uint32 // 计算 key 的哈希的时候会传入哈希函数
	buckets    unsafe.Pointer //array of 2^B Buckets. may be nil if count==0.
	oldbuckets unsafe.Pointer // 扩容的时候,buckets 长度会是 oldbuckets 的两倍
	nevacuate  uintptr// 指示扩容进度,小于此地址的 buckets 迁移完成       
	extra *mapextra 
}

Go语言中map类型的底层实现就是哈希表,map类型的变量本质是是一个指针,指向hmap结构体

(1)B记录常规桶个数等于2^B,桶的数目是2的多少次幂(以下是2^B的原因是:这里选择桶时用是“与运算”),由于 map底层的哈希表通过与运算的方式选择桶,所以hmap中并不直接记录桶的个数,而是记录这个数目是2的多少次幂;B是 buckets 数组的长度的对数,也就是说buckets 数组的长度就是 2^B
(2)noverflow记录使用的溢出桶的数量
(3)bukets用于记录桶在哪,记录常规桶的起始地址; bukets是bucket是数组指针,数组的大小为2^B
(4)oldbukets用于记录扩容阶段保存旧桶在哪,扩容时保存原来常规桶的地址
(5)nevacuate记录渐进式扩容阶段下一个要迁移的旧桶编号
(6)count记录当前保存的元素个数,即已经存储的键值对个数

image-20220304162329081

5.bmap结构体

//bmap结构体源码位于runtime/map.go里
//源码里是没有data与overflow字段的,编译期间会给它加料,动态地创建一个新的结构
type bmap struct {
	tophash [bucketCnt]uint8
}

bmap是Map使用的桶的样子;
1)tophash是个长度为8的数组,哈希值相同的键(准确的说是哈希值低位相同的键)存入当前bucket时会将哈希值的高位存储在该数组中,以方便后续匹配;在8个key的前面,则是8个tophash,每个tophash都是对应哈希值的高8位;
tophash是一个长度为8的数组,它不仅仅用来存放key的哈希高8位,在不同场景下它还可以标记迁移状态,bucket是否为空等;
当tophash对应的K/V被使用时,存的是key的哈希值的高8位;当tophash对应的K/V未被使用时,存的是K/V对应位置的状态。有以下5个状态:
emptyRest = 0
emptyOne = 1
evacuatedX = 2
evacuatedY = 3
evacuatedEmpty = 4
minTopHash = 5
当tophash[i] < 5时,表示存的是状态;
当tophash[i] >= 5时,表示存的是哈希值;
2)data区存放的是key-value数据,存放顺序是key/key/key/…value/value/value,如此存放是为了节省字节对齐带来的空间浪费;bmap一个桶里可以放8个键值对,但是为了让内存排列更加紧凑,8个key放一起,8个value放一起;
3)overflow 指针指向的是下一个bucket,意为当前bucket盛不下而溢出的部分,据此将所有冲突的键连接起来(与哈希冲突拉链法有一定的关系),即overflow是一个bmap型的指针,指向一个溢出桶,溢出桶的内存布局与“常规桶”相同,是为了减少扩容次数而引入的,当一个桶存满了,还有可用的溢出桶时,就会在桶后面链一个溢出桶,继续往溢出桶里面存
注意:上述中data和overflow并不是在结构体中显示定义的,而是直接通过指针运算进行访问的,经编译器扩充之后的结构体才包含 key,value,overflow 这些字段

image-20220304163614296

map使用的桶很有设计感,每个桶里可以存储8个键值对,并且为了内存使用更加紧凑,8个键放一起,8个值放一起。对应每个key只保存其哈希值的高8位(tophash)。而每个键值对的tophash、key和value的索引顺序一一对应。这就是map使用的桶的内存布局

在这里插入图片描述

6.mapextra结构体

//mapextra结构体源码位于runtime/map.go里
type mapextra struct {
	overflow    *[]*bmap
	oldoverflow *[]*bmap
	nextOverflow *bmap
}

实际上,如果哈希表要分配的桶的数目大于2的4次方,就认为使用的溢出桶的几率较大,就会预分配“2的(B-4)次方”个溢出桶备用,这些溢出桶与常规桶在内存中是连续的,只是前2^B个用作常规桶,后面的用作溢出桶;(下图中例子:B=5,即有32个桶,大于16,分配2个溢出桶,总共34个桶)

hmap结构体最后有一个extra字段,指向一个mapextra结构体,里面记录的都是溢出桶相关的信息
1)overflow是一个slice,记录目前已经被使用的溢出桶的地址,把把已经用到的溢出桶链起来;假如编号为2的桶存满了,就会在后面链一个溢出桶,nextoverflow指向下一个空闲桶,overflow记录使用的溢出桶的数量,此时只用了一个;
3)oldoverflow也是一个slice,用于在渐进式扩容阶段时存储旧桶用到的那些溢出桶的地址
(4)nextoverflow指向下一个空闲溢出桶,即指向下一个尚未使用的溢出桶;

image-20220304181057846在这里插入图片描述

如果当前桶存满了以后,检查hmap.extra.nextoverflow还有可用的溢出桶,就在这个桶后面链上这个溢出桶,然后继续往这个溢出桶里存。而hmap.extra.nextoverflow继续指向下一个空闲的溢出桶。所以这里解决哈希冲突的方式应该属于拉链法。

在这里插入图片描述

7.键值对选择桶

7.1详细复杂描述

bmap 就是我们常说的“桶”,桶里面会最多装 8 个 key,这些 key 之所以会落入同一个桶,是因为它们经过哈希计算后,哈希结果是“一类”的。在桶内,又会根据 key 计算出来的 hash 值的高 8 位来决定 key 到底落入桶内的哪个位置(一个桶内最多有8个位置)。

7.2取模法

哈希表通常会有一堆桶来存储键值对,如果你要存储键值对“k1:v1”,这有8个桶,你选哪一个?
一个键值对来了,自然要选择一个桶,先通过哈希函数把"键"处理一下,得到一个哈希值hash;
现在要利用这个哈希值从m个桶中选择一个,桶编号区间0到m-1,有两种方法比较常用:
假设哈希值为hash,桶数量为m。通常我们有两种方法来选择一个桶:
第一种:取模运算
hash%m
第二种:按位与运算
hash&(m-1)

image-20220304150147638

第一种取模法,就是用哈希值与桶的个数m取模,得到一个桶编号;
我们这里就用第一种方法简单展示下,如果选择了索引4这个桶,把键值对存进去以后,哈希表如下图所示:
如果想从哈希表里查找这个键的值,只需用同样的方法对键进行哈希求值,再对桶数目取模就能定位到键值对所在的桶了。不用遍历,的确很不错
注:0%2=0、1%2=1、2%2=0 3%2=1、4%2=0

![

7.3位与运算

第二种“与运算”,哈希值与m-1进行与运算,若想确保运算结果落在区间[0,m-1]而不会出现空桶,就要限制桶的个数必须是2的整数次幂,这样的话m的二进制表示一定只有一位为1,m-1的二进制表示一定是 低于这一位的所有位的均为一;如果桶的个位数不是2的整数次幂,就会出现有些桶绝对不会被选中的情况
注: 100=4 011=3

桶的的个数是2的整数次幂情况下:

image-20220304150316584

如果桶的个数不是2的整数次幂下:
就会出现有些桶绝对不会被选中的情况,以下是1、2、3号桶都不会被选中

image-20220304150355607
image-20220304150452333

8.哈希冲突

假如现在“k1:v1”已经选中了2号桶

image-20220304150718541

如果后来又有新的键值对“k2:v2”要选择2号桶,同样要对键求哈希值然后对桶个数取模,好巧不巧,又是编号为2这个桶。这个桶已经被占用了,就会发生哈希冲突,常用的解决哈希冲突有两种解决办法:
第一种:开放地址法
第二种:拉链法

image-20220304151145892

9.解决哈希冲突

9.1开放地址法

既然桶编号为2的桶被占用了,那就找它后面的没有被占用的桶来用 ,这里选择3号桶

image-20220304152735316

等到查找k2这个键值对的时候,虽然首先会定位到编号为2的桶,但是经过比较k不相等,就会遍历它后面的桶,直到k相等(或者遇到空桶,则证明key不存在)

image-20220304152800169

image-20220304152811210

9.2拉链法

拉链法也叫链地址法,Go使用链地址法来解决键冲突;
由于每个bucket可以存放8个键值对,所以同一个bucket存放超过8个键值对时就会再创建一个键值对,用类似链表的方式将bucket连接起来;
假如现在2号桶被占用了,需要再存入一个键值对,则 在2号桶后面链一个新桶存储这个键值对就好了;
查找k2时,同样会选找到2号桶经比较发现key不相等,所以就顺着链子往往后找(即不往3号桶去了,直接到那个新桶查找)

image-20220304151145892

image-20220304153721284

10.负载因子

使用哈希表的目的就是要快速查找到目标 key,然而,随着向 map 中添加的 key 越来越多,key 发生碰撞的概率也越来越大。bucket 中的 8 个 cell 会被逐渐塞满,查找、插入、删除 key 的效率也会越来越低。最理想的情况是一个 bucket 只装一个 key,这样,就能达到 O(1) 的效率,但这样空间消耗太大,用空间换时间的代价太高。
Go 语言采用一个 bucket 里装载 8 个 key,定位到某个 bucket 后,还需要再定位到具体的 key,这实际上又用了时间换空间。
当然,这样做,要有一个度,不然所有的 key 都落在了同一个 bucket 里,直接退化成了链表,各种操作的效率直接降为 O(n),是不行的。
因此,需要有一个指标来衡量前面描述的情况,这就是负载因子

哈希冲突的发生会影响哈希表读写的效率,选择散列均匀的哈希函数可以减少哈希冲突的发生,适时的对哈希表进行扩容也是保障读写效率的有效手段;
通常会把存储键值对的数目与桶的数目的比值作为是否需要扩容的判断依据这个比值被称为“负载因子”;

image-20220304154537714

11.一次性扩容

就是一次性把所有键值对挪到新的桶里,但是如果键值对数量很多,每次扩容占用的时间太长就会造成性能瞬时明显抖动,所以通常会选择“渐进式扩容”。

12.渐进式扩容

"渐进式扩容”方式下,旧桶里的键值对是分多次挪到新桶里的,可以在触发扩容后,先分配新桶,然后标记当前哈希表正在扩容,并在哈希表的读写操作中判断若在扩容中,则迁移一部分键值对到新桶里,这样可以把扩容消耗的时间分散到多次操作中。

在这里插入图片描述
在这里插入图片描述

需要扩容时,需要分配更多的桶,他们就是新桶,需要把旧桶里存储的键值对都迁移到新桶里,如果旧哈希表存储的键值对过多,一次性迁移花费的时间就比较显著,所以通常会在哈希表扩容时先分配足够多的新桶,然后用一个字段记录旧桶的位置,再增加一个字段记录旧桶迁移的进度,例如下一个记录的要迁移的旧桶的编号。在哈希表每次读写操作时,如果检测到到当前处于扩容阶段,就完成一部分键值对迁移任务,直到所有的旧桶迁移完成,旧桶不再使用,才算真正完成一次哈希表的扩容。
像这样把键值对迁移的时间分摊到多次哈希表操作中的方式就是“渐进式扩容”,可以避免一次性扩容带来的性能瞬间抖动

image-20220304160116247

image-20220304160727204

image-20220304160741294

13.渐进式扩容

13.1扩容前提条件

为了保证访问效率,当新元素将要添加进map时,都会检查是否需要扩容,扩容实际上是以空间换时间的手段。
触发扩容的条件有二个:
1)负载因子 > 6.5时,也即平均每个bucket存储的键值对达到6.5个,引发翻倍扩容
2)B<=15,已使用的溢出桶个数>=2的B次方时,引发等量扩容。
B>15,已使用的溢出桶个数>=2的15次方时,引发等量扩容

13.2翻倍扩容

翻倍扩容也叫增量扩容;
如果超过负载因子(默认6.5)就触发翻倍扩容:
hmap.count / 2^hmap.B > 6.5;
我们知道,每个 bucket 有 8 个空位,在没有溢出,且所有的桶都装满了的情况下,装载因子算出来的结果是 8。因此当装载因子超过 6.5 时,表明很多 bucket 都快要装满了,查找效率和插入效率都变低了。在这个时候进行扩容是有必要的
当负载因子过大时,就新建一个bucket,新的bucket长度是原来的2倍,注意,这时候元素都在老 bucket 里,还没迁移到新的 bucket 来
分配新桶数目是旧桶的2倍,hmap.oldbuckets指向旧桶,hmap.buckets指向新桶。hmap.nevacuate为0,表示接下来要迁移编号为0的旧桶。

在这里插入图片描述

变量a本质上是一个hmap的指针,目前存储了一个键值对,只有一个桶,也没有预分配的溢出桶;
下面把唯一的这个桶展开来看一下,首先是8个tophash,每个tophash占一个字节,因为key与value都是string类型,所以64位下每个key和value都占用16字节;
目前只存储了一个键值对,取k1哈希值的高8位(即1个字节)存到这里;
还记得字符串内存布局么?字符串内容为k1,占用字节数目为2。value也是一样,字符串内容为k1,占用字节数目为2,目前这个桶的样子长下面这个样子

image-20220304184033847

如果我们把上面这个桶存满,接下来再继续存储新的键值对时,这个哈希表是会创建溢出桶,还是谁发生扩容呢?

image-20220305102146069

这就要看map的扩容规则了,Go语言中map的默认负载因子是6.5,超过这个数就会触发翻倍扩容,分配新桶的数目是旧桶的2倍
8/1>6.5

image-20220305102243973

回到刚刚那个例子,buckets指向新分配的两个桶,oldbuckets指向旧桶,nevacuate=0即表示接下来要迁移编号为0的旧桶;
每个旧桶的键值对都会分流到两个新桶中

image-20220305102511280

例如如果旧桶数量为4(11),新桶就是8.如果一个哈希值选择0号旧桶,那么哈希值的二进制低两位一定为0(00&11),所以选择新桶的结果只有两种,取决于哈希值的第三位是0还是1,如果第三位是0,则选择编号为0(000)的新桶,如果是1,则会选择编号为4(100)的新桶;
因为桶的数量一定是2的整数次幂,所以无论容量是多少,翻倍扩容后,每个旧桶都会按以上按照这样的规律分流到两个新桶中

image-20220305102902255

image-20220305103112640

image-20220305103127985

13.3等量扩容

如果负载因子没有超标,但是使用的溢出桶很多,这时候 map 的查找和插入效率也很低,也会触发扩容,不过这一次是等量扩容。
如果常规桶数目小于等于“2的15次方”,那么使用溢出桶数目超过常规桶就算是多了;如果常规桶数目大于“2的15次方”,那么使用溢出桶数目一旦超过“2的15次方”就算是多了

image-20220305103607768

所谓等量扩容,就是创建和旧桶数目一样多的新桶,然后把原来的键值对迁移到新桶中,但是既然等量,迁移来迁移去有什么用?那我们就要想想什么情况下,桶的负载因子没有超过上限值,却偏偏使用了很多溢出桶呢?自然是由很多键值对被删除的情况, 就像这里编号为0的情况: 如果此时满足等量扩容的触发条件,就会分配等量的新桶,编号为0的旧桶依然会迁移到同样编号的新桶中,同样数目的键值对迁移到新桶中,能够排列的更加紧凑,从而减少溢出桶的使用,这就是等量扩容的意义所在

image-20220305103817300

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

GoGo在努力

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值