当前位置: 首页 > news >正文

go语言map扩容

map是什么?

​在Go语言中,map是一种内置的无序key/value键值对的集合,可以根据key在O(1)的时间复杂度内取到value,有点类似于数组或者切片结构,可以把数组看作是一种特殊的map,数组的key为数组的下标,而map的key可以为任意的可比较结构。在map中key不允许重复并且要能够比较。

在go语言中,map的底层采用hash表,用变种拉链法来解决hash冲突问题。

哈希冲突

基本概念:

哈希冲突(哈希碰撞)是指在使用哈希函数时,两个不同的输入值(称为键或关键字)产生了相同的输出值(哈希码或哈希值)的现象。换句话说,当hash(key₁) = hash(key₂)但key₁ ≠ key₂时,就发生了哈希冲突。

简单来说就是两个不同的键被映射到同一哈希桶

产生原因:

  • 哈希表容量限制:即使哈希函数能够均匀分布哈希值,但是当哈希表容量有限时,通过取模运算(如hash(key) mod size)仍可能导致不同哈希值映射到同一位置。
  • 哈希函数设置:设计不佳的哈希函数可能无法将键均匀分布到哈希空间中,导致某些区域过于集中,会增加冲突概率。
  • 数据分布特性:当输入数据具有特定的分布模式或集中在某个范围内时,经过哈希计算后更容易产生冲突。例如,大量相似的字符串作为键可能导致冲突率升高。

解决哈希冲突一般有两种方式:拉链法和开放寻址法

拉链法

​拉链法是一种最常见的解决哈希冲突的方法,很多语言都是用拉链法哈希冲突。拉链法的主要实现是底层不直接使用连续数组来直接存储数据元素,而是使用通过数组和链表组合连使用,数组里存储的其实是一个指针,指向一个链表。当出现两个key比如key1和key2的哈希值相同的情况,就将数据链接到链表上。拉链法处理冲突简单,可以动态的申请内存,删除增加节点都很方便,当冲突严重,链表长度过长的时候也支持更多的优化策略,比如用红黑树代替链表。

拉链法结构:

在这里插入图片描述

左边为一个连续数组,数组每个元素存储一个指针,指向一个链表,链表里每个节点存储的是发生hash冲突的数据

开放地址法

开放地址法是另外一种非常常用的解决哈希冲突的策略,与拉链法不同,开放地址法是将具体的数据元素存储在数组桶中,在要插入新元素时,先根据哈希函数算出hash值,根据hash值计算索引,如果发现冲突了,计算出的数组索引位置已经有数据了,就继续向后探测,直到找到未使用的数据槽为止。哈希函数可以简单地为:hash(key)=(hash1(key)+i)%len(buckets)

开放地址法结构:

在这里插入图片描述

在存储键值对<b,101>的时候,经过hash计算,发现原本应该存放在数组下标为2的位置已经有值了,存放了<a,100>,就继续向后探测,发现数组下标为3的位置是空槽,未被使用,就将<b,101>存放在这个位置。

Go中Map的底层

go语言中的map其实就是一个指向hmap的指针,占用8个字节。所以map底层结构就是hmap,hmap包含多个结构为bmap的bucket数组,当发生冲突的时候,会到正常桶里面的overflow指针所指向的溢出桶里面去找,Go语言中溢出桶也是一个动态数组形式,它是根据需要动态创建的。Go语言中处理冲突采用了优化的拉链法,链表中每个节点存储的不是一个键值对,而是8个键值对。其整体的结构如下图:

在这里插入图片描述

hmap结构体定义:

// A header for a Go map.
type hmap struct {// Note: the format of the hmap is also encoded in cmd/compile/internal/reflectdata/reflect.go.// Make sure this stays in sync with the compiler's definition.​count     int // # live cells == size of map.  Must be first (used by len() builtin)​flags     uint8B         uint8  // log_2 of # of buckets (can hold up to loadFactor * 2^B items)​noverflow uint16 // approximate number of overflow buckets; see incrnoverflow for detailshash0     uint32 // hash seedbuckets    unsafe.Pointer // array of 2^B Buckets. may be nil if count==0.​oldbuckets unsafe.Pointer // previous bucket array of half the size, non-nil only when growingnevacuate  uintptr        // progress counter for evacuation (buckets less than this have been evacuated)extra *mapextra // optional fields
}

结构体中各个字段的含义

字段释义
countmap中元素的个数,对应len(map)的值
flags状态标志位,标记map的一些状态
B桶数以2为底的对数,即B=log2(len(buckets)), 如B = 3,那么桶数为2^3 = 8
noverflow溢出桶数量近似值
hash0哈希种子
buckets指向buckets数组的指针,buckets数组的元素为bmap,如果元素个数为0,其值为nil
oldbuckets是一个指向buckets数组的指针,在扩容时,oldbuckets指向老的buckets数组,非扩容时,oldbuckets为空
nevacuate表示扩容进度的一个计数器,小于该值的桶已经迁移完毕
extra指向mapextra结构的指针,mapextra存储map中的溢出桶

