一、概念及设计原理
哈希是除了数组之外,最常见的数据结构。几乎所有的语言都会有数组和哈希表两种集合元素,有的语言将数组实现成列表,而有的语言将哈希称作字典或者映射。无论如何命名或者如何实现,数组和哈希是两种设计集合元素的思路,数组用于表示元素的序列,而哈希表示的是键值对之间映射关系。
哈希表是计算机科学中的最重要数据结构之一,这不仅因为它 O(1)
的读写性能非常优秀,还因为它提供了键值之间的映射。想要实现一个性能优异的哈希表,需要注意两个关键点 —— 哈希函数和冲突解决方法。
1.1 哈希函数
实现哈希表的关键点在于哈希函数的选择,哈希函数的选择在很大程度上能够决定哈希表的读写性能。在理想情况下,哈希函数应该能够将不同键映射到不同的索引上,这要求哈希函数的输出范围大于输入范围,但是由于键的数量会远远大于映射的范围,所以在实际使用时,这个理想的效果是不可能实现的。
比较实际的方式是让哈希函数的结果能够尽可能的均匀分布,然后通过工程上的手段解决哈希碰撞的问题。哈希函数映射的结果一定要尽可能均匀,结果不均匀的哈希函数会带来更多的哈希冲突以及更差的读写性能。
如果使用结果分布较为均匀的哈希函数,那么哈希的增删改查的时间复杂度为 O(1)
;但是如果哈希函数的结果分布不均匀,那么所有操作的时间复杂度可能会达到 O(n)
,由此看来,使用好的哈希函数是至关重要的。
1.2 解决冲突
就像我们之前所提到的,在通常情况下,哈希函数输入的范围一定会远远大于输出的范围,所以在使用哈希表时一定会遇到冲突,哪怕我们使用了完美的哈希函数,当输入的键足够多也会产生冲突。然而多数的哈希函数都是不够完美的,所以仍然存在发生哈希碰撞的可能,这时就需要一些方法来解决哈希碰撞的问题,常见方法的就是开放寻址法和拉链法。
需要注意的是,这里提到的哈希碰撞不是多个键对应的哈希完全相等,可能是多个哈希的部分相等,例如:两个键对应哈希的前四个字节相同。
开放寻址法
开放寻址法
是一种在哈希表中解决哈希碰撞的方法,这种方法的核心思想是依次探测和比较数组中的元素以判断目标键值对是否存在于哈希表中,如果我们使用开放寻址法来实现哈希表,那么实现哈希表底层的数据结构就是数组,不过因为数组的长度有限,向哈希表写入 (author, draven) 这个键值对时会从如下的索引开始遍历:
index := hash("author") % array.len
当我们向当前哈希表写入新的数据时,如果发生了冲突,就会将键值对写入到下一个索引不为空的位置:
开放寻址法中对性能影响最大的是装载因子,它是数组中元素的数量与数组大小的比值。随着装载因子的增加,线性探测的平均用时就会逐渐增加,这会影响哈希表的读写性能。当装载率超过 70%
之后,哈希表的性能就会急剧下降,而一旦装载率达到 100%
,整个哈希表就会完全失效,这时查找和插入任意元素的时间复杂度都是 O(n)
的,这时需要遍历数组中的全部元素,所以在实现哈希表时一定要关注装载因子的变化。
拉链法
与开放地址法相比,拉链法是哈希表最常见的实现方法,大多数的编程语言都用拉链法实现哈希表,它的实现比较开放地址法稍微复杂一些,但是平均查找的长度也比较短,各个用于存储节点的内存都是动态申请的,可以节省比较多的存储空间。
实现拉链法一般会使用数组加上链表,不过一些编程语言会在拉链法的哈希中引入红黑树以优化性能,拉链法会使用链表数组作为哈希底层的数据结构,我们可以将它看成可以扩展的二维数组:
如上图所示,当我们需要将一个键值对 (Key6, Value6) 写入哈希表时,键值对中的键 Key6 都会先经过一个哈希函数,哈希函数返回的哈希会帮助我们选择一个桶,和开放地址法一样,选择桶的方式是直接对哈希返回的结果取模:
index := hash("Key6") % array.len
选择了 2 号桶后就可以遍历当前桶中的链表了,在遍历链表的过程中会遇到以下两种情况:
1、找到键相同的键值对 — 更新键对应的值;
2、没有找到键相同的键值对 — 在链表的末尾追加新的键值对;
如果要在哈希表中获取某个键对应的值,会经历如下的过程:
二、数据结构体
Go 语言运行时同时使用了多个数据结构组合表示哈希表,其中 runtime.hmap
是最核心的结构体,我们先来了解一下该结构体的内部字段:
type hmap struct {
// Note: the format of the hmap is also encoded in cmd/compile/internal/reflectdata/reflect.go.
// Make sure this stays in sync with the compiler's definition.
count int // # live cells == size of map. Must be first (used by len() builtin)
flags uint8
B uint8 // log_2 of # of buckets (can hold up to loadFactor * 2^B items)
noverflow uint16 // approximate number of overflow buckets; see incrnoverflow for details
hash0 uint32 // hash seed
buckets unsafe.Pointer // array of 2^B Buckets. may be nil if count==0.
oldbuckets unsafe.Pointer // previous bucket array of half the size, non-nil only when growing
nevacuate uintptr // progress counter for evacuation (buckets less than this have been evacuated)
extra *mapextra // optional fields
}
字段 | 说明 |
---|---|
count |
表示当前哈希表中的元素数量 |
B |
表示当前哈希表持有的 buckets 数量,因为桶的数量都是2的倍数,所以 len(buckets) == 2^B |
hash0 |
哈希种子,它能为哈希函数的结果引入随机性,这个值在创建哈希表时确定,并在调用哈希函数时作为参数传入 |
oldbuckets |
是哈希在扩容时用于保存之前 buckets 的字段,它的大小是当前 buckets 的一半; |
buckets |
是桶数组,长度由 B 决定 |
如上图所示hmap
中的 buckets
是由 runtime.bmap
组成的数组,而 bmap
就是我们所说的桶;每一个 bmap
都能存储 8 个键值对,当哈希表中存储的数据过多,单个桶已经装满时就会使用 extra.nextOverflow
中桶存储溢出的数据。
上述两种不同的桶在内存中是连续存储的,我们在这里将它们分别称为正常桶和溢出桶,上图中黄色的 bmap
就是正常桶,绿色的 bmap
是溢出桶,溢出桶是在 Go 语言还使用 C 语言实现时使用的设计,由于它能够减少扩容的频率所以一直使用至今。
桶的结构体 runtime.bmap
在 Go 语言源代码中的定义只包含一个简单的 tophash
字段,tophash
存储了键的哈希的高 8 位,通过比较不同键的哈希的高 8 位可以减少访问键值对次数以提高性能:
type bmap struct {
tophash [bucketCnt]uint8
}
// 在编译期间会产生新的结构体
type bmap struct {
topbits [8]uint8
keys [8]keytype
values [8]valuetype
pad uintptr
overflow uintptr
}
三、读写操作
2.1 写入数据
数据结构的写一般指的都是增加、删除和修改,增加和修改字段都使用索引和赋值语句,而删除字典中的数据需要使用关键字 delete
:
hash[key] = value
hash[key] = newValue
delete(hash, key)
1、通过key的hash值后 B
位确定是哪一个桶,图中示例为4号桶。
2、遍历当前桶,通过 key 的 tophash 和 hash 值,防止 key 重复,然后找到第一个可以插入的位置,即空位置处存储数据。
3、如果当前桶元素已满,会通过overflow链接创建一个新的桶,来存储数据。
随着哈希表存储的数据逐渐增多,哈希冲突的概率就越大;我们会扩容哈希表或者使用额外的桶存储溢出的数据,不会让单个桶中的数据超过 8
个,不过溢出桶只是临时的解决方案,创建过多的溢出桶最终也会导致哈希的扩容。
2.2 读取数据
哈希表作为一种数据结构,我们肯定要分析它的常见操作,首先就是读写操作的原理。哈希表的访问一般都是通过下标或者遍历进行的:
_ = hash[key]
for k, v := range hash {
// k, v
}
1、计算 k4 的hash值。[由于当前主流机都是64位操作系统,所以计算结果有64个比特位]
2、通过最后的 B
位来确定在哪号桶,此时B为4,所以取k4对应哈希值的后4位,也就是0101,0101用十进制表示为5,所以在5号桶)
3、根据k4对应的hash值前8位快速确定是在这个桶的哪个位置(额外说明一下,在bmap中存放了每个key对应的tophash,是key的哈希值前8位),一旦发现前8位一致,则会执行下一步
4、对比key完整的hash是否匹配,如果匹配则获取对应value
5、如果都没有找到,就去连接的下一个溢出桶中找
哈希会依次遍历正常桶和溢出桶中的数据,它会先比较哈希的高 8 位和桶中存储的 tophash,后比较传入的和桶中的值以加速数据的读写。用于选择桶序号的是哈希的最低几位,而用于加速访问的是哈希的高 8 位,这种设计能够减少同一个桶中有大量相等 tophash 的概率影响性能。
v, ok := hash[k]
使用 v, ok := hash[k]
的形式访问哈希表中元素时,我们能够通过这个布尔值更准确地知道当 v == nil
时,v
到底是哈希中存储的元素还是表示该键对应的元素不存在,所以在访问哈希时,更推荐使用这种方式判断元素是否存在。
2.3 扩容
随着哈希表中元素的逐渐增加,哈希的性能会逐渐恶化,所以我们需要更多的桶和更大的内存保证哈希的读写性能,即对哈希表扩容;runtime.mapassign
函数会在以下两种情况发生时触发哈希的扩容:
1、装载因子已经超过 6.5;
2、哈希使用了太多溢出桶;
因为 Go 语言哈希的扩容不是一个原子的过程、还需要判断当前哈希是否已经处于扩容状态,避免二次扩容造成混乱。
根据触发的条件不同扩容的方式分成两种等量扩容和翻倍扩容。
等量扩容
溢出的桶太多导致的扩容,那么这次扩容就是等量扩容 sameSizeGrow
;
注意
前文讲到每个桶最多能存 8 个,若超过会以拉链的方式放到溢出桶,当我们持续向哈希中插入数据并将它们全部删除时,如果哈希表中的数据量没有超过阈值,就会不断积累溢出桶造成缓慢的内存泄漏。
而等量扩容就是通过复用已有的哈希扩容机制解决该问题,一旦哈希中出现了过多的溢出桶,它会创建新桶保存数据,垃圾回收会清理老的溢出桶并释放内存。
runtime.hashGrow
函数会创建新桶,哈希表的数据迁移的过程在是 runtime.evacuate
函数中完成的。
runtime.evacuate
将旧桶中的数据分流到新桶去,创建一个 runtime.evacDst
结构体用来保存分配上下文:
扩容后的哈希表桶的数量比之前大,则可能将旧桶的数据分流到不同的桶中:
所有的旧桶都被分流后清空哈希的 oldbuckets
和 oldoverflow
。
提示
若扩容时访问哈希表,若 oldbuckets
存在时,会先定位到旧桶并在该桶没有被分流时从中获取键值对。
因为旧桶中的元素还没有被 runtime.evacuate
函数分流,其中还保存着我们需要使用的数据,所以旧桶会替代新创建的空桶提供数据。
哈希在存储元素过多时会触发扩容操作,每次都会将桶的数量翻倍,扩容过程不是原子的,而是通过
runtime.growWork
增量触发的,在扩容期间访问哈希表时会使用旧桶,向哈希表写入数据时会触发旧桶元素的分流。除了这种正常的扩容之外,为了解决大量写入、删除造成的内存泄漏问题,哈希引入了sameSizeGrow
这一机制,在出现较多溢出桶时会整理哈希的内存减少空间的占用。
2.4 删除
如果想要删除哈希中的元素,就需要使用 Go 语言中的 delete
关键字,这个关键字的唯一作用就是将某一个键对应的元素从哈希表中删除,无论是该键对应的值是否存在,这个内建的函数都不会返回任何的结果。
哈希表的删除逻辑与写入逻辑很相似,只是触发哈希的删除需要使用关键字,如果在删除期间遇到了哈希表的扩容,就会分流桶中的元素,分流结束之后会找到桶中的目标元素完成键值对的删除工作。
三、总结
Go 语言使用拉链法来解决哈希碰撞的问题实现了哈希表,它的访问、写入和删除等操作都在编译期间转换成了运行时的函数或者方法。哈希在每一个桶中存储键对应哈希的前 8 位,当对哈希进行操作时,这些 tophash
就成为可以帮助哈希快速遍历桶中元素的缓存。
哈希表的每个桶都只能存储 8 个键值对,一旦当前哈希的某个桶超出 8 个,新的键值对就会存储到哈希的溢出桶中。随着键值对数量的增加,溢出桶的数量和哈希的装载因子也会逐渐升高,超过一定范围就会触发扩容,扩容会将桶的数量翻倍,元素再分配的过程也是在调用写操作时增量进行的,不会造成性能的瞬时巨大抖动。