LLadventure

  博客园  :: 首页  :: 新随笔  :: 联系 :: 订阅 订阅  :: 管理

哈希表高位分裂机制:HashMap 扩容与 Redis SCAN 原理剖析

学习日期:2026.08.06

学习核心:梳理 HashMap 扩容、Redis SCAN 遍历的通用底层原理,核心为2的幂次容量哈希表的高位分裂特性

前置思考问题

  • JDK8 HashMap 扩容无需重算哈希、性能优于 JDK7 的核心原因

  • Redis 渐进式扩容场景下,SCAN 无锁遍历不丢数据、但存在数据重复的底层逻辑

HashMap 和 Redis 哈希字典是开发中常用的两种哈希结构,二者的核心优化逻辑高度统一,均依托于2次幂容量哈希表的二进制高位分裂特性。本文作为自学笔记,系统性梳理该底层原理,以及其在 Java 和 Redis 源码中的具体落地实现。


一、基础原理:高效哈希表容量为何固定为2的幂?

Java HashMap、Redis 底层字典的数组容量,均固定采用2的n次方(1、2、4、8、16...)。该设计并非规范约束,而是基于性能和数据分布的最优技术选型,主要服务位运算寻址和扩容数据分裂两大核心能力。

1.1 位运算寻址:替代低效取模运算

哈希表存储数据的核心步骤,是通过 key 的哈希值计算对应的数组槽位索引,常规实现与优化实现存在明显性能差距:

  • 通用实现:通过取模运算计算索引,公式为 索引 = 哈希值 % 数组容量。取模运算CPU执行开销较高,寻址效率偏低。

  • 2次幂容量优化实现:通过位运算计算索引,公式为 索引 = 哈希值 & (容量 - 1)。位运算为CPU原生高效运算,可大幅提升寻址速度。

原理说明:当容量为2的幂时,容量减1后的二进制全部为1。例如容量4(二进制100),容量-1为3(二进制011)。哈希值与该数值做位与运算,结果会精准落在 0~容量-1 区间内,可完全替代取模运算。

若容量非2的幂,哈希值 & (容量 - 1) 会出现槽位覆盖、空置问题,导致严重哈希冲突和空间浪费,这也是哈希表容量必须为2的幂的核心原因。

1.2 扩容核心机制:槽位高位分裂

2次幂容量的哈希表,扩容规则固定为容量翻倍。扩容后数组掩码(容量-1)仅新增一个高位1,旧槽位的数据仅存在两种迁移情况:

  • 哈希值新增高位为0:数据索引不变,保留在原槽位

  • 哈希值新增高位为1:数据索引 = 原索引 + 旧容量,迁移至新槽位

简言之,哈希表每次扩容,原有单个槽位的数据会精准分裂为两个槽位数据。该高位分裂特性,是 HashMap 高效扩容、Redis SCAN 安全遍历的共同底层支撑。


二、原理落地:JDK8 HashMap 扩容优化

JDK7 及之前的 HashMap 扩容逻辑繁琐,需要遍历所有元素、重新计算哈希值和索引位置,冗余计算量大。JDK8 基于高位分裂特性,对扩容逻辑做了针对性优化,大幅提升扩容效率。

2.1 核心优化:无需重算哈希,位运算判定数据归属

扩容过程中无需重新计算 key 的哈希值,仅通过一行位运算即可判定数据迁移位置:(e.hash & oldCap) == 0

oldCap 为哈希表旧容量,二进制仅有一个1。该运算可精准判断哈希值的新增高位:

  • 结果为0:高位为0,数据归入低位链表,保留原索引位置

  • 结果非0:高位为1,数据归入高位链表,迁移至「原索引 + 旧容量」位置

2.2 性能本质:降低常数开销,不改变时间复杂度

HashMap 扩容的时间复杂度仍为 O(N),所有数据仍需完成迁移。核心优化点并非降低时间复杂度,而是彻底去除了重算哈希、重算索引的冗余逻辑,仅通过极简位运算完成数据分类,大幅降低了扩容的常数开销,优化了硬件执行效率。

2.3 并发优化:尾插法规避环形链表问题

JDK8 同步将原有头插法改为尾插法,解决了并发扩容的经典问题:

  • JDK7 头插法:并发扩容时会触发链表反转,易形成环形链表,导致CPU空转死循环

  • JDK8 尾插法:保留链表原有节点顺序,从根源杜绝并发扩容环形链表问题

2.4 核心源码解析

JDK8 resize 方法通过高低位双链表拆分,落地高位分裂优化,核心源码如下:

// 定义低位、高位链表头尾指针
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;

