哈希表冲突解决
在通常情况下,哈希函数输入的范围一定会远远大于输出的范围,所以在使用哈希表时一定会遇到冲突。这时就需要一些方法来解决哈希碰撞的问题,常见方法就是开放寻址法和拉链法
开放寻址法
数据结构: 数组
核心思想:依次探测和比较数组中的元素以判断目标键值对是否存在在于哈希表中。当我们向当前哈希表写入新的数据时,如果发生了冲突,就会将键值对写入到下一个索引为空的位置。
当需要查找某个键对应的值时,会从索引的位置开始线性探测数组,找到目标键值对或者空内存就意味着这一次查询操作的结束。
开放寻址法中对性能影响最大的是装载因子,它是数组中元素的数量与数组大小的比值。随着装载因子的增加,线性探测的平均用时就会逐渐增加,这会影响哈希表的读写性能。当装载率超过 70% 之后,哈希表的性能就会急剧下降,而一旦装载率达到 100%,整个哈希表就会完全失效,这时查找和插入任意元素的时间复杂度都是 O(n)O(n) 的,这时需要遍历数组中的全部元素,所以在实现哈希表时一定要关注装载因子的变化。
拉链法
数据结构: 数组+链表,有些实现引入红黑树优化性能
拉链法写入数据
- 使用哈希函数计算出存储桶,例如直接对哈希返回的结果取模 index := hash("Key6") % array.len
- 选择桶后遍历当前桶中的链表 如果找到键相同的键值对就更新键对应的值,如果没有找到键相同的键值对就在链表的末尾追加新的键值对
拉链法读取数据
使用哈希函数计算出存储桶,遍历桶中的链表,直到找到期望的值,如果找不到,说明哈希表中没有该键对应的值
拉链法的转载因子概念:装载因子:=元素数量÷桶数量
Golang哈希表
数据结构
type hmap struct {
count int //哈希表中的元素数量
flags uint8
B uint8 //哈希表持有的桶数量,数量为2的B次方
noverflow uint16
hash0 uint32 //哈希种子,为哈希函数结果引入随机性
buckets unsafe.Pointer
oldbuckets unsafe.Pointer //哈希在扩容时用于保存之前buckets的字段,大小是当前buckets的一半
nevacuate uintptr
extra *mapextra
}
type mapextra struct {
overflow *[]*bmap
oldoverflow *[]*bmap
nextOverflow *bmap
}
哈希表中一个桶就是一个bmap,每一个bmap都能存储8个键值对,当哈希表中存储的数据过多,单个桶已经装满时就会使用extra.nextOverflow中桶存储溢出的数据。上述两种不同的桶在内存中是连续存储的,我们在这里将它们分别称为正常桶和溢出桶。溢出桶能够减少扩容的频率。
bmap结构体:
type bmap struct {
tophash [bucketCnt]uint8
}
tophash 存储了键的哈希的高8位,通过比较不同键的哈希的高8位可以减少访问键值对次数以提高性能。
在运行期间,bmap会重建结构体
type bmap struct {
topbits [8]uint8
keys [8]keytype
values [8]valuetype
pad uintptr
overflow uintptr
}
随着哈希表存储的数据逐渐增多,我们会扩容哈希表或者使用额外的桶存储溢出的数据,不会让单个桶中的数据超过8个,不过溢出桶只是临时的解决方案,创建过多的溢出桶最终也会导致哈希的扩容。
初始化
hash := map[string]int{
"1": 2,
"3": 4,
"5": 6,
}
当哈希表中的元素数量少于或者等于25个时,编译器会将所有的键值对一次加入到哈希表中。
hash := make(map[string]int, 3)
hash["1"] = 2
hash["3"] = 4
hash["5"] = 6
当哈希表元素数量超过了25个,编译器会创建两个数组分别存储键和值,这些键值对会通过for循环加入哈希
hash := make(map[string]int, 26)
vstatk := []string{"1", "2", "3", ... , "26"}
vstatv := []int{1, 2, 3, ... , 26}
for i := 0; i < len(vstak); i++ {
hash[vstatk[i]] = vstatv[i]
}
运行时
当创建的哈希被分配到栈上并且其容量小于BUCKETSIZE=8时,Go语言在编译阶段会使用如下方法快速初始化哈希,这也是编译器对小容量的哈希做的优化:
var h *hmap
var hv hmap
var bv bmap
h := &hv
b := &bv
h.buckets = b
h.hash0 = fashtrand0()
当我们使用make函数创建哈希,Go语言编译器都会在类型检查期间将它们转换成runtime.makemap,使用字面量初始化哈希也只是语言提供的辅助工具。 makemap 函数执行步骤:
- 计算哈希占用的内存是否溢出或者超出能分配的最大值
- 调用runtime.fastrand 获取一个随机的哈希种子
- 根据传入的hint计算出需要的最小需要的桶的数量
- 使用runtime.makeBucketArray创建用于保存桶的数组。makeBucketArray会根据传入的B计算出需要创建的桶数量并在内存中分配一片连续的空间用于存储数据。
- 当桶的数量小于2的四次方,会省略创建溢出桶的过程
- 当桶的数量大于2的四次方,会额外创建2的B-4次方个溢出桶
读写操作
get操作:
- 计算key的哈希值(64位操作系统,计算结果有64个比特位)
- 通过最后的“B”位来确定几号桶(B为桶的数量,且为2的倍数),例如B为4,则取哈希值的后4位的二进制数转换为十进制数确定桶的位置
- 根据哈希值的前8位快速确定桶内的具体位置(类似于缓存设计,作用是快速定位)
- 对比KEY完整的HASH是否匹配,如果匹配则获取对应VALUE,如果不匹配继续查询
- 如果在正常桶内都没有找到,就会去下一个溢出桶中查找。
put操作
- 计算key的哈希值,通过最后的“B”位确定几号桶
- 遍历当前桶,比较存储的键值对的tophash和完整哈希,如果找到了相同的key,那么更新key的value。如果没有找到,那么在第一个空闲的位置插入键值对。
- 如果当前桶元素已满,会通过overflow链接创建一个新的桶 golang的哈希表使用的是拉链法解决哈希冲突。
扩容
扩容的条件:
- 装载因子已经超过了6.5 装载因子为元素的个数/桶的个数
- 哈希使用了太多溢出桶
等量扩容——整理重排
由于map中不断的put和delete key,桶中可能会出现很多不连续的空位,这些空位会导致连接的bmap溢出桶很长,导致扫描时间变长,此时元素会发生重排,将溢出桶的元素放入正常桶中。等量扩容创建的新桶数量和旧桶一样,也没有对数据进行拷贝和转移。
2倍容量扩容
- 当哈希表的容量翻倍,我们会创建一个两倍容量的新桶,每个旧桶的元素都会分流到新创建的桶中。
- 扩容发生时,并不会一次性将旧桶数据迁移到新桶中,而是每次对map进行删改操作时,会将旧桶中的数据迁移一部分到新桶。(渐进式rehash)这样的好处是不会造成性能的瞬时巨大抖动。
- 在扩容没有完全迁移完成之前,每次get或者put遍历数据时,都会先遍历旧桶,再遍历buckets。
Go语言哈希的扩容不是一个原子的过程,所以扩容的时候需要判断当前哈希是否已经处于扩容状态
使用map的注意事项
- 对map数据进行操作时不可取地址原因:map在扩容的过程中会重新分配内存空间,导致之前的地址无效
- map是线程不安全的,并发使用的时候需要搭配mutex使用或者使用sync.map
sync.Map
实现原理
- sync.map使用了写时复制的技术实现了高并发的map. sync.Map主要实现原理是维护两个map分别是read和dirty map,read map是只读atomic.value类型,所以它的读是并发安全的。而dirty类型是普通的map类型的指针,我们需要使用mutex保护dirty。存储指针还能避免因为同时维护read和dirty两个map导致的内存浪费。
- 当key在read和dirty都存在时,就可以直接替换指针实现无锁更新map,当key不存在,我们需要加锁添加键值对。
- read可以是看作dirty的一个缓存,当我们需要读取数据的时候,先从read中读取,如果read不存在,再加锁在dirty中读取。如果raed缓存未命中次数等于dirty数组的长度,我们就会将dirty数组提升至read数组。
Store更新插入操作
- 首先查询read中是否存在该key,如果存在,则尝试更新。查询dirty中是否存在该key,如果dirty也存在,则直接通过指针替换进行更新,不需要加锁。
- 如果read不存在,加锁之后去dirty数组中查找该key
- 如果dirty中没有任何数据,则将该key添加至dirty并初始化dirty。
- 如果dirty中存在,则更新或者写入该key。
Delete操作
- 首先判断read中是否存在该key,如果存在,先通过指针是否指向expunged判断dirty中是否存在该key,如果不存在则直接返回,如果存在则直接通过CAS操作将指针指向nil删除该key
- 如果read中不存在该key,则通过amended字段判断dirty是否跟read一致,如果不一致,则加锁并通过双检查直接将dirty数组中的key删除。 需要注意的是在删除读表中的key是通过指针指向nil删除,而写表删除key则是直接通过map的delete函数删除。
Load查找操作
- 先从read查找是否存在该key
- 如果read中不存在该key,并且dirty和read不一致,则加锁从dirty中查找。
- 从dirty中查找的时候会调用missLocked函数判断misses也就是read未命中的次数是否等于dirty数组的长度。如果相等,则将dirty数组提升至read函数,清空dirty数组。重置misses计数。