哈希表の奇妙冒险

2,147 阅读7分钟

哈希表是常见的数据结构,其本质就是用空间换取时间。

各种语言对哈希表的实现各有不同,本文主要以Java和Go以及Redis的哈希表做主要陈述。

哈希表的实现

Java中的实现

熟悉的Java的同学都知道,HashMap是老朋友了,经常使用到且非常好用。相比起以前的HashTable,HashMap支持null作为键值插入,作为键只能有一个,作为值可以有多个。

JDK1.8 之前 HashMap 由 数组+链表 组成的,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的(“拉链法”解决冲突)。

image.png

JDK1.8 以后的 HashMap 在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间。

image.png

HashMap 默认的初始化大小为 16。之后每次扩充,容量变为原来的 2 倍。并且, HashMap 总是使用 2 的幂作为哈希表的大小。给定的默认容量为 16,负载因子为 0.75。Map 在使用过程中不断的往里面存放数据,当数量达到了 16 * 0.75 = 12 就需要将当前 16 的容量进行扩容,而扩容这个过程涉及到 rehash、复制数据等操作,所以非常消耗性能。

并发安全的Map

我们知道HashMap在并发下是不安全的,在高并发的情况下:

Rehash过程将数据迁移到新桶的过程中,数组链表中的链表形成循环链表,在后面的get操作时会无限循环

image.png

详细细节可以参考 blog.csdn.net/bjwfm2011/a…

Java 7 中 ConcurrentHashMap 的存储结构如上图,ConcurrnetHashMap 由很多个 Segment 组合,而每一个 Segment 是一个类似于 HashMap 的结构,所以每一个 HashMap 的内部可以进行扩容。但是 Segment 的个数一旦初始化就不能改变,默认 Segment 的个数是 16 个,你也可以认为 ConcurrentHashMap 默认支持最多 16 个线程并发。

image.png

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紧凑。

image.png

  • 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桶地址连续。

image.png

并发安全的Map

参考colobu.com/2017/07/11/…

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

参考colobu.com/2017/07/11/…

其优点有:

  1. 空间换时间。 通过冗余的两个数据结构(readdirty),实现加锁对性能的影响。
  2. 使用只读数据(read),避免读写冲突。
  3. 动态调整,miss次数多了之后,将dirty数据提升为read。
  4. double-checking。
  5. 延迟删除。 删除一个键值只是打标记,只有在提升dirty的时候才清理删除的数据。
  6. 优先从read读取、更新、删除,因为对read的读取不需要锁。

它使用了冗余的数据结构readdirtydirty中会包含read中为删除的entries,新增加的entries会加入到dirty中。

image.png

  • mu (Mutex): 当涉及到dirty数据的操作的时候,需要使用这个
  • read (atomic.Value): 一个只读的数据结构,因为只读,所以不会有读写冲突。
  • dirty (map[interface{}]*entry): 需要加锁,因为对它的操作可能会有读写竞争
  • misses (int): 如果read中不包含这个entry, 会尝试从dirty中读取,这个时候会将misses + 1,当misses累积到 dirty 的长度的时候, 就会将dirty提升为read,避免从dirty中miss太多次。因为操作dirty需要加锁。

虽然readdirty有冗余数据,但这些数据是通过指针指向同一个数据,所以尽管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…

image.png

  • table: 是一个数组, 数组中的每个元素都是一个指向 dictEntry 结构的指针, 每个 dictEntry 结构保存着一个键值对。
  • size: 记录了哈希表的大小, 也即是 table 数组的大小
  • used: 记录了哈希表目前已有节点(键值对)的数量。
  • sizemask: 等于 size - 1 , 这个属性和哈希值一起决定一个键应该被放到 table 数组的哪个索引上面。

image.png

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 操作的执行而最终变成空表。