do {
    next = e.next;
    // 位运算判断数据归属高低位链表
    if ((e.hash & oldCap) == 0) {
        // 低位链表:保留原索引
        if (loTail == null) loHead = e;
        else loTail.next = e;
        loTail = e;
    } else {
        // 高位链表:迁移至原索引+旧容量
        if (hiTail == null) hiHead = e;
        else hiTail.next = e;
        hiTail = e;
    }
} while ((e = next) != null);

// 低位链表挂载原位置
if (loTail != null) {
    loTail.next = null;
    newTab[j] = loHead;
}
// 高位链表挂载新位置
if (hiTail != null) {
    hiTail.next = null;
    newTab[j + oldCap] = hiHead;
}

源码核心逻辑:依托高位分裂特性拆分链表,结合尾插法保证节点顺序,兼顾扩容性能与并发安全性。


三、原理落地:Redis SCAN 遍历机制

Redis 采用单线程模型,无法像 HashMap 一样一次性阻塞完成哈希表扩容,因此设计了渐进式 Rehash 机制。扩容期间新旧两张哈希表并存,SCAN 遍历机制正是基于高位分裂特性,解决了动态扩容下的遍历数据丢失问题。

3.1 渐进式 Rehash 的遍历痛点

Redis 渐进式扩容会分步迁移旧表数据至新表,全程不阻塞服务。普通顺序遍历(0、1、2、3...)存在明显缺陷:遍历指针走过旧槽位后,该槽位分裂出的新高位槽位会被永久跳过,导致数据遗漏;若回溯重扫,又会出现数据重复遍历问题。

3.2 SCAN 核心方案:高位翻转游标遍历

SCAN 放弃传统递增遍历方式,基于二进制高位分裂特性,采用高位翻转游标算法推进遍历,从机制上避免数据遗漏。以容量4(2位二进制)为例,SCAN 标准遍历顺序为:0 → 2 → 1 → 3。

算法逻辑:对游标二进制位执行「反转-加1-再反转」操作,改变传统从右至左的进位逻辑,实现高位优先进位。该机制可保证旧槽位分裂出的新高位槽位,一定会出现在后续遍历序列中,不会被遗漏。

3.3 双表扫描机制

渐进式 Rehash 期间,Redis 会同时遍历旧哈希表 ht[0] 和新哈希表 ht[1],匹配当前游标对应的槽位。双表扫描结合高位翻转游标,是 SCAN 遍历数据完整性的核心保障。

3.4 SCAN 核心特性

  • 数据不遗漏:遍历周期内存在的有效数据,均可被扫描到,无丢失风险

  • 数据可重复:扩容迁移过程中,同一key可能同时存在于新旧双表,遍历会出现重复数据,需业务层自行去重

3.5 常见误区纠正

  • 游标越界问题:Rehash 双表并存时,游标可能超出旧表容量,但会通过掩码自动映射合法槽位,不会出现数组越界

  • COUNT 参数:仅为扫描粒度提示,非精确返回条数,Redis 仅按该值参考扫描槽位数量,生产环境建议设置 100~500

3.6 核心源码解析

Redis dict.c 的 dictScan 函数,通过三行核心代码实现高位翻转游标逻辑:

// 反转游标二进制位
v = rev(v);
// 反转后数值加1,实现高位进位
v++;
// 再次反转,得到下一个遍历游标
v = rev(v);

该逻辑改变了二进制进位方向,适配哈希表高位分裂的扩容特性,是 SCAN 安全遍历的核心底层实现。


四、核心原理总结

HashMap 扩容与 Redis SCAN 遍历,底层均依托2次幂哈希表高位分裂特性,只是应用场景不同:

  • JDK8 HashMap:将该特性用于静态全量扩容,省去哈希重算逻辑,降低扩容性能开销,结合尾插法解决并发问题

  • Redis SCAN:将该特性用于动态渐进式扩容,通过高位翻转游标遍历,实现无锁、不丢数据的安全遍历

五、核心知识点速记

  • 哈希表采用2次幂容量,核心是通过位运算替代取模,提升寻址效率,同时支持精准槽位分裂

  • 扩容数据分裂规则:高位0保留原槽位,高位1迁移至「原索引+旧容量」

  • JDK8 HashMap 扩容优化为降低常数开销,时间复杂度仍为O(N),尾插法解决并发死循环

  • Redis SCAN 遍历不丢数据、允许重复,数据去重由业务层实现

  • 渐进式 Rehash 双表并存,是 SCAN 遍历机制的前置基础

  • SCAN 的 COUNT 参数仅为扫描参考粒度,无法精准控制返回数据量

posted on 2026-08-06 13:12  落落历险记  阅读(1)  评论(0)    收藏  举报