mapextra结构体定义

// mapextra holds fields that are not present on all maps.
type mapextra struct {// If both key and elem do not contain pointers and are inline, then we mark bucket// type as containing no pointers. This avoids scanning such maps.// However, bmap.overflow is a pointer. In order to keep overflow buckets// alive, we store pointers to all overflow buckets in hmap.extra.overflow and hmap.extra.oldoverflow.// overflow and oldoverflow are only used if key and elem do not contain pointers.// overflow contains overflow buckets for hmap.buckets.// oldoverflow contains overflow buckets for hmap.oldbuckets.// The indirection allows to store a pointer to the slice in hiter.overflow    *[]*bmapoldoverflow *[]*bmap// nextOverflow holds a pointer to a free overflow bucket.nextOverflow *bmap
}

各个字段含义

字段释义
overflow溢出桶链表地址
oldoverflow旧桶使用的溢出桶链表地址
nextoverflow下一个空闲桶地址

hmap中真正用于存储数据的是buckets指向的这个bmap(桶)数组,每一个bmap 都能存储8个键值对,当map中的数据过多,bmap数组存不下的时候就会存储到extra指向的溢出bucket(桶)里面

bmap结构体定义

type bmap struct {topbits  [8]uint8keys     [8]keytypevalues   [8]valuetypeoverflow uintptr
}

各个字段含义

字段释义
topbits存储了bmap里面8个key/value键值对的每个key根据哈希函数计算出的hash值的高8位
keys存储了bmap里面8个key/value键值对的key
values存储了bmap里面8个key/value键值对的value
overflow指向溢出桶的指针

go语言的map会根据每一个key计算出一个hash值,go中对这个hash值得使用,并不是一次性使用的,而是分开使用的,在使用中将hash按照用途可以分为:高位和低位

在这里插入图片描述

假设对一个key做hash计算得到了一个hash值如图所示,蓝色就是这个hash值的高8位,红色就是这个hash值的低8位。而每个bmap中其实存储的就是8个这个蓝色的数字。

通过上图map的底层结构图可以看到bmap的结构,bmap显示存储了8个tohash值,然后存储了8个键值对注意,这8个键值对并不是按照key/value这样key和value放在一起存储的,而是先连续存完8个key,之后再连续存储8个value这样,当键值对不够8个时,对应位置就留空。这样存储的好处是可以消除字节对齐带来的空间浪费。

map的扩容

​随着不断地往map里写入元素,会导致map的数据量变得很大,hash性能会逐渐变差,而且溢出桶会越来越多,导致查找的性能变得很差。所以,需要更多的桶和更大的内存保证哈希的读写性能,这时map会自动触发扩容,在runtime.mapassign可以看到这条语句:

func mapassign(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {...if !h.growing() && (overLoadFactor(h.count+1, h.B) || tooManyOverflowBuckets(h.noverflow, h.B)) {hashGrow(t, h)goto again}...
}

map在两种情况下会触发扩容

  • 负载因子已经超过6.5:双倍扩容
  • 溢出桶数量过多:等量扩容
什么是负载因子

负载因子 = 哈希表中元素的数量 / 桶的数量

为什么负载因子是6.5

​源码里对负载因子的定义是6.5,是经过测试后取出的一个比较合理的值,每个 bucket 有8个空位,假设map里所有的数组桶都装满元素,没有一个数组有溢出桶,那么这时的负载因子刚好是8。而负载因子是6.5的时候,说明数组桶快要用完了,存在溢出的情况下,查找一个key很可能要去遍历溢出桶会造成查找性能下降,所以有必要扩容了

溢出桶数量过多

​可以想象一下这种情况,先往一个map插入很多元素,然后再删除很多元素?再插入很多元素。会造成什么问题?

​由于插入了很多元素,在不是完全理想的情况下,肯定会创建一些溢出桶,但是,又由于没有达到负载因子的临界值,所以不会触发扩容,这时候再删除很多元素,这个时候负载因子又会减小,再插入很多元素,会继续创建更多的溢出桶,导致查找元素的时候要去遍历很多的溢出桶链表,性能下降。

​所以在这种情况下要进行扩容,新建一个桶数组把原来的数据拷贝到里面,这样数据排列更紧密,查找性能更快。

扩容过程

扩容过程需要用到两个函数,hashGrow()growWork()

扩容函数
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 = 0h.flags |= sameSizeGrow}oldbuckets := h.bucketsnewbuckets, 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 += biggerh.flags = flagsh.oldbuckets = oldbucketsh.buckets = newbucketsh.nevacuate = 0h.noverflow = 0if 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.overflowh.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().
}

go语言在对map进行过扩容的时候,并不是一次性将map的所有数据从旧的桶搬到新的桶,如果map的数据量很大,会非常影响性能,而是采用一种“渐进式”的数据转移技术,遵循写时复制(copy on write)的规则,每次只对使用到的数据做迁移。

