Map底层的一些东西

本文深入解析了Go语言中Map数据结构的底层实现,包括bmap和tophash的结构,以及为何遍历无序、非线程安全的原因。同时讨论了如何查找、冲突解决策略、负载因子选择和渐进式扩容,以及sync.Map与原始Map的性能对比。
摘要由CSDN通过智能技术生成

1. Go map的底层实现原理

Go中的map是一个指针,占用8个字节,指向hmap结构体源码包中src/runtime/map.go定义了hmap的数据结构:
hmap包含若干个结构为bmap的数组,每个bmap底层都采用链表结构,bmap通常叫其bucket
在这里插入图片描述

hmap结构体

在这里插入图片描述

bmap结构体

bmap就是我们常说的"桶" 一个桶里面会最多装8个ke),这些key之所以会落入同一个桶,是因为它们经过哈希计算后,哈希结果的低B位是相同的,关于key的定位我们在map的查询中详细说明。在桶内,又会根据key 计算出来的 hash值的高8位来决定key到底落入桶内的哪个位置(一个桶内最多有8个位置)。

在这里插入图片描述

上面bmap结构是静态结构,在编译过程中runtime.bmap 会拓展成以下结构体:

在这里插入图片描述

tophash就是用于实现快速定位key的位置,在实现过程中会使用key的hash值的高8位作为tophash值,存放在bmap的tophash字段中
tophash字段不仅存储key哈希值的高8位,还会存储一些状态值,用来表明当前桶单元状态,这些状态值都是小于minTopHash的
为了避免key哈希值的高8位值和这些状态值相等,产生混淆情况,所以当key哈希值高8位若小于minTopHash时候,自动将其值加上minTopHash作为该xey的Stophash。桶单元的状态值如下:

在这里插入图片描述

mapextra结构体
当map的ikey和value都不是指针类型时候, bmap将完全不包含指针,那么gc时候就不用扫描bmap。 bmap指向溢出桶的字段overflow是uintptr类型,为了防止这些overflow桶被gc掉,所以需要mapextra.overflow将它保存起来。如果bmap的overflow是"bmap类型,那么gc扫描的是一个个拉链表,效率明显不如直接扫描一段内存
(hmap.mapextra.overflow)

在这里插入图片描述

总结
bmap (bucket)内存数据结构可视化如下:
注意到key和vaue 是各自放在一起的,要不是key/value/key/valuel…这样的形式,当key和value类型不一样的时候,key和value占用字节大小不一样,使用keylvalue这种形式可能会因为内存对齐导致内存空间浪费,所以Go采用key和value分开存储的设计,更节省内存空间

在这里插入图片描述

2.Go map遍历为什么是无序的?

使用range多次遍历map时输出的key和vaue的顺序可能不同。这是Go语言的设计者们有意为之,旨在提示开发者们,Co底层实现并不保证map遍历顺序稳定,请大家不要依赖range遍历结果顺序
主要原因有2点
.map在遍历时,并不是从固定的0号bucket开始遍历的,每次遍历,都会从一个随机值序号的bucket,再从其中随机的cell开始遍历
·map遍历时,是按序遍历bucket,同时按需遍历bucket中和其overflow bucket中的cell。但是map在扩容后,会发生key的搬迁,这造成原来落在一个bucket中t的%e y搬迁后,有可能会落到其他bucket中了,从这个角度看,遍历map的结果就不可能是按照原来的顺序了
map本身是无序的,且遍历时顺序还会被随机化,如果想顺序遍历map,需要对 map key先排序,再按照key 的顺序遍历map.

在这里插入图片描述

3. Go map为什么是非线程安全的?

map默认是并发不安全的,同时对map进行并发读写时,程序会panic,原因如下:
Go官方在经过了长时间的讨论后,认为Go map更应适配典型使用场景〈不需要从多个goroutine中进行安全访问),而不是为了小部分情况《(并发访问),导致大部分程序付出加锁代价(性能),决定了不支持。
场景:2个协程同时读和写,以下程序会出现致命错误: fatal error: concurrent map writes

在这里插入图片描述

如果想实现map线程安全,有两种方式:
方式一:使用读写锁map + sync.RWMutex

在这里插入图片描述

方式二:使用Go提供的sync.Map

在这里插入图片描述

4. Go map如何查找?

Go语言中读取map有两种语法︰带comma和不带comma。当要查询的key不在map里,带comma 的用法会返回一个bool型变量提示 key是否在map中;而不带comma的语句则会返回一个value类型的零值。如果value是int型就会返回0,如果value是string类型,就会返回空字符串。

在这里插入图片描述

map的查找通过生成汇编码可以知道,根据key 的不同类型/返回参数,编译器会将查找函数用更具体的函数替换,以优化效率:

