参考:
一、Go Map底层结构:
Go map的底层实现是一个哈希表(数组 + 链表),使用拉链法消除哈希冲突,因此实现map的过程实际上就是实现哈希表的过程。
先来看下go map底层的具体结构:
type hmap struct {
count int // 元素个数,调用len(map)返回这个值
B uint8 // bucket数量是2^B, 最多可以放 loadFactor * 2^B 个元素,再多就要扩容了
hash0 uint32 // hash seed
buckets unsafe.Pointer // 指向bucket数组的指针(存储key val);大小:2^B
oldbuckets unsafe.Pointer // 扩容时,buckets 长度是 oldbuckets 的两倍
// ...
}
type bmap struct {
topbits [8]uint8 // 高位哈希值数组
keys [8]keytype // 存储key的数组
values [8]valuetype // 存储val的数组
overflow uintptr // 指向当前bucket的溢出桶
// 为缓解当存在多个key计算后的哈希值低8位相同的个数大于一个bucket所能存放的数目8个时,且这个map还没达到扩容条件时,做的一种存储设计。
}
在这个哈希表中,主要涉及到的结构体有两个:一个是 hmap
(a header for a go map),一个是 bmap
(a bucket for a go map):
- 对于
hmap
,我们只需要关注其中的buckets
,它是一个指向bmap
结构体类型数组的指针。- 而对于其中的
bmap
:- 高位哈希值
topbits
:数组记录的是当前bucket中key相关的 “索引” - 指向扩容bucket的指针
overflow
:每个 bmap类型的 bucket 最多只能放 8个k-v键值对。如果碰巧有key的哈希值一样的新数据存入当前bucket,那就需要再构建一个新的溢出桶 bucket,并通过overflow指针连接起来,使得bucket形成一个链表结构。 - 存储key/value的数组
keys
、values
- 高位哈希值
- 而对于其中的
二、key-value是如何存放的:
当前bucket桶中的 key-value 的值的存放是有其特点的,bucket桶中所有的key存放到 keys
数组中,而所有的value存放到 values
数组中。
这么做的原因也很简单,可以在key和value的长度不同时,消除padding(内存对齐)带来的空间浪费。具体如图所示:
三、根据key 查找/新增 数据:
对传来的key进行哈希运算得到唯一哈希值,并将该哈希值分为高位和低位,如图所示:
蓝色为高位,红色为低位。 低位用于寻找当前key属于哪个bucket,而高位用于寻找对应bucket中的具体key。
而之前 bmap
中的高位哈希值数组字段 topbits
,存的就是当前bucket桶中不同key-value键值对中对应key的高位哈希值,这样便于根据key查找数据。
新增的过程与查找过程类似,也是填充桶的过程。
四、删除map中的数据
针对map中的key-value数据:
- 如果是指针类型数据,则将其原有引用去除,利用go GC来清理内存
- 如果是值类型数据,则直接清理对应内存空间
最后将该key-value记录对应的 【bmap
中高位哈希值数组 topbits
】中的key相关 “索引” 置空。
五、map的扩容
当go map中每个bucket桶存储的平均元素个数大于加载因子 loadFactor = 6.5
(判断扩容的条件)时,map底层就会创建一个容量大小是原来2倍的新buckets数组,并将 oldbuckets
指针指向原来的旧buckets数组。然后,对旧buckets数组中的元素key重新哈希(rehash)得到新的哈希值,根据新的哈希值的高位和低位来放入扩容后的新buckets数组中。
加载因子越小↓,说明空间利用率低,因此 “产生冲突的机会” 低;
加载因子越大↑,说明空间利用率高,但是 “产生冲突的机会” 也高了。
不过需要注意的是:
并不是立刻把 oldbuckets
指针所指向的旧bucket数组中的元素一次性转移到新的bucket数组当中,而是当只有访问到具体某个key所在的bucket时,才会将该bucket中的旧数据逐步迁移到新bucket中。一直到旧数据完全迁移完,才会删除 oldbuckets
的指向,使得旧buckets空间得到释放。如下图所示:
这里迁移完并不会直接删除旧bucket中的数据,而是把原来旧数据的引用去掉,利用GC逐步清除内存。
最终迁移完成后,会释放oldbuckets
指针指向的旧哈希表占用的内存空间。
六、map的等量扩容(缩容)
map中数据较少,但 overflow
指向的溢出桶bucket数量过多时,会导致溢出桶中的记录存储很稀疏,排列不紧凑,大量空间被浪费。这时就需要进行等量扩容/缩容(一般出现在之前数据被大量删除的场景下)。
其实就是重新整理一下数据,使溢出桶中的数据重新紧凑的放在普通bucket桶中,避免不必要的空间浪费。
七、map的渐进式扩容
Go Map 扩容,做数据迁移时为何不一次性迁移数据,而是等到访问到具体某个bucket时才将数据从旧bucket中迁移到新bucket中?
- 一次性迁移会涉及到cpu资源和内存资源的占用,在数据量较大时,会有较大的延时,影响正常业务逻辑。因此Go采用渐进式的数据迁移,每次最多迁移两个bucket的数据到新的buckets中(一个是当前访问key所在的bucket,然后再多迁移一个bucket)。
- 尤其是cpu资源,扩容时需要迁移map中
oldbuckets
的元素,其中的 rehash 操作(计算键在新哈希表中新的位置)会消耗cpu的计算资源,有可能会影响到用户协程的调度。 - 而内存资源,因为最终还是会将所有的旧数据进行迁移,因此内存占用的大小最终其实还是一样的,影响不大。