Map底层原理剖析

Map底层原理剖析

Map的作用: go语言的map是一种内置的数据结构,它使用键值对的方式来映射和存储数据,具有O(1)的读写性能。在平常的开发中,经常使用map来存储和操作数据。下面将详细讲解map的数据结构、增删改的过程,以及扩容和迁移的流程。

数据结构

在理解map的各种操作实现流程之前,先看一下hmap数据结构源码注解

下面的函数和结构体链接都是有详细注解可以点击查看

go 复制代码
type hmap struct {
	count     int 
	flags     uint8
	B         uint8
	noverflow uint16 
	hash0     uint32 

	buckets    unsafe.Pointer
	oldbuckets unsafe.Pointer 
	nevacuate  uintptr 

	extra *mapextra 
}

type mapextra struct {
	overflow    *[]*bmap
	oldoverflow *[]*bmap
	nextOverflow *bmap
}

type bmap struct {
	tophash [bucketCnt]uint8
}

//运行时重构结构
type bmap struct {
    topbits  [8]uint8 
    keys     [8]keytype 
    values   [8]valuetype
    pad      uintptr 
    overflow uintptr
}

hmap
count:表示map中键值对的个数;
flags:通过位存储当前状态和一些额外的标志信息;
B:在创建具体桶的数量的时候,会根据2^B来计算桶的数量;
noverflow:已使用溢出桶数量;
hash0:哈希函数的结果引入随机性;
buckets:存放键值对的桶;
oldbuckets:是哈希在扩容时用于保存之前 buckets 的字段;
nevacuate:接下来要迁移桶编号;
extra:存储溢出桶信息;
溢出桶:当数据过多,超过一个桶存储大小时,就会使用其他的桶来存储溢出数据,这类桶我们称之为溢出桶;

mapextra

这个结构记录了溢出桶的新旧信息以及当前可用溢出桶的位置。
overflow:所有使用的溢出桶,遍历和扩容迁移的时候要使用;
oldoverflow:扩容桶后,旧的溢出桶数据;
nextOverflow:当前可用溢出桶位置;

bmap

存储键值对的结构体。
topbits:哈希值的前8位;
keys:kay类型数组;
values:value类型数组;
pad:参数用于填充对齐,确保 bmap 结构体的大小是对齐的;
overflow:溢出桶的地址;

各个结构的关系

如上图所示,蓝色我们表示正常桶,黄色表示溢出桶,可以总结出几个特性:

  1. 一个bmap可以存储8个键值对(当一个桶键值对超过8个时,会使用溢出桶)
  2. 溢出桶和正常桶的是一块连续的内存地址(它能减少扩容的频率,增加内存使用效率)
  3. tophash 存储哈希值的高8位,通过tophash来加快访问键值的速度

介绍完结构体,我们来看一下map各个操作的实现流程是怎样的?

初始化

下面初始化map有以下几种写法

go 复制代码
info := map[string]string{}
var info = map[string]string{}
info := make(map[string]string, 0)

不管什么方式最后都会使用runtime.makemap函数去初始化map,实现流程如下:

  1. 创建一个hmap结构体对象
  2. 生成一个哈希因子hash0并赋值到hmap对象中(用于后续为key创建哈希值)
  3. 根据hint(map的容量)来计算B大小,如下
css 复制代码
hint B

0~8 0

9~13 1

14~26 2

......
  1. 根据B去创建桶(bmap对象)并存放在buckets指向的数组中,如果我们创建10个容量的map,对应的B为2,创建桶的公式如下
  • 当B<4时,根据B创建桶的个数的规则为: <math xmlns="http://www.w3.org/1998/Math/MathML"> 2 B 2^{B} </math>2B

  • 当B>=4时,根据B创建桶的个数的规则为: <math xmlns="http://www.w3.org/1998/Math/MathML"> 2 B 2^{B} </math>2B+ <math xmlns="http://www.w3.org/1998/Math/MathML"> 2 B − 4 2^{B-4} </math>2B−4

当B>=4时( <math xmlns="http://www.w3.org/1998/Math/MathML"> 2 4 2^{4} </math>24*8=128个键值对),会创建 <math xmlns="http://www.w3.org/1998/Math/MathML"> 2 B − 4 2^{B-4} </math>2B−4个桶当溢出桶使用,所以公式是 <math xmlns="http://www.w3.org/1998/Math/MathML"> 2 B 2^{B} </math>2B+ <math xmlns="http://www.w3.org/1998/Math/MathML"> 2 B − 4 2^{B-4} </math>2B−4,在平常开发中,make初始化map,在不知道容量的时候,可以预测数据量的大小来决定是否初始化溢出桶,数据量多的情况,建议初始化溢出桶。


增删改查数据

查询数据

less 复制代码
value,ok :=info["name"]

value := info["name"]

直接查询数据使用函数runtime.mapaccess2

带ok查询数据使用函数 runtime.mapaccess1
mapaccess1mapaccess2查询数据流程类似,只是mapaccess2会返回一个布尔值来确实数据是否存在

