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
:溢出桶的地址;
各个结构的关系

如上图所示,蓝色我们表示正常桶,黄色表示溢出桶,可以总结出几个特性:
- 一个
bmap
可以存储8个键值对(当一个桶键值对超过8个时,会使用溢出桶) - 溢出桶和正常桶的是一块连续的内存地址(它能减少扩容的频率,增加内存使用效率)
tophash
存储哈希值的高8位,通过tophash
来加快访问键值的速度
介绍完结构体,我们来看一下map各个操作的实现流程是怎样的?
初始化
下面初始化map有以下几种写法
go
info := map[string]string{}
var info = map[string]string{}
info := make(map[string]string, 0)
不管什么方式最后都会使用runtime.makemap函数去初始化map,实现流程如下:
- 创建一个hmap结构体对象
- 生成一个哈希因子
hash0
并赋值到hmap
对象中(用于后续为key创建哈希值) - 根据hint(map的容量)来计算B大小,如下
css
hint B
0~8 0
9~13 1
14~26 2
......
- 根据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
mapaccess1
和 mapaccess2
查询数据流程类似,只是mapaccess2
会返回一个布尔值来确实数据是否存在
实现流程:
- 根据
hash0
和键如上图的name
值生成哈希值011011100011111110111011011
- 根据哈希值的后B位对应的值找到键值对存放的桶(bmap),如上的哈希后3位对应的值为
011
转化为十进制为3 - 通过哈希值的
高八位
确定tophash
值,然后再遍历比较桶(正常桶和溢出桶)中8个tophash
,找到相等的tophash
值,根据桶中的tophash
地址偏移量找到桶中的key
值,然后再比较桶中key
和传入key
是否相等,相同再去根据桶中的key
地址偏移量去查询到value
- 查询到
value
,返回value
,没有找到,返回value
的零值,如果带ok会返回数据是否存在的布尔值;
写入数据
arduino
info["name"] = "重庆市"
使用的函数runtime.mapassign
实现流程:
- 根据
hash0
和键name
计算出哈希值,根据哈希值后B位确认桶的位置,高8位和key值确认数据是否存在 - 如果存在直接返回
value
的地址,不存在创建数据,移动数据到桶中内存地址 - 不存在数据,创建数据后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的遍历,通过两个问题来查看源码
- 为什么遍历是无序的?
- 当在发生翻倍扩容时遍历的流程是怎样的?
遍历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
个槽开始往后找非空的键值对,找到了就返回键值
需要注意以下几点:
- 遍历每个桶时,从第
offset
个槽位开始遍历, - 例如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,用链表法解决哈希冲突。
学习来源: