哈希表
是常见的数据结构,其本质就是用空间换取时间。
各种语言对哈希表
的实现各有不同,本文主要以Java和Go以及Redis的哈希表做主要陈述。
哈希表的实现
Java中的实现
熟悉的Java的同学都知道,HashMap
是老朋友了,经常使用到且非常好用。相比起以前的HashTable,HashMap支持null作为键值插入,作为键只能有一个,作为值可以有多个。
JDK1.8 之前 HashMap 由 数组+链表
组成的,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的(“拉链法”解决冲突)。
JDK1.8 以后的 HashMap
在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间。
HashMap
默认的初始化大小为 16。之后每次扩充,容量变为原来的 2 倍。并且, HashMap
总是使用 2 的幂作为哈希表的大小。给定的默认容量为 16,负载因子为 0.75。Map 在使用过程中不断的往里面存放数据,当数量达到了 16 * 0.75 = 12 就需要将当前 16 的容量进行扩容,而扩容这个过程涉及到 rehash、复制数据等操作,所以非常消耗性能。
并发安全的Map
我们知道HashMap
在并发下是不安全的,在高并发的情况下:
Rehash过程将数据迁移到新桶的过程中,数组链表中的链表形成循环链表,在后面的get操作时会无限循环
详细细节可以参考
blog.csdn.net/bjwfm2011/a…
Java 7 中 ConcurrentHashMap
的存储结构如上图,ConcurrnetHashMap
由很多个 Segment
组合,而每一个 Segment
是一个类似于 HashMap 的结构,所以每一个 HashMap
的内部可以进行扩容。但是 Segment
的个数一旦初始化就不能改变,默认 Segment
的个数是 16 个,你也可以认为 ConcurrentHashMap
默认支持最多 16 个线程并发。
Java 8 中的 ConcurrentHashMap
使用 Synchronized 锁
加 CAS
的机制来确保并发安全。结构与Java 8 的HashMap Node 数组 + 链表 / 红黑树 类似。它的冲突再达到一定大小时会转化成红黑树,在冲突小于一定数量时又退回链表。
关于如何保证并发安全可以参考blog.csdn.net/taurus_7c/a…
Go中的实现
参考视频www.bilibili.com/video/BV1Sp…
bmap是具体存kv键值对的结构,上面8bit存的是tophash(哈希值的高8位),接下来是8个key紧凑和8个value紧凑。
- count: 键值对数目
- B: 桶数目是2的多少次幂
- buckets: 指向bmap也就是具体的桶
- old buckets: 指向旧桶,扩容时用
- nevacuate: 即将迁移的旧桶编号
Go语言使用渐进式
的方式扩容,与Redis一样,这是为了rehash的时候不产生瞬时的抖动影响性能。Go语言中的负载因子为6.5,即count/2^B > 6.5时扩容成两倍。如果B>4那么认为用到溢出桶的概率比较大,会产生2^(B-4)个溢出桶,溢出桶是为了避免rehash,且溢出桶与前面的2^B桶地址连续。
并发安全的Map
go 1.9之前的解决Map并发不安全是额外绑定一个锁
,封装成一个新的struct或者单独使用锁都可以。
与Java处理类似,Go语言社区广泛使用分段锁
策略将 key 分散到固定数量的 shard
中避免 rehash
操作。shard 是有锁保护的 map, 当 shard 进行 rehash 时会阻塞shard内的读写,但不会对其他 shard 造成影响。
具体源码实现参考studygolang.com/articles/18…
Go语言在1.9版本推出了sync.Map
其优点有:
- 空间换时间。 通过
冗余
的两个数据结构(read
、dirty
),实现加锁对性能的影响。 - 使用只读数据(read),避免读写冲突。
- 动态调整,
miss
次数多了之后,将dirty数据提升为read。 - double-checking。
- 延迟删除。 删除一个键值只是打标记,只有在提升dirty的时候才清理删除的数据。
- 优先从read读取、更新、删除,因为对read的读取不需要锁。
它使用了冗余的数据结构read
、dirty
。dirty
中会包含read
中为删除的entries,新增加的entries会加入到dirty
中。
- mu (Mutex): 当涉及到dirty数据的操作的时候,需要使用这个
锁
- read (atomic.Value): 一个
只读
的数据结构,因为只读,所以不会有读写冲突。 - dirty (map[interface{}]*entry): 需要
加锁
,因为对它的操作可能会有读写竞争
。 - misses (int): 如果
read
中不包含这个entry, 会尝试从dirty
中读取,这个时候会将misses
+ 1,当misses累积到dirty
的长度的时候, 就会将dirty提升
为read,避免从dirty中miss太多次。因为操作dirty需要加锁。
虽然read
和dirty
有冗余数据,但这些数据是通过指针指向同一个数据,所以尽管Map的value会很大,但是冗余的空间占用还是有限的。
如果查询
的键值正好存在于read
中,无须加锁,直接返回,理论上性能优异。即使不存在于read
中,经过miss
几次之后,dirty
会被提升为read
,又会从read
中查找。所以对于更新/增加较少,加载存在的key很多的case, 性能基本和无锁的map类似。双检查
避免加锁的时候dirty提升为read, 这个时候read可能被替换了查找
不到。
修改(新增、更新和删除
)都是先从操作read
开始的,不满足条件再加锁,然后操作dirty
。如果这个entry不存在于read
中,并且dirty
中有新数据,则加锁尝试从dirty
中修改。删除
要双检查的,从dirty
中直接删除即可,就当它没存在过,但是如果是从read
中删除,并不会直接删除,而是打标记。
Redis中的实现
参考www.w3cschool.cn/hdclil/lun1…
- table: 是一个数组, 数组中的每个元素都是一个指向 dictEntry 结构的指针, 每个
dictEntry
结构保存着一个键值对。 - size: 记录了哈希表的大小, 也即是
table
数组的大小 - used: 记录了哈希表目前已有节点(键值对)的数量。
- sizemask: 等于
size - 1
, 这个属性和哈希值一起决定一个键应该被放到table
数组的哪个索引上面。
ht有两个dictht, 第二个ht[1]是扩容时候用的,和Go语言一样采用渐进式
扩容。 扩容时可以看成,ht[0]的元素慢慢的被取下放到ht[1]里面。当扩容完毕后,原ht[1]升级为新ht[0], 原ht[0]直接为空的充当新的ht[1]。
在扩容期间,会同时使用 ht[0]
和 ht[1]
两个哈希表, 所以在渐进式 rehash 进行期间, 字典的删除(delete)、查找(find)、更新(update)等操作会在两个哈希表上进行: 比如说, 要在字典里面查找一个键的话, 程序会先在 ht[0]
里面进行查找, 如果没找到的话, 就会继续到 ht[1]
里面进行查找, 诸如此类。
另外, 在渐进式 rehash 执行期间, 新添加到字典的键值对一律会被保存到 ht[1]
里面, 而 ht[0]
则不再进行任何添加操作: 这一措施保证了 ht[0]
包含的键值对数量会只减不增, 并随着 rehash 操作的执行而最终变成空表。
本文转载自: 掘金