简单分析一下扩容过程:

通过代码分析,hashGrow(函数是在mapassiqn函数中被调用,所以,扩容过程会发生在map的赋值操作,在满足上述两个扩容条件时触发。
扩容过程中大概需要用到两个函数,hashGrow(和growWork()。其中hashGrow()函数只是分配新的 buckets,并将老的 buckets 挂到了 oldbuckets 字段上,并未参与真正的数据迁移,而数据迁移的功能是由growWork()函数完成的。

迁移时机

growWork() 函数会在 mapassign 和 mapdelete 函数中被调用,所以数据的迁移过程一般发生在插入或修改、删除 key 的时候。在扩容完毕后(预分配内存),不会马上就进行迁移。而是采取写时复制的方式,当有访问到具体bukcet 时,才会逐渐的将 oldbucket 迁移到 新bucket中。

growWork0)函数定义如下:

func growWork(t *maptype, h *hmap, bucket uintptr) {// 首先把需要操作的bucket迁移evacuate(t, h, bucket&h.oldbucketmask())// 再顺带迁移一个bucketif h.growing() {evacuate(t, h, h.nevacuate)}
}

分析evacuate函数,大致迁移过程如下:

1.先判断当前bucket是不是已经迁移,没有迁移就做迁移操作

 b := (*bmap)(add(h.oldbuckets, oldbucket*uintptr(t.bucketsize)))newbit := h.noldbuckets()// 判断旧桶是否已经被迁移了if !evacuated(b) {do...  // 做转移操作}

2.evacuated函数直接通过tohash中第一个hash值判断当前bucket是否被转移

func evacuated(b *bmap) bool {h := b.tophash[0]return h > emptyOne && h < minTopHash
}

3.数据迁移时,根据扩容规则,可能是迁移到大小相同的buckets上,也可能迁移到2倍大的buckets上。

如果迁移到等量数组上,则迁移完的目标桶位置还是在原先的位置上的。如果是双倍扩容迁移到2倍桶数组上,迁移完的目标桶位置有可能在原位置,也有可能在原位置+偏移量。(偏移量大小为原桶数组的长度)。xy 标记目标迁移位置,x标识的是迁移到相同的位置,y标识的是迁移到2倍桶数组上的位置。

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.e = add(x.k, bucketCnt*uintptr(t.keysize))​
​
if !h.sameSizeGrow() {// Only calculate y pointers if we're growing bigger.​// Otherwise GC can see bad pointers.​y := &xy[1]​y.b = (*bmap)(add(h.buckets, (oldbucket+newbit)*uintptr(t.bucketsize)))​y.k = add(unsafe.Pointer(y.b), dataOffset)​y.e = add(y.k, bucketCnt*uintptr(t.keysize))}

evacDst结构如下:

type evacDst struct {​b *bmap          // 迁移桶​i int            // 迁移桶槽下标​k unsafe.Pointer // 迁移桶Key指针​e unsafe.Pointer // 迁移桶Val指针​
}

4.确定完bucket之后,就会按照bucket内的槽位逐条迁移key/value键值对

5.迁移完一个桶后,迁移标记位nevacuate+1,当nevacuate等于旧桶数组大小时,迁移完成,释放旧的桶数组和旧的溢出桶数组

扩容过程大致如下:

  • 等量扩容:等量扩容,目标桶扩容后还在原位置处

在这里插入图片描述

  • 双倍扩容:目标桶扩容后位置可能在原位置,也可能在原位置+偏移量处
    在这里插入图片描述

相关文章:

  • 数据结构测试模拟题(4)
  • PySide6 GUI 学习笔记——常用类及控件使用方法(多行文本控件QTextEdit)
  • 1.认识Spring
  • 第3章:图数据模型与设计
  • 运行示例程序和一些基本操作
  • [ACTF2020 新生赛]Include 1(php://filter伪协议)
  • AI数据分析在体育中的应用:技术与实践
  • 从零设计一个智能英语翻译API:架构与实现详解
  • 计算机组成与体系结构:补码数制一(Complementary Number Systems)
  • 信息最大化(Information Maximization)
  • 大模型在创伤性脑出血全周期预测与诊疗方案中的应用研究
  • leetcode刷题日记——二叉搜索树中第 K 小的元素
  • 从认识AI开始-----AutoEncoder:生成模型的起点
  • Web前端基础
  • ELK日志管理框架介绍
  • XSS(跨站脚本攻击)详解
  • 对称哈希连接实现
  • ECharts 提示框(tooltip)居中显示位置的设置技巧
  • 学习STC51单片机30(芯片为STC89C52RCRC)
  • Jina AI 开源 node-DeepResearch
  • 营销网站建设/山西太原百度公司
  • 学网站开发的软件/关键词下载
  • 网站的url如何设置/快速网站排名提升
  • 淘宝客建设网站/宁波免费建站seo排名
  • 做网站最主要是那个一类商标/百度指数网页版
  • 网站上的网站地图怎么做/制作网页的步骤