Java/Go双修 - Go哈希表map原理

map是什么

Javaer看一眼就明白就是Map,典型的有HashMap,map就是一个key-value的键值对的集合,可以在O(1)内拿到value

在Go中,map的底层采用hash表,通过变种拉链法来解决hash冲突问题,通过如下方式声明map

go 复制代码
myMap := make(map[string]string)

哈希冲突

哈希表的原理是将多个key-value键值对散列的存储在buckets中,buckets可以理解为一个连续的数组

给定一个key-value键值对,我们要将其存储到合适的位置需要经过两个步骤

  1. 计算key的hash值:hash = hashFunc(key)
  2. 计算对应桶的位置:index = hash % len(buckets)

这里需要处理的问题是,有两个键值对key1-value1和key2-value2,经过哈希函数hashFunc的计算得到的hash1和hash2相同

那么对应桶的位置也必然是相同的,那将会存放到同一个位置,那么怎么处理呢?丢弃后来的键值对?或者是覆盖之前的键值对?

但是这都是不可取的,因为key1和key2是不同的,那就是两个不同的键值对,理论上都应该被存储,那应该怎么存呢?

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

  • 拉链法

    拉链法是最常见的解决哈希冲突的方法,很多语言都是用拉链法解决哈希冲突的,拉链法不直接使用连续数组来直接存储数据元素

    而是通过数组和链表的组合来使用,数组里存的是指针,指向一个链表。当出现key1和key2的哈希值相同的情况,就将数据连接到

    链表上,如果没有发现冲突的key,显然链表上就只有一个元素。拉链法处理冲突简单,可以动态的申请内存,删除增加节点都方便

    当冲突严重的时候,链表长度过长的时候也支持更多的优化策略,比如用红黑树代替链表,拉链法结构如下图:

  • 拉链寻址法 (可以参考Java的ThreadLocalMap就是用的拉链寻址法)

Go语言map的底层结构

Go语言中的map是一个指向hmap的指针,hmap包含多个结构为bmap的buckets数组,当发生冲突的时候会到正常桶里面的overflow

指针所指向的溢出桶里面去找,GO语言中的溢出桶也是一个动态数组的形式,它是根据需要去动态创建的

Go语言处理冲突时采用了优化的拉链法,链表中的每个节点存储的不是一个键值对,而是8个键值对,整体结构如下图

再直白一点,Go语言处理hash冲突是结合了拉链法和开放寻址法,在后面我们会提到

看一下hmap的结构体定义:

go 复制代码
type hmap struct {
  count     int 							// map中元素的个数 len(map)的值
	flags     uint8							// 状态标识位 标记map的一些状态
  B         uint8  						// 桶数以2为底的对数 即B = log_2(len(buckets)) 比如B=3,那么桶数为2^3=8个bmap
	noverflow uint16 						// 溢出桶数量近似值
	hash0     uint32 						// 哈希种子
	buckets    unsafe.Pointer 	// 指向buckets数组的指针 buckets数组的元素为bmap 如果数组元素个数为0 其值为nil
	oldbuckets unsafe.Pointer 	// 指向buckets数组的指针 在扩容时,oldbuckets指向老的buckets数组 非扩容时为空
	nevacuate  uintptr        	// 表示扩容进度的一个计数器 小于该值的桶已经完成迁移
	extra *mapextra							// 指向mapextra结构的指针 mapextra存储map中的溢出桶
}

mapextra结构定义如下:

go 复制代码
type mapextra struct {
	overflow    *[]*bmap
	oldoverflow *[]*bmap
	nextOverflow *bmap
}

bmap结构体定义如下(为了方便理解,大家就以下面这个结构体来理解):

go 复制代码
type bmap struct {
	tophash  [8]uint8				// 存储bmap里8个key-value键值对的每个key根据哈希函数计算出的hash值的高8位
	keys		 [8]keytype			// 存储bmap里8个key-value键值对的key
	values	 [8]valuetype		// 存储bmap里8个key-value键值对的value
	overflow uintptr				// 指向溢出桶的指针
}

解释一下这个tophash,Go语言的map会根据每一个key计算出一个hash值,有意思的是,对这个hash值的使用

