本文转载自微信公众号「脑子进煎鱼了」,作者陈煎鱼 。转载本文请联系脑子进煎鱼了公众号。
我们拥有十多年网页设计和网站建设经验,从网站策划到网站制作,我们的网页设计师为您提供的解决方案。为企业提供成都做网站、成都网站建设、微信开发、重庆小程序开发、手机网站开发、H5技术、等业务。无论您有什么样的网站设计或者设计方案要求,我们都将富于创造性的提供专业设计服务并满足您的需求。
大家好,我是煎鱼。
在上篇 《一文啃透 Go map:初始化和访问》中,我们在数据结构小节里讲解了大量基础字段,可能你会疑惑需要 #&(!……#(!¥!来干嘛?
今天我们一起简单了解一下基础概念,再开始研讨 map 的重点内容。我相信这样你能更好的读懂这篇文章。
哈希函数,又称散列算法、散列函数。主要作用是通过特定算法将数据根据一定规则组合重新生成得到一个散列值。
在哈希表中,其生成的散列值常用于寻找其键映射到哪一个桶上。
一个好的哈希函数,应当尽量少的出现哈希冲突,以此保证操作哈希表的时间复杂度(但是哈希冲突在目前来讲,是无法避免的。我们需要 “解决” 它)。
在哈希操作中,相当核心的一个处理动作就是 “哈希冲突” 的解决。而在 Go map 中采用的就是 "链地址法 " 去解决哈希冲突,又称 "拉链法"。
主要做法是数组 + 链表的数据结构,其溢出节点的存储内存都是动态申请的,因此相对更灵活。而每一个元素都是一个链表。
如下图:
- type hmap struct {
- ...
- buckets unsafe.Pointer
- ...
- extra *mapextra
- }
- type mapextra struct {
- overflow *[]*bmap
- oldoverflow *[]*bmap
- nextOverflow *bmap
- }
在上章节中,我们介绍了 Go map 中的桶和溢出桶的概念,在其桶中只能存储 8 个键值对元素。当超过 8 个时,将会使用溢出桶进行存储或进行扩容。
你可能会有疑问,hint 大于 8 又会怎么样?答案很明显,性能问题,其时间复杂度改变(也就是执行效率出现问题)。
基本概要复习的差不多后,接下来我们将一同研讨 Go map 的另外三个核心行为:
正式开始我们的研讨之旅吧 :)
- m := make(map[int32]string)
- m[0] = "EDDYCJY"
在 map 的赋值动作中,依旧是针对 32/64 位、string、pointer 类型有不同的转换处理,总的函数原型如下:
- func mapassign(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer
- func mapaccess1_fast32(t *maptype, h *hmap, key uint32) unsafe.Pointer
- func mapaccess2_fast32(t *maptype, h *hmap, key uint32) (unsafe.Pointer, bool)
- func mapassign_fast32(t *maptype, h *hmap, key uint32) unsafe.Pointer
- func mapassign_fast32ptr(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer
- func mapaccess1_fast64(t *maptype, h *hmap, key uint64) unsafe.Pointer
- func mapaccess2_fast64(t *maptype, h *hmap, key uint64) (unsafe.Pointer, bool)
- func mapassign_fast64(t *maptype, h *hmap, key uint64) unsafe.Pointer
- func mapassign_fast64ptr(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer
- func mapaccess1_faststr(t *maptype, h *hmap, ky string) unsafe.Pointer
- func mapaccess2_faststr(t *maptype, h *hmap, ky string) (unsafe.Pointer, bool)
- func mapassign_faststr(t *maptype, h *hmap, s string) unsafe.Pointer
- ...
接下来我们将分成几个部分去看看底层在赋值的时候,都做了些什么处理?
第一阶段:校验和初始化
- func mapassign(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {
- if h == nil {
- panic(plainError("assignment to entry in nil map"))
- }
- ...
- if h.flags&hashWriting != 0 {
- throw("concurrent map writes")
- }
- alg := t.key.alg
- hash := alg.hash(key, uintptr(h.hash0))
- h.flags |= hashWriting
- if h.buckets == nil {
- h.buckets = newobject(t.bucket) // newarray(t.bucket, 1)
- }
- ...
- }
第二阶段:寻找可插入位和更新既有值
- ...
- again:
- bucket := hash & bucketMask(h.B)
- if h.growing() {
- growWork(t, h, bucket)
- }
- b := (*bmap)(unsafe.Pointer(uintptr(h.buckets) + bucket*uintptr(t.bucketsize)))
- top := tophash(hash)
- var inserti *uint8
- var insertk unsafe.Pointer
- var val unsafe.Pointer
- for {
- for i := uintptr(0); i < bucketCnt; i++ {
- if b.tophash[i] != top {
- if b.tophash[i] == empty && inserti == nil {
- inserti = &b.tophash[i]
- insertk = add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize))
- val = add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.valuesize))
- }
- continue
- }
- k := add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize))
- if t.indirectkey {
- k = *((*unsafe.Pointer)(k))
- }
- if !alg.equal(key, k) {
- continue
- }
- // already have a mapping for key. Update it.
- if t.needkeyupdate {
- typedmemmove(t.key, k, key)
- }
- val = add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.valuesize))
- goto done
- }
- ovf := b.overflow(t)
- if ovf == nil {
- break
- }
- b = ovf
- }
- if !h.growing() && (overLoadFactor(h.count+1, h.B) || tooManyOverflowBuckets(h.noverflow, h.B)) {
- hashGrow(t, h)
- goto again // Growing the table invalidates everything, so try again
- }
- ...
总的来讲,这一块逻辑做了两件大事:
若是第二种场景,更新完毕后就会进行收尾动作,第一种将继续执行下述的代码。
第三阶段:申请新的插入位和插入新值
- ...
- if inserti == nil {
- newb := h.newoverflow(t, b)
- inserti = &newb.tophash[0]
- insertk = add(unsafe.Pointer(newb), dataOffset)
- val = add(insertk, bucketCnt*uintptr(t.keysize))
- }
- if t.indirectkey {
- kmem := newobject(t.key)
- *(*unsafe.Pointer)(insertk) = kmem
- insertk = kmem
- }
- if t.indirectvalue {
- vmem := newobject(t.elem)
- *(*unsafe.Pointer)(val) = vmem
- }
- typedmemmove(t.key, insertk, key)
- *inserti = top
- h.count++
- one:
- ...
- return val
经过前面迭代寻找动作,若没有找到可插入的位置,意味着当前的所有桶都满了,将重新分配一个新溢出桶用于插入动作。
最后再在上一步申请的新插入位置,存储键值对,返回该值的内存地址。
第四阶段:写入
但是这里又疑惑了,最后为什么是返回内存地址?
这是因为隐藏的最后一步写入动作(将值拷贝到指定内存区域)是通过底层汇编配合来完成的,在 runtime 中只完成了绝大部分的动作。
写入 map 的示例代码:
- func main() {
- m := make(map[int32]int32)
- m[0] = 6666666
- }
对应的汇编部分:
- ...
- 0x0099 00153 (test.go:6) CALL runtime.mapassign_fast32(SB)
- 0x009e 00158 (test.go:6) PCDATA $2, $2
- 0x009e 00158 (test.go:6) MOVQ 24(SP), AX
- 0x00a3 00163 (test.go:6) PCDATA $2, $0
- 0x00a3 00163 (test.go:6) MOVL $6666666, (AX)
这里分为了几个部位,主要是调用 mapassign 函数和拿到值存放的内存地址,再将 6666666 这个值存放进该内存地址中。
另外我们看到 PCDATA 指令,主要是包含一些垃圾回收的信息,由编译器产生。
通过前面几个阶段的分析,我们可梳理出一些要点。例如:
在所有动作中,扩容规则是大家较关注的点,也是赋值里非常重要的一环。因此咱们将这节拉出来,对这块细节进行研讨。
- if !h.growing() && (overLoadFactor(h.count+1, h.B) || tooManyOverflowBuckets(h.noverflow, h.B)) {
- hashGrow(t, h)
- goto again
- }
在特定条件的情况下且当前没有正在进行扩容动作(以判断 hmap.oldbuckets != nil 为基准)。哈希表在赋值、删除的动作下会触发扩容行为。
条件如下:
那么什么情况下会对这两个 “值” 有影响呢?如下:
负载因子 load factor,用途是评估哈希表当前的时间复杂度,其与哈希表当前包含的键值对数、桶数量等相关。
溢出桶 overflow buckets 的判定与 buckets 总数和 overflow buckets 总数相关联。
loadFactor | %overflow | bytes/entry | hitprobe | missprobe |
---|---|---|---|---|
4.00 | 2.13 | 20.77 | 3.00 | 4.00 |
4.50 | 4.05 | 17.30 | 3.25 | 4.50 |
5.00 | 6.85 | 14.77 | 3.50 | 5.00 |
5.50 | 10.55 | 12.94 | 3.75 | 5.50 |
6.00 | 15.27 | 11.67 | 4.00 | 6.00 |
6.50 | 20.90 | 10.79 | 4.25 | 6.50 |
7.00 | 27.14 | 10.15 | 4.50 | 7.00 |
这一组数据能够体现出不同的负载因子会给哈希表的动作带来怎么样的影响。
在上一章节中,有提到默认的负载因子是 6.5 (loadFactorNum/loadFactorDen),可以看出来是经过测试后取出的一个比较合理的因子。
其能够较好的影响哈希表的扩容动作的时机。
- func hashGrow(t *maptype, h *hmap) {
- 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)
- ...
- h.oldbuckets = oldbuckets
- h.buckets = newbuckets
- h.nevacuate = 0
- h.noverflow = 0
- if h.extra != nil && h.extra.overflow != nil {
- 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().
- }
第一阶段:确定扩容容量规则
在上小节有讲到扩容的依据有两种,在 hashGrow 开头就进行了划分。如下:
- if !overLoadFactor(h.count+1, h.B) {
- bigger = 0
- h.flags |= sameSizeGrow
- }
若不是负载因子 load factor 超过当前界限,也就是属于溢出桶 overflow buckets 过多的情况。
因此本次扩容规则将是 sameSizeGrow,即是不改变大小的扩容动作。那要是前者的情况呢?
- bigger := uint8(1)
- ...
- newbuckets, nextOverflow := makeBucketArray(t, h.B+bigger, nil)
结合代码分析可得出,若是负载因子 load factor 达到当前界限,将会动态扩容当前大小的两倍作为其新容量大小。
第二阶段:初始化、交换新旧 桶/溢出桶
主要是针对扩容的相关数据前置处理,涉及 buckets/oldbuckets、overflow/oldoverflow 之类与存储相关的字段
- ...
- oldbuckets := h.buckets
- newbuckets, nextOverflow := makeBucketArray(t, h.B+bigger, nil)
- flags := h.flags &^ (iterator | oldIterator)
- if h.flags&iterator != 0 {
- flags |= oldIterator
- }
- h.B += bigger
- ...
- h.noverflow = 0
- if h.extra != nil && h.extra.overflow != nil {
- ...
- h.extra.oldoverflow = h.extra.overflow
- h.extra.overflow = nil
- }
- if nextOverflow != nil {
- ...
- h.extra.nextOverflow = nextOverflow
- }
这里注意到这段代码:newbuckets, nextOverflow := makeBucketArray(t, h.B+bigger, nil)。
第一反应是扩容的时候就马上申请并初始化内存了吗?假设涉及大量的内存分配,那挺耗费性能的...
然而并不,内部只会先进行预分配,当使用的时候才会真正的去初始化
在源码中,发现第三阶段的流转并没有显式展示。这是因为流转由底层去做控制了。
但通过分析代码和注释,可得知由第三阶段涉及 growWork 和 evacuate 方法。如下:
- func growWork(t *maptype, h *hmap, bucket uintptr) {
- evacuate(t, h, bucket&h.oldbucketmask())
- if h.growing() {
- evacuate(t, h, h.nevacuate)
- }
- }
在该方法中,主要是两个 evacuate 函数的调用。他们在调用上又分别有什么区别呢?如下:
另外,在执行扩容动作的时候,可以发现都是以 bucket/oldbucket 为单位的,而不是传统的 buckets/oldbuckets。
再结合代码分析,可得知在 Go map 中扩容是采取增量扩容的方式,并非一步到位。
为什么是增量扩容?
如果是全量扩容的话,那问题就来了。假设当前 hmap 的容量比较大,直接全量扩容的话,就会导致扩容要花费大量的时间和内存,导致系统卡顿,最直观的表现就是慢。
显然,不能这么做。
而增量扩容,就可以解决这个问题。它通过每一次的 map 操作行为去分摊总的一次性动作。
因此有了 buckets/oldbuckets 的设计,它是逐步完成的,并且会在扩容完毕后才进行清空。
通过前面三个阶段的分析,可以得知扩容的大致过程。我们阶段性总结一下。主要如下:
这时候又想到,既然迁移是逐步进行的。那如果在途中又要扩容了,怎么办?
- again:
- bucket := hash & bucketMask(h.B)
- ...
- if !h.growing() && (overLoadFactor(h.count+1, h.B) || tooManyOverflowBuckets(h.noverflow, h.B)) {
- hashGrow(t, h)
- goto again
- }
在这里注意到 goto again 语句,结合上下文可得若正在进行扩容,就会不断地进行迁移,待迁移完毕后才会开始进行下一次的扩容动作。
在扩容的完整闭环中,包含着迁移的动作,又称 “搬迁”。因此我们继续深入研究 evacuate 函数。接下来一起打开迁移世界的大门。如下:
- type evacDst struct {
- b *bmap
- i int
- k unsafe.Pointer
- v unsafe.Pointer
- }
evacDst 是迁移中的基础数据结构,其包含如下字段:
- func evacuate(t *maptype, h *hmap, oldbucket uintptr) {
- b := (*bmap)(add(h.oldbuckets, oldbucket*uintptr(t.bucketsize)))
- newbit := h.noldbuckets()
- if !evacuated(b) {
- var xy [2]evacDst
- x := &xy[0]
- x.b = (*bmap)(add(h.buckets, oldbucket*uintptr(t.bucketsize)))
- x.k = add(unsafe.Pointer(x.b), dataOffset)
- x.v = add(x.k, bucketCnt*uintptr(t.keysize))
- if !h.sameSizeGrow() {
- y := &xy[1]
- y.b = (*bmap)(add(h.buckets, (oldbucket+newbit)*uintptr(t.bucketsize)))
- y.k = add(unsafe.Pointer(y.b), dataOffset)
- y.v = add(y.k, bucketCnt*uintptr(t.keysize))
- }
- for ; b != nil; b = b.overflow(t) {
- ...
- }
- if h.flags&oldIterator == 0 && t.bucket.kind&kindNoPointers == 0 {
- b := add(h.oldbuckets, oldbucket*uintptr(t.bucketsize))
- ptr := add(b, dataOffset)
- n := uintptr(t.bucketsize) - dataOffset
- memclrHasPointers(ptr, n)
- }
- }
- if oldbucket == h.nevacuate {
- advanceEvacuationMark(h, t, newbit)
- }
- }
总的来讲,就是计算得到所需数据的位置。再根据当前的迁移状态、扩容规则进行数据分流迁移。结束后进行清理,促进 GC 的回收。
在本章节我们主要研讨了 Go map 的几个核心动作,分别是:“赋值、扩容、迁移” 。而通过本次的阅读,我们能够更进一步的认识到一些要点,例如:
最后恭喜你通过本文的阅读,能更清楚地了解到 Go map 是怎么样运作的 :)
分享标题:一篇文章把GoMap赋值和扩容扒干净!
本文路径:http://www.csdahua.cn/qtweb/news11/519511.html
网站建设、网络推广公司-快上网,是专注品牌与效果的网站制作,网络营销seo公司;服务项目有等
声明:本网站发布的内容(图片、视频和文字)以用户投稿、用户转载内容为主,如果涉及侵权请尽快告知,我们将会在第一时间删除。文章观点不代表本网站立场,如需处理请联系客服。电话:028-86922220;邮箱:631063699@qq.com。内容未经允许不得转载,或转载时需注明来源: 快上网