在这里插入图片描述

查找流程

在这里插入图片描述

在这里插入图片描述

1.写保护监测
函数首先会检查map的标志位flags。如果flags的写标志位此时被置1了,说明有其他协程在执行"写"操作,进而导致程序panic,这也说明map不是线程安全的

在这里插入图片描述

2.计算hash值

在这里插入图片描述

key经过哈希函数计算后,得到的哈希值如下(主流64位机下共64个bit位),不同类型的key会有不同的hash函数

在这里插入图片描述

3.找到hash对应的bucket
bucket定位:哈希值的低B个bit位,用来定位key所存放的bucket
如果当前正在扩容中,并且定位到的旧bucket数据还未完成迁移,则使用旧的bucket (扩容前的bucket)

在这里插入图片描述

4.遍历bucket查找
tophash值定位︰哈希值的高8个bit位,用来快速判断key是否已在当前bucket中(如果不在的话,需要去bucket的overflow中查找)用步骤2中的hash值,得到高8个bit位,也就是10010111,转化为十进制,也就是151

在这里插入图片描述

上面函数中hash是64位的,sys.PtrSize值是8,所以 top := uintB(hash > (sys.PRtrSizex8 - 8))等效top = uint(hash >> 5),最后top取出来的值就是hash的高8位值
在bucket 及bucket的foverfiow中寻找tophash值(HOB hash)][为151*的槽位,即为key所在位置,找到了空槽位或者2号槽位,这样整个查找过程就结束了,其中找到空槽位代表没找到。

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

5.返回key对应的指针
如果通过上面的步骤找到了key对应的槽位下标i,我们再详细分析下key/value值是如何获取的:

在这里插入图片描述

bucket里keys的起始地址就是unsafe.Pointer(b)+dataOffset

第i个下标 key 的地址就要在此基础上跨过i个key的大小;
而我们又知道,value的地址是在所有key 之后,因此第i个下标value的地址还需要加上所有key 的偏移。

5. Go map 冲突的解决方式?

比较常用的Hash冲突解决方案有链地址法和开放寻址法:

链地址法

当哈希冲突发生时,创建新单元,并将新单元添加到冲突单元所在链表的尾部。
开放寻址法
当哈希冲突发生时,从发生冲突的那个单元起,按照一定的次序,从哈希表中寻找一个空闲的单元,然后把发生冲突的元素存入到该单元。开放寻址法需要的表长度要大于等于所需要存放的元素数量
开放寻址法有多种方式︰线性探测法、平方探测法、随机探测法和双重哈希法。这里以线性探测法来帮助读者理解开放寻址法思想线性探测法
设Hash( key)表示关键字key 的哈希值,表示哈希表的槽位数(哈希表的大小)。线性探测法则可以表示为:
如果 Hash(x)%M已经有数据,则尝试(Hash(x) +1)% M;
如果(Hash(x) + 1) % M也有数据了,则尝试(Hash(x) + 2)% M;
如果(Hash(x) + 2)% M也有数据了,则尝试《Hash(x) + 3)% M;两种解决方案比较
对于链地址法,基于数组+链表进行存储,链表节点可以在需要时再创建,不必像开放寻址法那样事先申请好足够内存,因此链地址法对于内存的利用率会比开方寻址法高。链地址法对装载因子的容忍度会更高,并且适合存储大对象、大数据量的哈希表。而且相较于开放寻址法,它更加灵活,支持更多的优化策略,比如可采用红黑树代替链表。但是链地址法需要额外的空间来存储指针。

对于开放寻址法,它只有数组一种数据结构就可完成存储,继承了数组的优点,对CPU缓存友好,易于序列化操作。但是它对内存的利用率不如链地址法,且发生冲突时代价更高。当数据量明确、装载因子小,适合采用开放寻址法。
总结
在发生哈希冲突时,Python中dict采用的开放寻址法,Java的HashMap采用的是链地址法,而Go map也采用链地址法解决冲突,具体就是插入key到map中时,当key定位的桶填满8个元素后(这里的单元就是桶,不是元素),将会创建一个溢出桶,并且将溢出桶插入当前桶所在链表尾部。

在这里插入图片描述

6. Go map的负载因子为什么是6.5?

什么是负载因子?
负载因子(load factor),用于衡量当前哈希表中空间占用率的核心指标,也就是每个bucket桶存储的平均元素个数。
负载因子=哈希表存储的元素个数/桶个数