Go语言并不是一次性使用的,而是分开使用 的,在使用中,把求得的hash值按照用途一分为二:高8位 和 低B位 (注意hmap中的B)

假设我们对一个key做hash计算得到一个hash值如图,蓝色的就是这个hash值的高8位,红色是低8位,tophash存的是蓝色的高8位

注意红色的低8位,我们一般使用后B位来判断桶位置的

通过上面整个数据结构的图我们可以看到,bmap的结构,先存储8个tophash值,然后存储8个key值,再存储8个value值

注意,这8个键值对并不是按照key-value的形式将key和value放在一起存储,而是先连续存储8个key再连续存储8个value

当键值对不够8个的时候,对应的位置就留空,这样存储的好处是可以消除字节对齐带来的空间浪费,那么又有一个问题

  • 为什么是8个键值对而不是4个或者16个呢?

    内存问题:如果是4个,会导致在存储相同数量的键值对的时候占用更多的桶,浪费内存空间

    ​ 如果是16个,当大部分桶未存满的时候,也会造成空间浪费

    CPU缓存:现代CPU缓存加载数据的时候,通常按照固定大小64字节的缓存行来进行,8个键值对布局,使内存分布更符合CPU缓存

bmap结构体深度解析(可以先跳过):

实际上的bmap如下:

go 复制代码
type bmap struct {
	tophash [bucketCnt]uint8
}
  • **为什么只有一个tophash的int数组呢?**这样是怎么访问到key-value的?

    当通过tophash匹配到目标下标 i 后,通过内存偏移量计算来定位key和value

    定位key:

    ini 复制代码
    key := add(unsafe.Pointer(b), dataOffset + i * uintptr(t.keysize))

    dataOffset是data区域中的第一个key的起始偏移量

    i * uintptr(t.keysize) 表示第i个key相对于data起始位置的偏移(t.keysize是单个key的大小)

    定位value:

    ini 复制代码
    e := add(unsafe.Pointer(b), dataOffset + bucketCnt*uintptr(t.keysize) + i*uintptr(t.elemsize))

    bucketCnt*uintptr(t.keysize) 跳过所有 key 占用的空间(bucketCnt 为 8,即一个桶内 key 的数量)

    i*uintptr(t.elemsize) 表示第 ivalue 相对于 value 起始位置的偏移(t.elemsize 是单个 value 的大小)

bmap在编译期间会拓展为类似的以下结构

go 复制代码
type bmap struct {
    tophash [8]uint8  // 存储哈希值的高8位,用于快速匹配
    data    byte[1]   // 连续存储 key 和 value 的数据区,布局为 key/key/.../value/value/...
    overflow *bmap    // 指向溢出桶的指针
}
  • 这个byte[1]是什么?

    byte[1] 代表一个长度为 1 的字节数组。不过在 bmap 里,它并非真的只存储一个字节的数据,而是作为一个占位符,为后续连续存储 keyvalue 预留空间。

map赋值原理

Go语言的map怎么赋值,原map中存在key,则更新对应value,若map中不存在key,则插入key-value

  • 注意点:

    在对map进行赋值操作的时候,map一定要先进行初始化,否则会产生panic

    同时map是非线程安全的,不支持并发读写操作,当有其他线程读写map的时候,执行map赋值会报并发读写错误

    后续会专门出一篇专门讲线程安全的sync.map的文章

go 复制代码
myMap := make(map[string]string)
myMap["test"] = "test"

map赋值的整个简单流程如下:

  1. map写检测,如果此时map正处于写状态,表示此时不能进行读取,会报fatal error

  2. 计算出hash值,将map置为写状态

  3. 判断桶数组是否为空,如果为空则进行桶数组的初始化

  4. 目标桶查找

    a. 根据hash哈希值找到桶的位置

    b. 根据当前是否处于扩容状态,若正在扩容,则迁移这个桶,并且另外帮忙多迁移一个桶(这个桶是被迁移进度指向的桶)

    c. 获取目标桶的指针,计算出tophash,开始后面的key的查找过程

  5. key查找(根据hash值后B位来确定key在哪个桶)

    a. 遍历桶和它的溢出桶的每个槽位,按照下述方式进行查找

    b. 判断槽位的tophash和目标tophash相不相等(后继空状态概念请往下看看到删除原理就明白了)

    ​ i. 不相等

    ​ 1. 槽位tophash为空,标记这个位置为候选位置(为空怎么不直接插入?因为后续可能还有没遍历到的位置已经存在这个key)

