大橙子网站建设,新征程启航
为企业提供网站建设、域名注册、服务器等服务
衍生类型,interface{} , map, [] ,struct等
专注于为中小企业提供成都网站制作、成都网站建设服务,电脑端+手机端+微信端的三站合一,更高效的管理,为中小企业沧县免费做网站提供优质的服务。我们立足成都,凝聚了一批互联网行业人才,有力地推动了近千家企业的稳健成长,帮助中小企业通过网站建设实现规模扩充和转变。
map类似于java的hashmap,python的dict,php的hash array。
常规的for循环,可以用for k,v :=range m {}. 但在下面清空有一个坑注意:
著名的map[string]*struct 副本问题
结果:
Go 中不存在引用传递,所有的参数传递都是值传递,而map是等同于指针类型的,所以在把map变量传递给函数时,函数对map的修改,也会实质改变map的值。
slice类似于其他语言的数组(list,array),slice初始化和map一样,这里不在重复
除了Pointer数组外,len表示使用长度,cap是总容量,make([]int, len, cap)可以预申请 比较大的容量,这样可以减少容量拓展的消耗,前提是要用到。
cap是计算切片容量,len是计算变量长度的,两者不一样。具体例子如下:
结果:
分析:cap是计算当前slice已分配的容量大小,采用的是预分配的伙伴算法(当容量满时,拓展分配一倍的容量)。
append是slice非常常用的函数,用于添加数据到slice中,但如果使用不好,会有下面的问题:
预期是[1 2 3 4 5 6 7 8 9 10], [1 2 3 4 5 6 7 8 9 10 11 12],但实际结果是:
注意slice是值传递,修改一下:
输出如下:
== 只能用于判断常规数据类型,无法使用用于slice和map判断,用于判断map和slice可以使用reflect.DeepEqual,这个函数用了递归来判断每层的k,v是否一致。
当然还有其他方式,比如转换成json,但小心有一些异常的bug,比如html编码,具体这个json问题,待后面在分析。
不知道你有没有听过这么一句:在使用 map 时尽量不要在 big map 中保存指针。好吧,你现在已经听过了:)为什么呢?原因在于 Go 语言的垃圾回收器会扫描标记 map 中的所有元素,GC 开销相当大,直接GG。
这两天在《Mastering Go》中看到 GC 这一章节里面对比 map 和 slice 在垃圾回收中的效率对比,书中只给出结论没有说明理由,这我是不能忍的,于是有了这篇学习笔记。扯那么多,Show Your Code
这是一个简单的测试程序,保存字符串的 map 和 保存整形的 map GC 的效率相差几十倍,是不是有同学会说明明保存的是 string 哪有指针?这个要说到 Go 语言中 string 的底层实现了,源码在 src/runtime/string.go里,可以看到 string 其实包含一个指向数据的指针和一个长度字段。注意这里的是否包含指针,包括底层的实现。
Go 语言的 GC 会递归遍历并标记所有可触达的对象,标记完成之后将所有没有引用的对象进行清理。扫描到指针就会往下接着寻找,一直到结束。
Go 语言中 map 是基于 数组和链表 的数据结构实现的,通过 优化的拉链法 解决哈希冲突,每个 bucket 可以保存 8 对键值,在 8 个键值对数据后面有一个 overflow 指针,因为桶中最多只能装 8 个键值对,如果有多余的键值对落到了当前桶,那么就需要再构建一个桶(称为溢出桶),通过 overflow 指针链接起来。
因为 overflow 指针的缘故,所以无论 map 保存的是什么,GC 的时候就会把所有的 bmap 扫描一遍,带来巨大的 GC 开销。官方 issues 就有关于这个问题的讨论, runtime: Large maps cause significant GC pauses #9477
无脑机翻如下:
如果我们有一个map [k] v,其中k和v都不包含指针,并且我们想提高扫描性能,则可以执行以下操作。
将“ allOverflow [] unsafe.Pointer”添加到 hmap 并将所有溢出存储桶存储在其中。 然后将 bmap 标记为noScan。 这将使扫描非常快,因为我们不会扫描任何用户数据。
实际上,它将有些复杂,因为我们需要从allOverflow中删除旧的溢出桶。 而且它还会增加 hmap 的大小,因此也可能需要重新整理数据。
最终官方在 hmap 中增加了 overflow 相关字段完成了上面的优化,这是具体的 commit 地址。
下面看下具体是如何实现的,源码基于 go1.15,src/cmd/compile/internal/gc/reflect.go 中
通过注释可以看出,如果 map 中保存的键值都不包含指针(通过 Haspointers 判断),就使用一个 uintptr 类型代替 bucket 的指针用于溢出桶 overflow 字段,uintptr 类型在 GO 语言中就是个大小可以保存得下指针的整数,不是指针,就相当于实现了 将 bmap 标记为 noScan, GC 的时候就不会遍历完整个 map 了。随着不断的学习,愈发感慨 GO 语言中很多模块设计得太精妙了。
差不多说清楚了,能力有限,有不对的地方欢迎留言讨论,源码位置还是问的群里大佬 _
由于go语言是一个强类型的语言,因此hashmap也是有类型的,具体体现在key和value都必须指定类型,比如声明一个key为string,value也是string的map,
需要这样做
大部分类型都能做key,某些类型是不能的,共同的特点是: 不能使用== 来比较,包括: slice, map, function
在迭代的过程中是可以对map进行删除和更新操作的,规则如下:
golang的map是hash结构的,意味着平均访问时间是O(1)的。同传统的hashmap一样,由一个个bucket组成:
那我们怎么访问到对应的bucket呢,我们需要得到对应key的hash值
各个参数的意思:
目前采用的是这一行:
| 6.50 | 20.90 | 10.79 | 4.25 | 6.50 |
本文目录如下,阅读本文后,将一网打尽下面Golang Map相关面试题
Go中的map是一个指针,占用8个字节,指向hmap结构体; 源码 src/runtime/map.go 中可以看到map的底层结构
每个map的底层结构是hmap,hmap包含若干个结构为bmap的bucket数组。每个bucket底层都采用链表结构。接下来,我们来详细看下map的结构
bmap 就是我们常说的“桶”,一个桶里面会最多装 8 个 key,这些 key 之所以会落入同一个桶,是因为它们经过哈希计算后,哈希结果是“一类”的,关于key的定位我们在map的查询和插入中详细说明。在桶内,又会根据 key 计算出来的 hash 值的高 8 位来决定 key 到底落入桶内的哪个位置(一个桶内最多有8个位置)。
bucket内存数据结构可视化如下:
注意到 key 和 value 是各自放在一起的,并不是 key/value/key/value/... 这样的形式。源码里说明这样的好处是在某些情况下可以省略掉 padding字段,节省内存空间。
当 map 的 key 和 value 都不是指针,并且 size 都小于 128 字节的情况下,会把 bmap 标记为不含指针,这样可以避免 gc 时扫描整个 hmap。但是,我们看 bmap 其实有一个 overflow 的字段,是指针类型的,破坏了 bmap 不含指针的设想,这时会把 overflow 移动到 extra 字段来。
map是个指针,底层指向hmap,所以是个引用类型
golang 有三个常用的高级类型 slice 、map、channel, 它们都是 引用类型 ,当引用类型作为函数参数时,可能会修改原内容数据。
golang 中没有引用传递,只有值和指针传递。所以 map 作为函数实参传递时本质上也是值传递,只不过因为 map 底层数据结构是通过指针指向实际的元素存储空间,在被调函数中修改 map,对调用者同样可见,所以 map 作为函数实参传递时表现出了引用传递的效果。
因此,传递 map 时,如果想修改map的内容而不是map本身,函数形参无需使用指针
map 底层数据结构是通过指针指向实际的元素 存储空间 ,这种情况下,对其中一个map的更改,会影响到其他map
map 在没有被修改的情况下,使用 range 多次遍历 map 时输出的 key 和 value 的顺序可能不同。这是 Go 语言的设计者们有意为之,在每次 range 时的顺序被随机化,旨在提示开发者们,Go 底层实现并不保证 map 遍历顺序稳定,请大家不要依赖 range 遍历结果顺序。
map 本身是无序的,且遍历时顺序还会被随机化,如果想顺序遍历 map,需要对 map key 先排序,再按照 key 的顺序遍历 map。
map默认是并发不安全的,原因如下:
Go 官方在经过了长时间的讨论后,认为 Go map 更应适配典型使用场景(不需要从多个 goroutine 中进行安全访问),而不是为了小部分情况(并发访问),导致大部分程序付出加锁代价(性能),决定了不支持。
场景: 2个协程同时读和写,以下程序会出现致命错误:fatal error: concurrent map writes
如果想实现map线程安全,有两种方式:
方式一:使用读写锁 map + sync.RWMutex
方式二:使用golang提供的 sync.Map
sync.map是用读写分离实现的,其思想是空间换时间。和map+RWLock的实现方式相比,它做了一些优化:可以无锁访问read map,而且会优先操作read map,倘若只操作read map就可以满足要求(增删改查遍历),那就不用去操作write map(它的读写都要加锁),所以在某些特定场景中它发生锁竞争的频率会远远小于map+RWLock的实现方式。
golang中map是一个kv对集合。底层使用hash table,用链表来解决冲突 ,出现冲突时,不是每一个key都申请一个结构通过链表串起来,而是以bmap为最小粒度挂载,一个bmap可以放8个kv。在哈希函数的选择上,会在程序启动时,检测 cpu 是否支持 aes,如果支持,则使用 aes hash,否则使用 memhash。
map有3钟初始化方式,一般通过make方式创建
map的创建通过生成汇编码可以知道,make创建map时调用的底层函数是 runtime.makemap 。如果你的map初始容量小于等于8会发现走的是 runtime.fastrand 是因为容量小于8时不需要生成多个桶,一个桶的容量就可以满足
makemap函数会通过 fastrand 创建一个随机的哈希种子,然后根据传入的 hint 计算出需要的最小需要的桶的数量,最后再使用 makeBucketArray 创建用于保存桶的数组,这个方法其实就是根据传入的 B 计算出的需要创建的桶数量在内存中分配一片连续的空间用于存储数据,在创建桶的过程中还会额外创建一些用于保存溢出数据的桶,数量是 2^(B-4) 个。初始化完成返回hmap指针。
找到一个 B,使得 map 的装载因子在正常范围内
Go 语言中读取 map 有两种语法:带 comma 和 不带 comma。当要查询的 key 不在 map 里,带 comma 的用法会返回一个 bool 型变量提示 key 是否在 map 中;而不带 comma 的语句则会返回一个 value 类型的零值。如果 value 是 int 型就会返回 0,如果 value 是 string 类型,就会返回空字符串。
map的查找通过生成汇编码可以知道,根据 key 的不同类型,编译器会将查找函数用更具体的函数替换,以优化效率:
函数首先会检查 map 的标志位 flags。如果 flags 的写标志位此时被置 1 了,说明有其他协程在执行“写”操作,进而导致程序 panic。这也说明了 map 对协程是不安全的。
key经过哈希函数计算后,得到的哈希值如下(主流64位机下共 64 个 bit 位):
m: 桶的个数
从buckets 通过 hash m 得到对应的bucket,如果bucket正在扩容,并且没有扩容完成,则从oldbuckets得到对应的bucket
计算hash所在桶编号:
用上一步哈希值最后的 5 个 bit 位,也就是 01010 ,值为 10,也就是 10 号桶(范围是0~31号桶)
计算hash所在的槽位:
用上一步哈希值哈希值的高8个bit 位,也就是 10010111 ,转化为十进制,也就是151,在 10 号 bucket 中寻找** tophash 值(HOB hash)为 151* 的 槽位**,即为key所在位置,找到了 2 号槽位,这样整个查找过程就结束了。
如果在 bucket 中没找到,并且 overflow 不为空,还要继续去 overflow bucket 中寻找,直到找到或是所有的 key 槽位都找遍了,包括所有的 overflow bucket。
通过上面找到了对应的槽位,这里我们再详细分析下key/value值是如何获取的:
bucket 里 key 的起始地址就是 unsafe.Pointer(b)+dataOffset。第 i 个 key 的地址就要在此基础上跨过 i 个 key 的大小;而我们又知道,value 的地址是在所有 key 之后,因此第 i 个 value 的地址还需要加上所有 key 的偏移。
通过汇编语言可以看到,向 map 中插入或者修改 key,最终调用的是 mapassign 函数。
实际上插入或修改 key 的语法是一样的,只不过前者操作的 key 在 map 中不存在,而后者操作的 key 存在 map 中。
mapassign 有一个系列的函数,根据 key 类型的不同,编译器会将其优化为相应的“快速函数”。
我们只用研究最一般的赋值函数 mapassign 。
map的赋值会附带着map的扩容和迁移,map的扩容只是将底层数组扩大了一倍,并没有进行数据的转移,数据的转移是在扩容后逐步进行的,在迁移的过程中每进行一次赋值(access或者delete)会至少做一次迁移工作。
1.判断map是否为nil
每一次进行赋值/删除操作时,只要oldbuckets != nil 则认为正在扩容,会做一次迁移工作,下面会详细说下迁移过程
根据上面查找过程,查找key所在位置,如果找到则更新,没找到则找空位插入即可
经过前面迭代寻找动作,若没有找到可插入的位置,意味着需要扩容进行插入,下面会详细说下扩容过程
通过汇编语言可以看到,向 map 中删除 key,最终调用的是 mapdelete 函数
删除的逻辑相对比较简单,大多函数在赋值操作中已经用到过,核心还是找到 key 的具体位置。寻找过程都是类似的,在 bucket 中挨个 cell 寻找。找到对应位置后,对 key 或者 value 进行“清零”操作,将 count 值减 1,将对应位置的 tophash 值置成 Empty
再来说触发 map 扩容的时机:在向 map 插入新 key 的时候,会进行条件检测,符合下面这 2 个条件,就会触发扩容:
1、装载因子超过阈值
源码里定义的阈值是 6.5 (loadFactorNum/loadFactorDen),是经过测试后取出的一个比较合理的因子
我们知道,每个 bucket 有 8 个空位,在没有溢出,且所有的桶都装满了的情况下,装载因子算出来的结果是 8。因此当装载因子超过 6.5 时,表明很多 bucket 都快要装满了,查找效率和插入效率都变低了。在这个时候进行扩容是有必要的。
对于条件 1,元素太多,而 bucket 数量太少,很简单:将 B 加 1,bucket 最大数量( 2^B )直接变成原来 bucket 数量的 2 倍。于是,就有新老 bucket 了。注意,这时候元素都在老 bucket 里,还没迁移到新的 bucket 来。新 bucket 只是最大数量变为原来最大数量的 2 倍( 2^B * 2 ) 。
2、overflow 的 bucket 数量过多
在装载因子比较小的情况下,这时候 map 的查找和插入效率也很低,而第 1 点识别不出来这种情况。表面现象就是计算装载因子的分子比较小,即 map 里元素总数少,但是 bucket 数量多(真实分配的 bucket 数量多,包括大量的 overflow bucket)
不难想像造成这种情况的原因:不停地插入、删除元素。先插入很多元素,导致创建了很多 bucket,但是装载因子达不到第 1 点的临界值,未触发扩容来缓解这种情况。之后,删除元素降低元素总数量,再插入很多元素,导致创建很多的 overflow bucket,但就是不会触发第 1 点的规定,你能拿我怎么办?overflow bucket 数量太多,导致 key 会很分散,查找插入效率低得吓人,因此出台第 2 点规定。这就像是一座空城,房子很多,但是住户很少,都分散了,找起人来很困难
对于条件 2,其实元素没那么多,但是 overflow bucket 数特别多,说明很多 bucket 都没装满。解决办法就是开辟一个新 bucket 空间,将老 bucket 中的元素移动到新 bucket,使得同一个 bucket 中的 key 排列地更紧密。这样,原来,在 overflow bucket 中的 key 可以移动到 bucket 中来。结果是节省空间,提高 bucket 利用率,map 的查找和插入效率自然就会提升。
由于 map 扩容需要将原有的 key/value 重新搬迁到新的内存地址,如果有大量的 key/value 需要搬迁,会非常影响性能。因此 Go map 的扩容采取了一种称为“渐进式”的方式,原有的 key 并不会一次性搬迁完毕,每次最多只会搬迁 2 个 bucket。
上面说的 hashGrow() 函数实际上并没有真正地“搬迁”,它只是分配好了新的 buckets,并将老的 buckets 挂到了 oldbuckets 字段上。真正搬迁 buckets 的动作在 growWork() 函数中,而调用 growWork() 函数的动作是在 mapassign 和 mapdelete 函数中。也就是插入或修改、删除 key 的时候,都会尝试进行搬迁 buckets 的工作。先检查 oldbuckets 是否搬迁完毕,具体来说就是检查 oldbuckets 是否为 nil。
如果未迁移完毕,赋值/删除的时候,扩容完毕后(预分配内存),不会马上就进行迁移。而是采取 增量扩容 的方式,当有访问到具体 bukcet 时,才会逐渐的进行迁移(将 oldbucket 迁移到 bucket)
nevacuate 标识的是当前的进度,如果都搬迁完,应该和2^B的长度是一样的
在evacuate 方法实现是把这个位置对应的bucket,以及其冲突链上的数据都转移到新的buckets上。
转移的判断直接通过tophash 就可以,判断tophash中第一个hash值即可
遍历的过程,就是按顺序遍历 bucket,同时按顺序遍历 bucket 中的 key。
map遍历是无序的,如果想实现有序遍历,可以先对key进行排序
为什么遍历 map 是无序的?
如果发生过迁移,key 的位置发生了重大的变化,有些 key 飞上高枝,有些 key 则原地不动。这样,遍历 map 的结果就不可能按原来的顺序了。
如果就一个写死的 map,不会向 map 进行插入删除的操作,按理说每次遍历这样的 map 都会返回一个固定顺序的 key/value 序列吧。但是 Go 杜绝了这种做法,因为这样会给新手程序员带来误解,以为这是一定会发生的事情,在某些情况下,可能会酿成大错。
Go 做得更绝,当我们在遍历 map 时,并不是固定地从 0 号 bucket 开始遍历,每次都是从一个**随机值序号的 bucket 开始遍历,并且是从这个 bucket 的一个 随机序号的 cell **开始遍历。这样,即使你是一个写死的 map,仅仅只是遍历它,也不太可能会返回一个固定序列的 key/value 对了。
sync.Map是1.9才推荐的并发安全的map,除了互斥量以外,还运用了原子操作,所以在这之前,有必要了解下 Go语言——原子操作
go1.10\src\sync\map.go
entry分为三种情况:
从read中读取key,如果key存在就tryStore。
注意这里开始需要加锁,因为需要操作dirty。
条目在read中,首先取消标记,然后将条目保存到dirty里。(因为标记的数据不在dirty里)
最后原子保存value到条目里面,这里注意read和dirty都有条目。
总结一下Store:
这里可以看到dirty保存了数据的修改,除非可以直接原子更新read,继续保持read clean。
有了之前的经验,可以猜测下load流程:
与猜测的 区别 :
由于数据保存两份,所以删除考虑:
先看第二种情况。加锁直接删除dirty数据。思考下貌似没什么问题,本身就是脏数据。
第一种和第三种情况唯一的区别就是条目是否被标记。标记代表删除,所以直接返回。否则CAS操作置为nil。这里总感觉少点什么,因为条目其实还是存在的,虽然指针nil。
看了一圈貌似没找到标记的逻辑,因为删除只是将他变成nil。
之前以为这个逻辑就是简单的将为标记的条目拷贝给dirty,现在看来大有文章。
p == nil,说明条目已经被delete了,CAS将他置为标记删除。然后这个条目就不会保存在dirty里面。
这里其实就跟miss逻辑串起来了,因为miss达到阈值之后,dirty会全量变成read,也就是说标记删除在这一步最终删除。这个还是很巧妙的。
真正的删除逻辑:
很绕。。。。
golang 中 map的实现结构为: 哈希表 + 链表。 其中链表,作用是当发生hash冲突时,拉链法生成的结点。
可以看到, []bmap 是一个hash table, 每一个 bmap是我们常说的“桶”。 经过hash 函数计算出来相同的hash值, 放到相同的桶中。 一个 bmap中可以存放 8个 元素, 如果多出8个,则生成新的结点,尾接到队尾。
以上是只是静态文件 src/runtime/map.go 中的定义。 实际上编译期间会给它加料 ,动态地创建一个新的结构:
上图就是 bmap的内存模型, HOB Hash 指的就是 top hash。 注意到 key 和 value 是各自放在一起的,并不是 key/value/key/value/... 这样的形式。源码里说明这样的好处是在某些情况下可以省略掉 padding 字段,节省内存空间。
每个 bmap设计成 最多只能放 8 个 key-value 对 ,如果有第 9 个 key-value 落入当前的 bmap,那就需要再构建一个 bmap,通过 overflow 指针连接起来。
map创建方法:
我们实际上是通过调用的 makemap ,来创建map的。实际工作只是初始化了hmap中的各种字段,如:设置B的大小, 设置hash 种子 hash 0.
注意 :
makemap 返回是*hmap 指针, 即 map 是引用对象, 对map的操作会影响到结构体内部 。
使用方式
对应的是下面两种方法
map的key的类型,实现了自己的hash 方式。每种类型实现hash函数方式不一样。
key 经过哈希计算后得到hash值,共 64 个 bit 位。 其中后B 个bit位置, 用来定位当前元素落在哪一个桶里, 高8个bit 为当前 hash 值的top hash。 实际上定位key的过程是一个双重循环的过程, 外层循环遍历 所有的overflow, 内层循环遍历 当前bmap 中的 8个元素 。
举例说明: 如果当前 B 的值为 5, 那么buckets 的长度 为 2^5 = 32。假设有个key 经过hash函数计算后,得到的hash结果为:
外层遍历bucket 中的链表
内层循环遍历 bmap中的8个 cell
建议先不看此部分内容,看完后续 修改 map中元素 - 扩容 操作后 再回头看此部分内容。
扩容前的数据:
等量扩容后的数据:
等量扩容后,查找方式和原本相同, 不多做赘述。
两倍扩容后的数据
两倍扩容后,oldbuckets 的元素,可能被分配成了两部分。查找顺序如下:
此处只分析 mapaccess1 ,。 mapaccess2 相比 mapaccess1 多添加了是否找到的bool值, 有兴趣可自行看一下。
使用方式:
步骤如下:
扩容条件 :
扩容的标识 : h.oldbuckets != nil
假设当前定位到了新的buckets的3号桶中,首先会判断oldbuckets中的对应的桶有没有被搬迁过。 如果搬迁过了,不需要看原来的桶了,直接遍历新的buckets的3号桶。
扩容前:
等量扩容结果
双倍扩容会将old buckets上的元素分配到x, y两个部key 1 B == 0 分配到x部分,key 1 B == 1 分配到y部分
注意: 当前只对双倍扩容描述, 等量扩容只是重新填充了一下元素, 相对位置没有改变。
假设当前map 的B == 5,原本元素经过hash函数计算的 hash 值为:
因为双倍扩容之后 B = B + 1,此时B == 6。key 1 B == 1, 即 当前元素rehash到高位,新buckets中 y 部分. 否则 key 1 B == 0 则rehash到低位,即x 部分。
使用方式:
可以看到,每一遍历生成迭代器的时候,会随机选取一个bucket 以及 一个cell开始。 从前往后遍历,再次遍历到起始位置时,遍历完成。