实现流程:

  1. 根据hash0和键如上图的name值生成哈希值 011011100011111110111011011
  2. 根据哈希值的后B位对应的值找到键值对存放的桶(bmap),如上的哈希后3位对应的值为011转化为十进制为3
  3. 通过哈希值的高八位确定tophash值,然后再遍历比较桶(正常桶和溢出桶)中8个tophash,找到相等的tophash值,根据桶中的tophash地址偏移量找到桶中的key值,然后再比较桶中key和传入key是否相等,相同再去根据桶中的key地址偏移量去查询到value
  4. 查询到value,返回value,没有找到,返回value的零值,如果带ok会返回数据是否存在的布尔值;

写入数据

arduino 复制代码
info["name"] = "重庆市"

使用的函数runtime.mapassign

实现流程:

  1. 根据hash0和键name计算出哈希值,根据哈希值后B位确认桶的位置,高8位和key值确认数据是否存在
  2. 如果存在直接返回value的地址,不存在创建数据,移动数据到桶中内存地址
  3. 不存在数据,创建数据后cont++(map中的元素个数+1)

删除数据

map的删除逻辑与写入逻辑很相似,只是触发哈希的删除需要使用delete关键字,如果在删除期间遇到了哈希表的扩容,会触发迁移数据函数,再查找桶中的目标元素完成键值对的删除工作。

接下来我们看一下map的扩容流程

扩容迁移

在向map中添加数据时,当到达某个条件,则会引发map扩容,

触发扩容条件:

  • 溢出桶过多
  • 装载因子> 6.5;

装载因子:用来衡量引发扩容发生的动机,公式如下

loadFactor := count / (2^B)

溢出桶过多的判断条件

B<=15,已使用的溢出桶个数>= <math xmlns="http://www.w3.org/1998/Math/MathML"> 2 B 2^{B} </math>2B

B>15,已使用的溢出桶个数>= <math xmlns="http://www.w3.org/1998/Math/MathML"> 2 15 2^{15} </math>215

两种条件触发的扩容方式也不同,溢出桶过多会触发等容扩容(源码中用sameSizeGrow标记),装载因子> 6.5会触发翻倍扩容

扩容源码函数 runtime.hashGrow

源码中,会创建一组新桶满足创建溢出桶条件(B>=4),还会创建溢出桶,随后将原有的桶数组设置到 oldbuckets 上并将新的空桶设置到 buckets 上,溢出桶也使用了相同的逻辑更新,扩容完成后,新buckets中是没有数据的,需要数据迁移后才会有数据,

但是如果有大量的桶需要搬迁,会非常影响性能。因此 Go map 的扩容采取了一种称为"渐进式"地方式,原有的 key 并不会一次性搬迁完毕,每次最多只会搬迁 2 个桶,这也是为什么map是并发不安全的原因之一

如果oldbuckets != nil,会判断map为扩容状态

去查询数据时,流程中会从oldbuckets的结构中获取数据

去写入和删除数据时,会去触发runtime.growWork 迁移数据,然后进行写入删除流程

翻倍扩容迁移

将旧桶中的数据cell(键值对)分流至新的两个桶中(比例不一定),并且编号的位置为:同编号位置和翻倍后对应编号位置。

首先: 因为是翻倍扩容,则新桶个数是旧桶的2倍,也就是说B+1(桶的个数= <math xmlns="http://www.w3.org/1998/Math/MathML"> 2 B 2^{B} </math>2B,桶个数变成2倍,B等同+1)

然后: 迁移时会遍历某个旧桶中所有的key(包括溢出桶),并根据key重新生成哈希值,根据哈希值的后B位来确定将此键值对分流到那个新桶中。

扩容前的图形: 翻倍扩容后:

因为key会发生搬迁,原来落在同一个bucket中的key,搬迁后就可以不再同一个bucket中了(bucket序列加上2^B),这也是map无序的原因之一

等容搬迁迁移

通过flags第4标记位是否为1判断是否为等容扩容sameSizeGrow

由于扩容桶的数量不变,因此可以按序号来搬,比如原来在 0 号 bucktes,到新的地方后,仍然放在 0 号 buckets就OK了

遍历数据

go语言Map的遍历,通过两个问题来查看源码

  1. 为什么遍历是无序的?
  2. 当在发生翻倍扩容时遍历的流程是怎样的?

遍历map需要使用到hiter结构体

hiter主要来记录遍历键值对开始点,遍历进度等信息

go 复制代码
type hiter struct {
   // 指向待遍历key的地址
   key         unsafe.Pointer 
   // 指向待遍历value的地址
   elem        unsafe.Pointer 
   // 应用hmap
   h           *hmap
   // 
   buckets     unsafe.Pointer
   bptr        *bmap          
   overflow    *[]*bmap       
   oldoverflow *[]*bmap   
   
   // 开始遍历时是第几个bucket    
   startBucket uintptr
   // 遍历每个桶时从第几个槽位开始       
   offset      uint8
   // 是否从头遍历了          
   wrapped     bool          
   B           uint8
   
   // 正在遍历的槽位
   i           uint8
   // 正在遍历的桶
   bucket      uintptr
   checkBucket uintptr
}

