当前位置: 首页 > news >正文

【ConcurrentHashMap】实现原理和HashMap、Redis哈希的区别

【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 能够实现高性能、单线程模型却又能处理大量数据的基石之一。

http://www.dtcms.com/a/353372.html

相关文章:

  • 【Linux网络】网络基础
  • 如何高效地学习:从“死记硬背”到“内化创新”
  • 第二章从事件驱动到信号
  • ESP32使用场景及大规模物联网IoT
  • 【高级机器学习】3. Convex Optimisation
  • 海康相机的 HB 模式功能详解
  • 深入解析 OpenGL 着色器:顶点着色器与片段着色器
  • 无人驾驶叉车的核心作用:技术赋能下如何重塑工业物流的运作逻辑
  • Chrome插件学习笔记(四)
  • 豆包分析linux top
  • 李飞飞谈 AI 世界模型:技术内涵与应用前景
  • 深度学习——卷积神经网络CNN(原理:基本结构流程、卷积层、池化层、全连接层等)
  • 编程算法实例-算法学习网站
  • [Mysql数据库] 知识点总结4
  • LeetCode热题 100——48. 旋转图像
  • CB1-3-面向对象
  • 琼脂糖凝胶核酸电泳条带异常问题及解决方案汇总
  • Day29 基于fork+exec的minishell实现与pthread多线程
  • 【Linux】基本指令学习3
  • IBMS集成管理系统与3D数字孪生智能服务系统的应用
  • Linux驱动 — 导出proc虚拟文件系统属性信息
  • LabVIEW 音频信号处理
  • 【ElasticSearch】原理分析
  • opencv+yolov8n图像模型训练和推断完整代码
  • django注册app时两种方式比较
  • PyTorch图像预处理完全指南:从基础操作到GPU加速实战
  • jQuery版EasyUI的ComboBox(下拉列表框)问题
  • 通义万相音频驱动视频模型Wan2.2-S2V重磅开源
  • 聊一聊 单体分布式 和 微服务分布式
  • Package.xml的字段说明