另外负载因子与扩容、迁移等重新散列(rehash)行为有直接关系:
·在程序运行时,会不断地进行插入、删除等,会导致bucket不均,内存利用率低,需要迁移。·在程序运行时,出现负载因子过大,需要做扩容,解决bucket过大的问题。
负载因子是哈希表中的一个重要指标,在各种版本的哈希表实现中都有类似的东西,主要目的是为了平衡buckets 的存储空间大小和查找元素时的性能高低。在接触各种哈希表时都可以关注一下,做不同的对比,看看各家的考量。
为什么是6.5?
为什么Go语言中哈希表的负载因子是6.5,为什么不是8,也不是1。这里面有可靠的数据支撑吗?测试报告
实际上这是Go官方的经过认真的测试得出的数字,一起来看看官方的这份测试报告。
报告中共包含4个关键指标,如下:

在这里插入图片描述

loadFactor:负载因子,也有叫装载因子。

%overflow:溢出率,有溢出bukcet 的百分比。

bytes/entry:平均每对key/value的开销字节数。

hitprobe:查找一个存在的key时,要查找的平均个数。

missprobe:查找一个不存在的key时,要查找的平均个数。

选择数值

Go官方发现:装载因子越大,填入的元素越多,空间利用率就越高,但发生哈希冲突的几率就变大。反之,装载因子越小,填入的元素越少,冲突发生的几率减小,但空间浪费也会变得更多,而且还会提高扩容操作的次数
根据这份测试结果和讨论,Go官方取了一个相对适中的值,把Go中的 map的负载因子硬编码为6.5,这就是6.5的选择缘由。
这意味着在Go语言中,当map存储的元素个数大于或等于6.5*桶个数时,就会触发扩容行为。

7. Go map如何扩容?

扩容时机:
在向map插入新key的时候,会进行条件检测,符合下面这2个条件,就会触发扩容

在这里插入图片描述

扩容条件:
条件1:超过负载
map元素个数>6.5*桶个数

在这里插入图片描述

条件2:溢出桶太多
当桶总数<2^15时,如果溢出桶总数>=桶总数,则认为溢出桶过多。
当桶总数>=215时,直接与215比较,当溢出桶总数>=2^15时,即认为溢出桶太多了。

在这里插入图片描述

对于条件2,其实算是对条件1的补充。因为在负载因子比较小的情况下,有可能map的查找和插入效率也很低,而第1点识别不出来这种情况。
表面现象就是负载因子比较小比较小,即 map里元素总数少,但是桶数量多(真实分配的桶数量多,包括大量的溢出桶)。比如不断的增删,这样会造成overflow的bucket数量增多,但负载因子又不高,达不到第1点的临界值,就不能触发扩容来缓解这种情况。这样会造成桶的使用率不高,值存储得比较稀疏,查找插入效率会变得非常低,因此有了第2扩容条件。

扩容机制:
双倍扩容∶针对条件1,新建一个buckets数组,新的buckets大小是原来的2倍,然后旧buckets数据搬迁到新的buckets。该方法我们称之为双倍扩容
等量扩容∶针对条件2,并不扩大容量,buckets数量维持不变,重新做一遍类似双倍扩容的搬迁动作,把松散的键值对重新排列一次,使得同一个bucket中的key排列地更紧密,节省空间,提高 bucket利用率,进而保证更快的存取。该方法我们称之为等量扩容。

扩容函数:
上面说的hashGrow(〉函数实际上并没有真正地"搬迁",它只是分配好了新的 buckets,并将老的 buckets 挂到了oldbuckets字段上。真正搬迁 buckets 的动作在groMork()函数中,而调用growork()函数的动作是在mapassign和mapdelete函数中。也就是插入或修改、删除 key 的时候,都会尝试进行搬迁 buckets的工作。先检查oldbuckets是否搬迁完毕,具体来说就是检查oldbuckets是否为nil

在这里插入图片描述

在这里插入图片描述

由于map扩容需要将原有的keyvalue重新搬迁到新的内存地址,如果map存储了数以亿计的key -vaue,一次性搬迁将会造成比较大的延时,因此 Go map的扩容采取了一种称为**“渐进式”**的方式,原有的key并不会一次性搬迁完毕,每次最多只会搬迁2个bucket。

在这里插入图片描述

8. Go map和sync.Map谁的性能好,为什么?

Go语言的 sync.Map支持并发读写,采取了“空间换时间”的机制,冗余了两个数据结构,分别是:read和dirty

在这里插入图片描述

对比原始map:
和原始map+RWLock的实现并发的方式相比,减少了加锁对性能的影响。它做了一些优化∶可以无锁访问read map,而且会优先操作read map,倘若只操作read map就可以满足要求,那就不用去操作write map(dirty),所以在某些特定场景中它发生锁竞争的频率会远远小于map+RWLock的实现方式
优点:
适合读多写少的场景
缺点:
写多的场景,会导致read map缓存失效,需要加锁,冲突变多,性能急剧下降

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值