使用到mapiterinit来初始化hiter,在源码中,我们可以找到遍历map时,确定遍历开始的桶和键值都是随机的,em.....懂了,设计师就是故意设计无序的。

scss 复制代码
func mapiterinit(t *maptype, h *hmap, it *hiter) {
   //其他源码...

   // decide where to start
   var r uintptr //生成随机数
   if h.B > 31-bucketCntBits {
      r = uintptr(fastrand64())
   } else {
      r = uintptr(fastrand())
   }
   //从那个桶开始遍历
   it.startBucket = r & bucketMask(h.B) 
   //从上面桶的那个键值开始遍历
   it.offset = uint8(r >> h.B & (bucketCnt - 1)) 

   //其他源码...
}

通过mapiternext函数来获取键值流程:

从第hiter.bucket个桶,hiter.i个槽开始往后找非空的键值对,找到了就返回键值

需要注意以下几点:

  1. 遍历每个桶时,从第offset个槽位开始遍历,
  2. 例如offset = 6,那么在每个桶中的遍历顺序都为:6,7,0,1,2,3,4,5 如果map正在扩容中,且是两倍扩容方式,如果当前遍历的新桶对应的老桶还没迁移,只去遍历老桶中将来会迁移到该新桶中的元素

如果遍历完了,即bucket又回到了startBucket时,将键值都设为空,for range 退出

哈希冲撞和哈希函数选择

哈希函数的选择

Golang 选择哈希算法时,根据CPU是否支持AES指令集进行判断 ,如果CPU支持AES指令集,则使用 Aes Hash,否则使用memhash

AES

HashAES 指令集全称是高级加密标准指令集(或称英特尔高级加密标准新指令,简称AES-NI),是一个 x86指令集架构的扩展,用于Intel和AMD处理器。利用AES指令集实现哈希算法性能很优秀,因为它能提供硬件加速。

memhash

网上没有找到这个哈希算法的作者信息,只在 Golang 的源码中有这几行注释,说它的灵感来源于 xxhash 和 cityhash

哈希冲撞的go处理

通常情况下,哈希算法的输入范围一定会远远大于输出范围,所以当输入的 key 足够多时一定会遇到冲突,这时就需要一些方法来解决哈希冲突问题。最常见的处理哈希冲突方法是链地址法开放地址法 ,go语言采用的链地址法
链地址法

当不同的key计算出的哈希值对于的同一个桶时,就会发生哈希冲突,然后就会把冲突的键值存在上一个键值对的后面,当8个键值对满了的情况,就会链接一个溢出桶存储数据。

总结

  • 通过key的哈希值将key散落到不同的桶中,每个桶中有8个cell。哈希值的低B位决定桶序号,高8位标识同一个桶中的不同key。

  • 当向桶中添加了很多key造成元素过多,或者溢出桶太多,就会触发扩容。扩容分为等量扩容翻倍容量扩容。扩容后,原来一个 bucket中的key一分为二,会被重新分配到两个桶中。

  • 查找赋值删除的一个很核心的内容是如何定位到key所在的位置,需要重点理解。一旦理解,关于map的源码就可以看懂了

  • 扩容过程是渐进的,主要是防止一次扩容需要搬迁的key数量过多,引发性能问题。触发扩容的时机是增加了新元素,bucket搬迁的时机则发生在赋值、删除期间,每次最多搬迁2个bucket

  • 遍历过程,for range每次循环都会通过hiter结构去获取cell,并且随机从一个桶的一个cell开始遍历,若遍历的桶处于翻倍扩容时,只会遍历翻倍后到本桶的cell

  • Go 语言中,通过哈希查找表实现 map,用链表法解决哈希冲突。

学习来源:

go语言设计于实现

Map的底层源码

深度解析Go语言之Map

深入理解 Go map (3)------ 遍历

相关推荐
fashia1 天前
Java转Go日记(三十六):简单的分布式
开发语言·分布式·后端·zookeeper·golang·go
fashia1 天前
Java转Go日记(四十四):Sql构建
开发语言·后端·golang·go
fashia2 天前
Java转Go日记(四十二):错误处理
开发语言·后端·golang·go
fashia2 天前
Java转Go日记(三十九):Gorm查询
开发语言·后端·golang·go
一丝晨光8 天前
数值溢出保护?数值溢出应该是多少?Swift如何让整数计算溢出不抛出异常?类型最大值和最小值?
java·javascript·c++·rust·go·c·swift
陌尘(MoCheeen)9 天前
技术书籍推荐(002)
java·javascript·c++·python·go
白泽来了11 天前
字节大模型应用开发框架 Eino 全解(一)|结合 RAG 知识库案例分析框架生态
开源·go·大模型应用开发
致于数据科学家的小陈12 天前
Go 层级菜单树转 json 处理
python·go·json·菜单树·菜单权限·children
白总Server13 天前
Golang领域Beego框架的中间件开发实战
服务器·网络·websocket·网络协议·udp·go·ssl
ん贤14 天前
GoWeb开发
开发语言·后端·tcp/ip·http·https·go·goweb