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)------ 遍历

相关推荐
zhuyasen15 小时前
当Go框架拥有“大脑”,Sponge框架集成AI开发项目,从“手写”到一键“生成”业务逻辑代码
后端·go·ai编程
写代码的比利16 小时前
Kratos 对接口进行加密转发处理的两个方法
go
chenqianghqu18 小时前
goland编译过程加载dll路径时出现失败
go
马里嗷20 小时前
Go 1.25 标准库更新
后端·go·github
郭京京21 小时前
go语言redis中使用lua脚本
redis·go·lua
心月狐的流火号1 天前
分布式锁技术详解与Go语言实现
分布式·微服务·go
一个热爱生活的普通人1 天前
使用 Makefile 和 Docker 简化你的 Go 服务部署流程
后端·go
HyggeBest2 天前
Golang 并发原语 Sync Pool
后端·go
来杯咖啡2 天前
使用 Go 语言别在反向优化 MD5
后端·go