​ 2. 槽位tophash的标识位为 "后继空状态",说明这个key之前没有被插入过(后继为空说明这个位置和后面的位置都为空)

​ 3. tophash标识位部位空,说明存储着其他key,说明当前槽的tophash不符合,继续遍历下一个槽位

​ ii. 相等

​ 1. 判断当前槽位的key与目标key是否相等,相等,则修改对应的value,不相等,继续遍历下一个槽位

  1. key插入

    a. 若map中既没有找到key,且根据这个key找到的桶及其这个桶的溢出桶中没有空的槽位了,要申请一个新的溢出桶并插入

    b. 否则在找到的位置插入

  2. 收尾程序

    a. 再次判断map的写状态

    b. 清除map的写状态

这里需要特别注意一点:申请一个新的溢出桶的时候并不会马上创建一个溢出桶,因为map在初始化的时候会提前创建好一些溢出桶

这些提前创建好的溢出桶存储在extra * mapextra字段中,当出现溢出的现象的时候,这些溢出桶会被优先使用

只有当预分配的溢出桶使用完了之后,才会再新建溢出桶

map访问原理

对map的访问有两种方式

go 复制代码
value			:= map[key]
value, ok := map[key] // 多了一个key存不存在的布尔值

大致原理如下图:

梳理一下map访问的大致流程:

  1. 判断map是否为空或者无数据,若为空或者无数据,则返回对应的空值

  2. map写检测,如果正处于写状态,表示此时不能进行操作,报fatal error

  3. 计算出对应key的hash值

  4. 判断当前map是否处于扩容状态,如果在扩容执行的状态,则执行下面步骤:

    a. 根据状态位判断当前桶是否被迁移

    b. 如果迁移,在新桶中查找,未被迁移,在旧桶中查找

  5. 依次遍历桶以及溢出桶来查找key

    a. 遍历桶内的8个槽位

    b. 比较该槽位的tophash和当前key的tophash是否相等

    ​ i. 如果相等,继续比较key是否相同,相同则直接返回对应的value

    ​ ii. 不相同,查看这个槽位的状态位是否为 "后继空状态"(删除原理中会提到什么是后继空状态)

    ​ 是,key在以后的槽位中也没有了,那么这个key不存在,直接返回零值

    ​ 否,继续遍历下一个槽位

  6. 当前桶没有找到,则遍历溢出桶,用同样的方式进行查找

map删除原理

map的删除delete原理很简单,删除动作前整体逻辑和前面map的访问是差不多的,也就是map的写检测,以及寻找bucket的过程

清空key/value的核心代码如下:

go 复制代码
for {
    b.tophash[i] = emptyRest
    if i == 0 {
        if b == bOrig {
            break // beginning of initial bucket, we're done.
        }
        // Find previous bucket, continue at its last entry.
        c := b
        for b = bOrig; b.overflow(t) != c; b = b.overflow(t) {
        }
        i = bucketCnt - 1
    } else {
        i--
    }
    if b.tophash[i] != emptyOne {
        break
    }
}

如果找到了目标key,则把当前桶该槽位对应的key和value删除,将该槽位的tophash置为emptyOne,如果发现当前槽位后面没有元素

则将tophash设置为emptyReset,并循环向前检查前面一个元素,若前一个元素也为空,槽位状态为emptyOne,则将前一个元素的

tophash也设置为emptyReset,这样做目的是将emptyRest状态尽可能地向前面的槽推进,这样做是为了增加效率,因为在查找的时候

发现了emptyReset状态就不用继续往后面着了,因为后面已经没有元素了

举个例子:

假设当前map的状态如下图所示,溢出桶2后面没有再指向溢出桶,或者是溢出桶2后面指向的溢出桶没有数据

溢出桶2中有三个空槽,即第2,3,6处为emptyOne

在删除了溢出桶1的key2和key4,以及溢出桶2的key7之后,对应的map状态如下

所从delete map单个key-value的原理可以看出,删除key-value时,内存不会释放,所以对于map的频繁写入和删除可能会造成内存泄漏

map扩容原理

在上面的map的写入操作的时候,我们没有提到一个点,那就是随着不断往map里面写入元素,会导致map的数据量变得很大

hash性能会逐渐的变差,而且溢出桶会越来越多,导致查找的性能变得很差,所以需要更多的桶和更大的内存保证哈希读写性能

这时map会自动触发扩容,在代码里runtime.mapassign可以看到这条语句

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

可以看到map会在两种情况下触发扩容:

  • map的负载因子超过6.5**(负载因子 = 哈希表中的元素数量 / 桶的数量)**
  • 溢出桶的数量过多**(一般认为溢出桶的数量接近正常桶的数量即为溢出桶的数量过多)**

在扩容的时候还有一个条件!h.growing,这是因为map的扩容并不是一个原子操作,不是一次性完成的

所以需要判断一下,当前map是否处于扩容状态,避免二次扩容造成混乱

对于两种情况下,我们的扩容策略是不同的,我们有两种扩容方式:双倍扩容和等量扩容

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

为什么负载因子是6.5?

源码里对负载因子的定义是6.5,是经过测试后取出的一个比较合理的值

每个bucket有8个空位,假设map里所有的数组桶都装满了元素,没有一个数组有溢出桶,那么这时的负载因子刚好是8

而6.5的时候,说明数组桶快要用完了,存在溢出的情况,查找一个key很有可能要去遍历溢出桶,会造成查找性能下降,有必要扩容了

  • 双倍扩容

溢出桶的数量过多?

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

由于插入了很多元素,在不完全理想的情况下,肯定会创建一部分溢出桶,但是由于没有达到负载因子的临界值,所以不会出发扩容

在删除很多元素的时候,这个时候负载因子又会减少,再插入很多元素,会继续创建更多的溢出桶,导致查找元素需要遍历很多溢出桶

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

  • 等量扩容

map的遍历

go语言中的map的遍历尤其要引起注意,因为每次遍历的数据顺序都是不同的。

因为go在每次开始遍历之前,都会随机选择一个桶的下标和一个桶内遍历的起点槽下标,遍历的时候以这个桶和桶内下标开始

go语言为什么要用这种随即开始的位置遍历呢?

  • 因为go的扩容不是一个原子,是渐进式的,所以在遍历map的时候,可能发生扩容,一旦发生了扩容,key的位置就变了,下次遍历就不是原来的顺序了
  • hash表中数据每次插入的位置是变化的,同一个map内,数据删除再添加的位置也有可能变化,因为在同一个桶及溢出链表中的数据位置是不分先后的

所以理论上来说,map的遍历结果本就是不同的,所以为了防止用户错误的依赖每次迭代的顺序,索性每次遍历的时候,就随机选取位置

相关推荐
why1516 小时前
微服务商城-商品微服务
数据库·后端·golang
結城8 小时前
mybatisX的使用,简化springboot的开发,不用再写entity、mapper以及service了!
java·spring boot·后端
星辰离彬9 小时前
Java 与 MySQL 性能优化:MySQL 慢 SQL 诊断与分析方法详解
java·spring boot·后端·sql·mysql·性能优化
q_19132846959 小时前
基于Springboot+Vue的办公管理系统
java·vue.js·spring boot·后端·intellij idea
陪我一起学编程10 小时前
关于nvm与node.js
vue.js·后端·npm·node.js
舒一笑10 小时前
基于KubeSphere平台快速搭建单节点向量数据库Milvus
后端
JavaBuild10 小时前
时隔半年,拾笔分享:来自一个大龄程序员的迷茫自问
后端·程序员·创业
一只叫煤球的猫11 小时前
虚拟线程生产事故复盘:警惕高性能背后的陷阱
java·后端·性能优化
周杰伦fans12 小时前
C#中用于控制自定义特性(Attribute)
后端·c#
Livingbody13 小时前
GitHub小管家Trae智能体介绍
后端