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键值对,我们要将其存储到合适的位置需要经过两个步骤:
- 计算key的hash值:hash = hashFunc(key)
- 计算对应桶的位置: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:
inikey := add(unsafe.Pointer(b), dataOffset + i * uintptr(t.keysize))
dataOffset
是data区域中的第一个key的起始偏移量i * uintptr(t.keysize)
表示第i个key相对于data起始位置的偏移(t.keysize是单个key的大小)定位value:
inie := 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)
表示第i
个value
相对于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
里,它并非真的只存储一个字节的数据,而是作为一个占位符,为后续连续存储key
和value
预留空间。
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赋值的整个简单流程如下:
-
map写检测,如果此时map正处于写状态,表示此时不能进行读取,会报fatal error
-
计算出hash值,将map置为写状态
-
判断桶数组是否为空,如果为空则进行桶数组的初始化
-
目标桶查找
a. 根据hash哈希值找到桶的位置
b. 根据当前是否处于扩容状态,若正在扩容,则迁移这个桶,并且另外帮忙多迁移一个桶(这个桶是被迁移进度指向的桶)
c. 获取目标桶的指针,计算出tophash,开始后面的key的查找过程
-
key查找(根据hash值后B位来确定key在哪个桶)
a. 遍历桶和它的溢出桶的每个槽位,按照下述方式进行查找
b. 判断槽位的tophash和目标tophash相不相等(后继空状态概念请往下看看到删除原理就明白了)
i. 不相等
1. 槽位tophash为空,标记这个位置为候选位置(为空怎么不直接插入?因为后续可能还有没遍历到的位置已经存在这个key)
2. 槽位tophash的标识位为 "后继空状态",说明这个key之前没有被插入过(后继为空说明这个位置和后面的位置都为空)
3. tophash标识位部位空,说明存储着其他key,说明当前槽的tophash不符合,继续遍历下一个槽位
ii. 相等
1. 判断当前槽位的key与目标key是否相等,相等,则修改对应的value,不相等,继续遍历下一个槽位
-
key插入
a. 若map中既没有找到key,且根据这个key找到的桶及其这个桶的溢出桶中没有空的槽位了,要申请一个新的溢出桶并插入
b. 否则在找到的位置插入
-
收尾程序
a. 再次判断map的写状态
b. 清除map的写状态
这里需要特别注意一点:申请一个新的溢出桶的时候并不会马上创建一个溢出桶,因为map在初始化的时候会提前创建好一些溢出桶
这些提前创建好的溢出桶存储在extra * mapextra字段中,当出现溢出的现象的时候,这些溢出桶会被优先使用
只有当预分配的溢出桶使用完了之后,才会再新建溢出桶
map访问原理
对map的访问有两种方式
go
value := map[key]
value, ok := map[key] // 多了一个key存不存在的布尔值
大致原理如下图:

梳理一下map访问的大致流程:
-
判断map是否为空或者无数据,若为空或者无数据,则返回对应的空值
-
map写检测,如果正处于写状态,表示此时不能进行操作,报fatal error
-
计算出对应key的hash值
-
判断当前map是否处于扩容状态,如果在扩容执行的状态,则执行下面步骤:
a. 根据状态位判断当前桶是否被迁移
b. 如果迁移,在新桶中查找,未被迁移,在旧桶中查找
-
依次遍历桶以及溢出桶来查找key
a. 遍历桶内的8个槽位
b. 比较该槽位的tophash和当前key的tophash是否相等
i. 如果相等,继续比较key是否相同,相同则直接返回对应的value
ii. 不相同,查看这个槽位的状态位是否为 "后继空状态"(删除原理中会提到什么是后继空状态)
是,key在以后的槽位中也没有了,那么这个key不存在,直接返回零值
否,继续遍历下一个槽位
-
当前桶没有找到,则遍历溢出桶,用同样的方式进行查找
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的遍历结果本就是不同的,所以为了防止用户错误的依赖每次迭代的顺序,索性每次遍历的时候,就随机选取位置