【ConcurrentHashMap】实现原理和HashMap、Redis哈希的区别
- 【一】核心思想
- 【1】HashMap
- (1)概括
- (2)🚀线程不安全的场景和原因
- 1-场景一:Put 操作导致的数据覆盖/丢失 (Lost Update)
- 2-场景二:扩容 (Resize) 导致死循环或数据丢失 (JDK 7 及之前版本的致命问题)
- 3-场景三:非原子性的 size 操作和可见性问题
- 4-核心结论
- 5-解决方案:
- 【2】ConcurrentHashMap (CHM)
- 【二】实现原理深度解析
- 【1】基础结构:Node 数组 + 链表 + 红黑树
- 【2】并发控制的三大核心机制
- (1)CAS (Compare-And-Swap) 操作 - 无锁乐观锁
- (2)Synchronized 锁 - 细粒度悲观锁
- (3)volatile 变量 - 内存可见性保证
- 【3】关键操作原理
- (1)put / putVal 操作:CAS和synchronized
- (2)get 操作:无锁
- (3)size() 操作:CounterCell分段计数
- (4)🔥扩容 (Transfer) 操作
- 1-概述
- 2-扩容的触发时机
- 3-扩容的核心思路
- 【4】扩容的详细过程
- (1)步骤 1:初始化与规划 (单线程完成)
- (2)步骤 2:多线程协助迁移 (并发完成)
- 1-场景一:其他线程也来执行 put或 remove
- 2-场景二:协助过程
- (3)步骤 3:收尾与切换 (单线程完成)
- (4)核心机制与设计哲学
- (5)总结
- 【三】ConcurrentHashMap 与 HashMap 的全面区别
- 【四】总结与使用建议
- 【五】补充扩展:redis的Hash如何实现扩容
- 【1】核心数据结构
- (1)dict结构体:代表整个字典。
- (2)dictht结构体(哈希表):
- (3)dictEntry结构体
- 【2】扩容的触发时机
- (1)负载因子触发 (主要条件)
- (2)无法完成命令触发 (强制条件):
- 【3】扩容过程:渐进式 Rehash (核心)
- (1)步骤 1:空间分配
- (2)步骤 2:开始 Rehash
- (3)步骤 3:逐步迁移 (最精妙的部分)
- (4)步骤 4:定时任务辅助
- (5)步骤 5:完成与切换
- 【4】在此期间的操作如何执行?
- (1)查找 (GET, HGET):
- (2)添加 (HSET, HMSET):
- (3)删除 (HDEL):
- 【5】总结与优势
【一】核心思想
【1】HashMap
(1)概括
(1)定位:高性能的单线程键值对存储结构。
(2)核心问题:在多线程环境下,进行 put操作可能触发 rehash,导致链表形成环,从而引起死循环和CPU 100%。即使不做结构性修改(只是读写),由于没有内存同步屏障,也可能导致线程间可见性问题,读取到过期的数据。
(2)线程安全方案:如果需要在线程中使用,必须通过外部同步(如 Collections.synchronizedMap())来包装它,但这会带来巨大的性能开销,因为锁的粒度是整个 map,导致所有操作串行化。
(2)🚀线程不安全的场景和原因
HashMap的线程不安全并非指它在多线程下会立即崩溃,而是指在多线程环境下对其进行操作会导致未定义的行为。这些行为包括数据丢失、数据不一致,甚至可能使程序卡死。其根本原因在于内部机制缺乏必要的同步保障,导致多个线程可以同时修改其内部结构。
以下是几个最经典、最致命的线程不安全场景:
1-场景一:Put 操作导致的数据覆盖/丢失 (Lost Update)
这是最常见且最容易理解的问题。
(1)发生原因:
1.HashMap的 put()方法最终会调用 putVal()。
2.在插入新元素时,有一个关键步骤:判断目标桶(bucket)是否为空。
3.如果桶为空,则通过
if ((p = tab[i = (n - 1) & hash]) == null)
判断后,直接执行 tab[i] = newNode(hash, key, value, null);将新节点放入桶中。
(2)并发执行过程:
1.线程 A 和 线程 B 同时向 HashMap 插入一个 key。
2.假设这两个 key 经过哈希计算后,落在了同一个空桶里。
3.线程 A 和线程 B 都执行了 if判断,发现 tab[i] == null为 true。
4.线程 A 先执行 tab[i] = newNode(…),成功将它的键值对放入。
5.紧接着,线程 B 也执行 tab[i] = newNode(…),覆盖了线程 A 刚刚放入的节点。
(3)最终结果:线程 A 存入的数据完全丢失了,仿佛从未存在过。整个 Map 中只存在线程 B 的数据。
根本原因:检查-然后-行动 (check-then-act)这个复合操作不是原子的,中间可能被其他线程打断。
2-场景二:扩容 (Resize) 导致死循环或数据丢失 (JDK 7 及之前版本的致命问题)
这是 HashMap线程不安全最著名的问题,在 JDK 7 及之前版本中会导致 CPU 100%。虽然 JDK 8 改进了扩容算法,避免了死循环,但仍然可能导致数据丢失。
JDK 7 的死循环问题 (经典头插法反转)
JDK 7 在扩容转移链表节点时,使用的是头插法,即新节点会插在链表的头部。这在多线程并发扩容时是灾难性的。
假设原链表在桶中的顺序是:A -> B -> null
扩容时,两个线程同时触发 resize()。
(1)线程一开始执行转移,刚执行完 A节点后被挂起。
此时在线程一的视角,新链表是 A -> null。
(2)线程二 获得了 CPU 时间片,完整地执行了整个链表的转移。由于是头插法,转移后的新链表顺序被反转:
B -> A -> null
(3)此时,线程一 恢复执行。它仍然持有旧的 A.next引用(指向 B),它继续将 A节点用头插法插入新数组的桶中。
1-它先将 A插入,此时新桶是 A -> null(因为线程一不知道线程二已经插入了 B->A)。
2-接着,它要处理 next = e.next,即 B。它将 B插入桶的头部,即 B -> A -> null。
3-但是! 在线程二转移后的世界里,A.next本来是指向 null的,但现在线程一操作后,B.next指向了 A,而 A.next又指向了 null,这看起来是正常的。
(4)最关键的一步:如果此时又有另一个元素 C,其哈希值也和 A、B一样,在线程二完成扩容后,链表是 B->A->null。此时如果线程一介入,并按照上述流程操作,极有可能在特定的时序下,形成 A.next -> B且 B.next -> A的环形链表。
(5)最终结果:当下一次对这个桶进行 get()或 put()操作时,需要遍历这个链表,就会陷入无限的死循环,导致 CPU 使用率飙升到 100%。
(6)JDK 8 的改进:将头插法改为了尾插法。即在扩容时,保持链表节点的原有顺序。这从根源上避免了环形链表的产生。但是,这并不代表 JDK 8 的 HashMap 就是线程安全的了! 它只是避免了最致命的死循环,但并发扩容仍然会导致数据丢失等其它问题。
JDK 8+ 的数据丢失问题
即使在 JDK 8+,多线程同时触发 resize(),虽然不会死循环,但可能会因为线程之间覆盖彼此转移的节点,或者因为 table数组的可见性问题,导致某个线程转移的数据被另一个线程覆盖,最终造成部分数据丢失。
3-场景三:非原子性的 size 操作和可见性问题
(1)size 不准确:HashMap的 size属性由一个普通的 int变量维护。多线程同时进行 put和 remove操作时,size的更新 (size++或 size–) 不是原子的,可能会丢失更新。导致你调用 size()方法得到的值是一个错误的值。
(2)可见性问题 (Dirty Read):由于 HashMap的内部状态(如 table数组引用、节点的 value)没有用 volatile修饰,也缺乏锁带来的内存同步效应。因此,一个线程的修改(例如成功 put了一个值)可能不会立即被其他线程看到。导致一个线程 get()可能拿到一个过期的、旧的值,或者甚至 null。
4-核心结论
HashMap的所有这些线程不安全行为,都源于其设计初衷是追求单线程下的极致性能,因此它刻意省略了所有昂贵的同步开销(如 synchronized、volatile)。这些同步保障需要由使用者来负责。
5-解决方案:
(1)替代方案:在多线程环境下,使用 ConcurrentHashMap。它通过 CAS、synchronized(锁单个桶)、volatile等机制,在保证线程安全的同时,提供了极高的并发性能。
(2)加锁方案:使用 Collections.synchronizedMap(new HashMap<>())来包装一个同步的 Map。但这会使用一个全局锁,性能很差,不推荐在高并发场景使用。
(3)只读方案:如果 Map 初始化后就不会再修改,那么多线程同时读取是安全的。
【2】ConcurrentHashMap (CHM)
(1)定位:高吞吐量的线程安全哈希表,专为多线程并发访问而设计。
(2)设计目标:在保证线程安全的前提下,最大限度地提高并发性能。
(3)核心思想:锁分段技术 (JDK 7) -> 锁粒度细化 (JDK 8)。它不像 Hashtable或同步的 HashMap那样用一把大锁锁住整个表,而是采用更细粒度的锁机制,使得多个读操作和一定条件下的写操作可以并发进行。
【二】实现原理深度解析
JDK 8 对 ConcurrentHashMap进行了彻底的重写,摒弃了之前的分段锁 (Segment) 概念,采用了更优化的实现方式。
【1】基础结构:Node 数组 + 链表 + 红黑树
与 HashMap类似,CHM 的基础结构也是一个 Node<K,V>[] table数组。每个 Node是一个链表节点或树节点(TreeNode)。
(1)当链表长度超过一定阈值(默认为 8)且数组长度达到一定规模(默认为 64)时,链表会转换为红黑树,以优化极端情况下的查询性能(从 O(n) 提升到 O(log n))。
(2)当树节点变得足够小时,会退化为链表。
【2】并发控制的三大核心机制
(1)CAS (Compare-And-Swap) 操作 - 无锁乐观锁
CAS 是一种乐观的原子操作,用于实现无锁(lock-free)的非阻塞算法。在 CHM 中,CAS 被广泛应用于非竞争性的场景,避免了昂贵的锁开销。
应用场景:
(1)初始化 table 数组。
(2)将新节点插入到空桶(bucket)中。如果发现目标桶是空的,则直接用 CAS 操作将新节点设置进去,而不是先获取锁。
(3)协助扩容时设置转移标记。
(2)Synchronized 锁 - 细粒度悲观锁
当 CAS 无法完成操作时(例如,要向非空桶的链表或树中插入节点),会退化成使用传统的 synchronized锁。
(1)锁的粒度:锁的不是整个 map,也不是某个段,而是单个桶(即数组的第一个节点)。这是 JDK 8 实现相比 JDK 7 分段锁的一大优势,粒度更细,并发度更高。
(2)工作流程:
1.计算 key 的哈希值,定位到具体的桶。
2.如果桶为空,使用 CAS 插入。
3.如果桶不为空,则 synchronized锁定这个桶的头节点。
4.在锁内进行链表或树的操作(插入、更新、删除)。
5.操作完成后释放锁。
由于锁的粒度非常小,只是众多桶中的一个,因此绝大多数情况下,线程可以并发地访问不同的桶,极大提升了吞吐量。
(3)volatile 变量 - 内存可见性保证
所有重要的属性(如 table数组引用、sizeCtl等)都使用 volatile修饰。
作用:确保了多线程之间的内存可见性。一个线程修改了这些变量后,其他线程能立即看到最新值,避免了脏读。这是实现无锁读取的基础。
【3】关键操作原理
(1)put / putVal 操作:CAS和synchronized
(1)计算 key 的 hash,定位到桶。
(2)如果桶为空,CAS 插入,成功则退出。
(3)如果桶不为空,检查是否正在扩容(MOVED节点),是则协助扩容。
(4)否则,synchronized锁住桶的头节点。
(5)遍历链表或树:
1-如果 key 已存在,根据规则覆盖或保留旧值。
2-如果不存在,插入新节点。
(6)如果是链表,检查是否需要树化。
(7)最后,判断是否需要扩容,并增加计数。
(2)get 操作:无锁
(1)完全无锁!这是 CHM 高性能读的关键。
(2)由于 table数组和 Node的 value, next字段都是 volatile的,get操作可以直接读取这些数据而无需加锁,总能拿到最新的写入值。
(3)遍历链表或树并返回匹配的值。
(3)size() 操作:CounterCell分段计数
(1)这是一个较难实现的操作,因为要在不停止所有线程的情况下统计一个不断变化的量。
(2)实现:CHM 没有像传统做法那样全局加锁计数,而是采用了一种称为 CounterCell 的分段计数方法(借鉴了 LongAdder的思想)。
(3)每个线程修改元素时,会尝试 CAS 更新一个基础计数器 baseCount。如果更新失败(表示有竞争),则会将计数累加到线程对应的 CounterCell中。
(4)size()方法最终返回 baseCount与所有 CounterCell中的值之和。这是一个最终一致性的近似值,在并发极高时可能不是完全精确的瞬时值,但性能极高。
(4)🔥扩容 (Transfer) 操作
基于JDK 8版本
这是 CHM 中最复杂的部分。当元素数量超过阈值时,数组会扩容为原来的 2 倍。
1-概述
(1)关键特性:多线程协助扩容。
(2)当一个线程触发扩容时,它会负责分配转移任务(将旧数组划分为多个“ stride ”块)。
(3)其他正在执行 put或 remove操作的线程如果检测到当前桶正处于转移状态(遇到一个特殊的 ForwardingNode节点),不会阻塞等待,而是会协助完成转移工作。转移完成后,再继续完成自己的操作。
(4)这种设计极大地利用了并发性能,加快了扩容过程。
2-扩容的触发时机
CHM 会在两种主要情况下触发扩容(resize,在代码中常被称为 transfer):
(1)容量超过阈值:在插入新元素后,如果当前元素总数(通过 addCount方法计算)超过了
容量 * 负载因子(如 16 * 0.75 = 12)
则会触发扩容。
(2)链表转红黑树时的容量检查:当链表长度达到 8,准备转换为红黑树前,会检查当前数组的容量。如果数组容量小于 64,会优先选择执行扩容(tryPresize),而不是转树,因为扩容可能减少哈希冲突。
3-扩容的核心思路
CHM 扩容的核心思路是:将一个旧的 table 数组中的键值对,迁移到一个新的、容量翻倍(2倍)的 newTable 数组中。
最大的挑战在于:如何让多个线程安全、高效地协同完成这个数据迁移任务,而不发生混乱、丢失数据或形成死循环。
【4】扩容的详细过程
扩容过程的详细步骤
(1)步骤 1:初始化与规划 (单线程完成)
当一个线程(例如线程 T1)执行 put操作并发现需要扩容时:
(1)分配任务块 (Stride):T1 并不会自己包揽所有工作,而是将旧的 table 数组逻辑上划分成多个小的、连续的任务块。每个任务块包含一定数量的桶(比如最少16个桶为一个步长stride)。
(2)创建新数组:T1 会创建一个新的、容量是旧数组两倍的 nextTable数组。
(3)设置控制变量 sizeCtl:这是一个非常重要的 volatile变量,用于控制整个扩容过程的状态。T1 会将其设置为一个负数(例如 -1),表示自己正在初始化扩容。成功后,会将其设置为 (新数组长度的) * 0.75,并让其最高位为负,表示扩容正在进行中,其低位数代表了参与扩容的线程数。
(4)放置“路标”:ForwardingNode:T1 会先为自己要处理的第一个桶的位置放置一个特殊的节点——ForwardingNode。这个节点是关键中的关键,它有两大作用:
1-标识作用:告诉所有其他线程:“这个桶里的数据已经被搬走了,现在正在扩容中,别动它”。
2-路由作用:如果在此期间有查询请求 (get) 到达,遇到 ForwardingNode,查询操作会被转发到新的 nextTable数组中去执行,从而保证在扩容过程中,读操作永远不会被阻塞,并且能拿到正确的结果。
至此,T1 完成了扩容的初始化工作。
(2)步骤 2:多线程协助迁移 (并发完成)
现在,其他线程(例如 T2, T3…)在干什么?
1-场景一:其他线程也来执行 put或 remove
当这些线程根据 key 的 hash 找到某个桶时,如果发现这个桶的头节点是 ForwardingNode,它们会立刻明白:“哦,当前正在扩容中”。
它们不会傻等着扩容结束,而是会主动调用 helpTransfer()方法参与到扩容工作中来。这是一个“人人为我,我为人人”的设计。
2-场景二:协助过程
(1)领取任务:协助的线程会通过检查一个共享的扩容进度标识(例如 transferIndex)来“领取”一个尚未被处理的任务块(stride)。
(2)处理自己的任务块:线程会锁住(synchronized)自己任务块内的每一个桶的头节点,然后开始迁移该桶中的链表或树到新数组。
1-迁移算法 (非常重要):CHM 采用的算法非常高效。由于新数组大小是旧数组的 2 倍(n -> 2n),所以每个桶中的元素在新数组中的位置要么是原来的位置 i,要么是 i + n。
2-例如,旧数组长度是 16,桶 i=5中的元素,在新数组(长度32)中,只可能出现在 5或 5+16=21这两个位置。
3-线程会遍历链表或树,根据哈希值的新比特位(第 log2(n)位)是 0 还是 1,将节点分成低位链和高位链。低位链放在新数组的 i位置,高位链放在新数组的 i + n位置。
4-这个过程保持了节点的相对顺序(JDK 8 是尾插法),因此绝对不会出现 HashMap JDK 7 那样的死循环问题。
(3)放置路标:一个桶迁移完成后,线程会在旧数组的这个桶位置放置一个 ForwardingNode,标记该桶已处理完毕。
(4)更新进度:当一个线程完成自己领取的任务块后,它会继续领取下一个未处理的任务块,直到所有桶都被迁移完毕。
(3)步骤 3:收尾与切换 (单线程完成)
当所有桶都被成功迁移后,最终会有一个线程(可能是最初的 T1,也可能是其他协助线程)来完成收尾工作:
(1)table 引用切换:将 ConcurrentHashMap内部的 table引用从旧的数组指向新的 nextTable数组。
(2)更新 sizeCtl:将 sizeCtl的值设置为 (新容量 * 负载因子),恢复为正数,表示扩容结束,下一次的扩容阈值也同时设好了。
(3)丢弃旧数组:旧的数组可以被垃圾回收了。
至此,整个扩容过程圆满结束。
(4)核心机制与设计哲学
(1)化整为零:将庞大的扩容任务分解成许多小任务块 (stride),允许并行处理。
(2)协同工作 (Help Transfer):任何访问到正在扩容的 Map 的线程,都有义务协助完成迁移。这极大地加快了扩容速度,避免了单个线程迁移缓慢成为瓶颈。
(3)无锁读 + 锁桶写:
1-读操作 (get):永远不会被阻塞。即使遇到 ForwardingNode,也会去新数组查找。这是高可用性的体现。
2-写操作 (put/remove):在迁移某个桶时,会对该桶加锁(synchronized),阻塞其他想要修改这个桶的写操作,但不会阻塞读操作,也不会阻塞其他线程处理其他桶。
(4)状态可见性:通过 volatile变量 sizeCtl和 table引用,确保所有线程能立刻感知到扩容状态的变化。
(5)ForwardingNode 的神奇作用:它是协调读操作、写操作和扩容操作的“交通警察”,是整个流程能无缝衔接的核心设计。
(5)总结
ConcurrentHashMap的扩容过程是一个极其精巧的分布式协作算法。它通过任务分治、协同搬运、状态标记和细粒度锁,完美地解决了并发环境下哈希表扩容这一难题。
与 HashMap的单线程、阻塞式扩容相比,CHM 的扩容:
(1)效率极高:多线程并行搬运数据。
(2)不影响服务:读操作完全不受影响,写操作只在被处理的桶上短暂阻塞。
(3)绝对安全:不会出现数据丢失或死循环。
这种设计是 ConcurrentHashMap能够成为高性能并发编程基石的重要原因之一。
【三】ConcurrentHashMap 与 HashMap 的全面区别
【四】总结与使用建议
总结与使用建议
(1)单线程程序:毫无疑问使用 HashMap,它的性能是最好的。
(2)多线程程序,需要读写 Map:
1-JDK 5+:使用 ConcurrentHashMap。它是替代旧的 Hashtable和 Collections.synchronizedMap(map)的最佳选择。
2-读多写少:ConcurrentHashMap的无锁读特性使其表现极佳。
3-写多:ConcurrentHashMap的细粒度锁仍然能提供很高的吞吐量。
4-需要强一致性的迭代:如果需要在迭代时获取绝对最新的、且迭代过程中不变的数据视图,ConcurrentHashMap的弱一致性迭代器可能不满足要求。此时可能需要考虑加外部锁,但这种情况很少见。
5-需要存储 null 值:如果业务必须使用 null,则不能使用 ConcurrentHashMap,只能使用 HashMap并自行处理线程安全。
核心思想:ConcurrentHashMap通过结合 CAS(无锁)、synchronized(细粒度锁)、volatile(内存可见性) 这三种技术,以及精巧的多线程协作扩容设计,在线程安全与高性能之间取得了完美的平衡,成为了 Java 并发编程中最重要的集合类之一。
【五】补充扩展:redis的Hash如何实现扩容
Redis 的 Hash 扩容机制可以概括为:渐进式 Rehash (Incremental Rehashing)。
【1】核心数据结构
要理解扩容,首先需要了解 Redis 字典 (dict) 的核心结构:
(1)dict结构体:代表整个字典。
(1)ht[2]:两个哈希表 (dictht)。通常情况下,只使用 ht[0],ht[1]在 rehash 时使用。
(2)rehashidx:Rehash 索引。当不在 rehash 时,值为 -1;当在 rehash 时,其值表示 ht[0]中正在进行 rehash 的桶的索引。
(3)iterators:当前正在运行的迭代器数量。
(2)dictht结构体(哈希表):
(1)table:一个指针数组,每个元素指向一个 dictEntry(键值对节点)链表。这就是桶数组。
(2)size:哈希表的大小,即桶数组的长度。总是 2^n。
(3)sizemask:掩码,用于计算索引值,总是 size - 1。
(4)used:哈希表中已有的键值对数量。
(3)dictEntry结构体
代表一个键值对,同时包含指向下一个 dictEntry的指针,用于解决哈希冲突(链地址法)。
/* 简化后的核心数据结构 */
typedef struct dict {dictht ht[2]; // 两个哈希表,交替使用long rehashidx; // Rehash 的进度,-1 表示未进行// ... 其他字段如类型特定函数等
} dict;typedef struct dictht {dictEntry **table; // 指向桶数组的指针unsigned long size;unsigned long sizemask;unsigned long used;
} dictht;typedef struct dictEntry {void *key;void *val;struct dictEntry *next; // 指向下一个Entry,形成链表
} dictEntry;
初始状态下,ht[0]是一个空表,ht[1]为 NULL,rehashidx = -1。
【2】扩容的触发时机
Redis 在每一次执行添加、查找、删除等命令前,都会检查是否需要 rehash。触发扩容有两个条件,只要满足任意一个即可:
(1)负载因子触发 (主要条件)
(1)条件:负载因子 = ht[0].used / ht[0].size > 1。
(2)例外:如果 Redis 正在执行 BGSAVE或 BGREWRITEAOF等持久化子进程操作,为了减少父进程的内存压力(避免 Copy-On-Write 机制导致过多内存重复),Redis 会变得更加保守。此时,只有当负载因子大于 5时才会触发扩容。这个阈值可以通过 redis.conf中的 activerehashing参数调整。
(2)无法完成命令触发 (强制条件):
当 ht[0].size已经非常大,但负载因子还未达到阈值时,如果服务器尝试执行一个命令,并且发现当前哈希表没有足够的连续空闲空间(可能因为有很多删除操作导致碎片化)来完成该操作,则会立即触发扩容。
【3】扩容过程:渐进式 Rehash (核心)
与一次性将所有键值对从旧表迁移到新表(会阻塞主线程)不同,Redis 采用了一种分而治之、平摊成本的方法。
(1)步骤 1:空间分配
为 ht[1]分配一个新的、更大的哈希表。新大小的计算规则是:大于 ht[0].used * 2的第一个 2^n。
•
例如,假设 ht[0].used = 5,那么 5 * 2 = 10。比 10 大的第一个 2^n 是 16。所以新 ht[1]的 size为 16。
(2)步骤 2:开始 Rehash
将 dict结构中的 rehashidx字段从 -1设置为 0。这标志着 rehash 正式开始了。
(3)步骤 3:逐步迁移 (最精妙的部分)
(1)在接下来的每次对字典的增、删、改、查操作中,Redis 除了执行指定的命令外,还会顺带做一件额外的事:
1-检查 rehashidx是否不等于 -1(即是否在 rehash 中)。
2-如果是,则它将从 ht[0].table[rehashidx]开始,将这个桶上的所有键值对重新哈希 (rehash) 到 ht[1]中。
3-迁移完后,将 ht[0].table[rehashidx]设置为 NULL。
4-将 rehashidx的值加 1,指向下一个需要迁移的桶。
这个过程完美地将庞大的迁移工作分摊到了无数个细小的操作中。每个操作只迁移一个桶,避免了单次操作时间过长导致阻塞 Redis 的单线程主循环。
(4)步骤 4:定时任务辅助
由于 Redis 可能很长一段时间没有收到请求,导致 rehash 进度停滞。为了解决这个问题,Redis 的时间事件处理器 (serverCron) 会定期(默认每 100 毫秒)检查并执行 rehash 操作。
它会在一个规定的时间片(比如 1 毫秒)内,连续迁移多个桶,直到时间片用完或所有桶都迁移完毕。
(5)步骤 5:完成与切换
(1)当 ht[0]的所有桶都被迁移完毕,即 rehashidx的值超过了 ht[0].size。
(2)释放 ht[0]的空间。
(3)将 ht[1]设置为新的 ht[0]。
(4)为 ht[1]分配一个 NULL空表,为下一次 rehash 做准备。
(5)将 rehashidx重新设置为 -1,标志着本次 rehash 完成。
【4】在此期间的操作如何执行?
在 rehash 进行期间,字典同时持有两个哈希表 ht[0]和 ht[1]。所有操作都需要在两个表中进行。
(1)查找 (GET, HGET):
(1)先到 ht[0]中查找。
(2)如果没找到,再去 ht[1]中查找。
(2)添加 (HSET, HMSET):
新添加的键值对会直接被插入到 ht[1]中。这保证了 ht[0]的键值对数量只会减少不会增加,使 rehash 过程最终一定能完成。
(3)删除 (HDEL):
会同时在 ht[0]和 ht[1]中查找并删除对应的键。
【5】总结与优势
与 Java HashMap 的对比:
(1)Java HashMap:在单线程下,一次性完成扩容,期间该次 put操作耗时较高。
(2)Redis Dict:在任何情况下都采用渐进式 rehash,将耗时平摊,极致追求服务的稳定性和响应速度,这是作为数据库中间件的核心要求。
这种精巧的设计是 Redis 能够实现高性能、单线程模型却又能处理大量数据的基石之一。