HashMAP底层原理和扰动hash的例子

HashMap 是 Java 集合框架中最核心的数据结构之一。要深入理解它,我们需要从 JDK 1.7JDK 1.8 的演变讲起,重点剖析 数据结构、哈希计算、扩容机制、线程安全 等核心细节。

以下是 HashMap 底层原理的深度拆解:


一、核心数据结构演变

1. JDK 1.7:数组 + 链表
  • 结构 :底层是一个 Entry[] 数组,每个数组元素指向一个链表的头节点。
  • 插入方式头插法。新节点直接插在链表头部。
  • 致命缺陷 :在多线程并发扩容(Rehash)时,头插法可能导致链表形成环形结构,引发死循环(CPU 100%)。
2. JDK 1.8:数组 + 链表 + 红黑树
  • 结构 :底层是 Node[] 数组。
    • 当链表长度较短时,使用链表
    • 当链表长度超过阈值(默认 8) 数组长度超过阈值(默认 64)时,链表会转换为红黑树
  • 插入方式尾插法 。新节点插在链表尾部。
    • 优势:解决了 JDK 1.7 多线程扩容时的死循环问题(虽然 1.8 的 HashMap 依然不是线程安全的,但至少不会死循环,只会数据覆盖)。
  • 为什么引入红黑树?
    • 链表查询复杂度是 O(n)。如果发生大量哈希冲突,链表过长会导致性能急剧下降。
    • 红黑树查询复杂度是 O(\\log n)。当冲突严重时,性能退化更平缓。
    • 注意 :如果数组长度 < 64,即使链表长度 > 8,也不会转红黑树,而是优先进行扩容,因为扩容通常能解决冲突。

二、核心参数与常量

复制代码
// 默认初始容量 (必须是 2 的幂)
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 16

// 最大容量 (2^30)
static final int MAXIMUM_CAPACITY = 1 << 30;

// 默认负载因子 (决定何时扩容)
static final float DEFAULT_LOAD_FACTOR = 0.75f;

// 树化阈值 (链表转红黑树)
static final int TREEIFY_THRESHOLD = 8;

// 退化阈值 (红黑树转回链表)
static final int UNTREEIFY_THRESHOLD = 6;

// 最小树化容量 (数组长度达到此值才允许树化)
static final int MIN_TREEIFY_CAPACITY = 64;

三、关键流程深度解析

1. 哈希计算 (Hashing) ------ 如何确定位置?

HashMap 的核心公式是:index = (n - 1) & hash。 其中 n 是数组长度(总是 2 的幂),& 是按位与运算,等价于 hash % n,但效率更高。

步骤详解:

  1. 获取原始 Hash :调用 Key 的 hashCode() 方法。

  2. 扰动函数 (Perturbation)

    • JDK 1.8 的扰动函数非常简单:h ^ (h >>> 16)

    • 目的 :将高位特征混合到低位。因为数组长度通常较小(如 16),只用低位计算索引容易导致冲突。通过右移 16 位异或,让高 16 位也参与运算,减少哈希碰撞

      static final int hash(Object key) {
      int h;
      return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
      }

  3. 计算索引(n - 1) & hash

    • 例如:n=16 (二进制 10000),n-1=15 (二进制 01111)。
    • 任何数与 01111 做与运算,结果必然在 0~15 之间,完美对应数组下标。
2. Put 操作流程 (核心逻辑)

当你调用 map.put(key, value) 时:

  1. 检查数组 :如果数组为空(第一次插入),触发 resize() 初始化数组(默认 16)。
  2. 计算位置 :计算 hashindex
  3. 判断冲突
    • 情况 A:该位置为空。直接新建节点放入。
    • 情况 B:该位置已有节点(哈希冲突)
      • 如果 Key 相同(hash 相等且 equals 为 true):覆盖旧 Value。
      • 如果是红黑树节点:调用 putTreeVal 插入树中。
      • 如果是链表节点:遍历链表。
        • 找到相同 Key -> 覆盖。
        • 遍历到尾仍未找到 -> 尾插法加入新节点。
        • 检查树化 :插入后,如果链表长度 >= 8,调用 treeifyBin 尝试转红黑树。
  4. 检查扩容 :当前元素个数 size 是否超过 threshold (capacity * loadFactor)?
    • 如果是,触发 resize() 扩容。
  5. 返回:返回旧的 Value(如果没有则 null)。
3. Resize 扩容机制 (最耗时的操作)

size > threshold 时触发扩容:

  1. 新容量 :变为原来的 2 倍 (必须是 2 的幂)。
  2. 重新哈希 (Rehash)
    • 不需要重新计算 hashCode
    • 巧妙优化 :因为容量是 2 倍,新数组的掩码多了一位。元素在新数组中的位置只有两种可能:
      • 原位置(e.hash & oldCap) == 0 -> 保持在原索引 j
      • 原位置 + 旧容量(e.hash & oldCap) != 0 -> 移动到 j + oldCap
    • 这意味着,扩容时只需要判断一位二进制数,就能决定元素去留,无需全量重算 hash,极大提升了效率。
  3. 迁移数据:将旧数组元素按上述规则迁移到新数组。
  4. 更新阈值threshold 也变为原来的 2 倍。
4. Get 操作流程
  1. 计算 hashindex
  2. 定位到数组桶。
  3. 判断类型
    • 如果是普通节点:遍历链表,比对 hashkey (先比 hash 快,再比 equals)。
    • 如果是树节点:调用红黑树的 getTreeNode 查找 (O(\\log n))。
  4. 找到返回 Value,否则返回 null。

四、为什么选择 0.75 作为负载因子?

这是一个时间与空间的权衡(Trade-off):

  • 太大(如 1.0):数组利用率高,节省空间,但冲突概率增加,链表/红黑树变长,查询性能下降。
  • 太小(如 0.5):冲突少,查询快,但数组稀疏,浪费大量内存空间,且频繁触发扩容(扩容非常耗时)。
  • 0.75:统计学上的泊松分布显示,在 0.75 负载因子下,链表长度超过 8 的概率极低(约千万分之一),既能保证较高的空间利用率,又能维持较好的查询性能。

五、常见面试题深挖

1. 为什么容量必须是 2 的幂?
  • 为了让 (n - 1) & hash 等价于 hash % n
  • 只有当 n 是 2 的幂时,n-1 的二进制才全是 1(如 15 是 1111),这样按位与运算才能均匀地取模。如果不是 2 的幂,会导致某些位置永远无法被映射到,造成哈希分布不均。
2. HashMap 是线程安全的吗?
  • 不安全
  • 并发问题
    • JDK 1.7 :扩容时头插法导致死循环
    • JDK 1.8 :扩容时尾插法解决了死循环,但存在数据覆盖 问题。
      • 场景:线程 A 和 B 同时 put 到同一个空桶。A 计算出位置,准备插入;此时 B 也计算出位置,插入了节点。A 继续执行,覆盖了 B 的节点,导致 B 的数据丢失。
  • 解决方案
    • 使用 ConcurrentHashMap(推荐)。
    • 使用 Collections.synchronizedMap(new HashMap<>())(性能较差,全表锁)。
    • 外部加锁 (synchronizedReentrantLock)。
3. 如果 Key 是自定义对象,需要注意什么?
  • 必须重写 hashCode()equals() 方法。
  • 原则 :如果两个对象 equals() 相等,它们的 hashCode() 必须相等。
  • 如果不重写:默认使用对象内存地址计算 hash,导致逻辑上相同的两个对象被视为不同的 Key,造成内存泄漏或数据无法获取。

为什么必须遵守"相等对象哈希码必须相等"的原则?

哈希表的设计初衷是利用 hashCode 进行快速定位(时间复杂度接近 O(1))。

  • 契约规定 :Java 规范明确规定,如果 a.equals(b)true,那么 a.hashCode() 必须等于 b.hashCode()
  • 如果不遵守
    • 数据丢失 :如上例所示,存进去的数据取不出来。
    • 重复元素 :在 HashSet 中,你会能够添加多个逻辑上相同的对象,导致集合中出现重复数据,违背了 Set 的定义。
    • 内存泄漏:如果你不断放入逻辑相同但物理不同的对象,而由于 hash 不同导致无法覆盖旧值或无法识别重复,内存中会堆积大量无用对象。
4. 红黑树退化成链表的时机?
  • 当进行 remove 操作,或者扩容/缩容后,如果红黑树的节点数小于等于 UNTREEIFY_THRESHOLD (默认 6),红黑树会退化为链表,以节省空间和维护成本(红黑树节点占用空间比链表节点大)。

六、总结图示 (逻辑结构)

复制代码
HashMap
├── Node[] table (数组,长度 2^n)
│   ├── Index 0 -> null
│   ├── Index 1 -> Node(k1, v1) -> Node(k2, v2) ... (链表,长度 < 8)
│   ├── Index 2 -> TreeNode(k3, v3) (红黑树根节点)
│   │        ├── Left: TreeNode(...)
│   │        └── Right: TreeNode(...)
│   └── ...
├── size (当前元素个数)
├── threshold (扩容阈值 = capacity * 0.75)
└── loadFactor (0.75)

一句话总结 : HashMap 是一个通过哈希算法 将 Key 映射到数组下标,利用链表/红黑树 解决冲突,通过2 倍扩容尾插法平衡性能与安全的高效键值对存储结构。

扰动hash

我给你一组真实数字 ,完整走一遍:
原始 hashCode → 扰动 hash 运算 → 计算数组下标

你一眼就能看懂为什么必须做 hash 运算。


完整流程演示(JDK1.8 HashMap)

我们用 两个很像的 key 举例:
key1 = 10000
key2 = 10001

假设 HashMap 数组长度 n = 16(默认初始长度)

步骤1:拿到原始 hashCode

整数的 hashCode 就是它自己:

  • key1:hashCode = 10000
  • key2:hashCode = 10001

步骤2:执行 HashMap 的 hash 运算(扰动函数)

公式:

复制代码
hash = hashCode ^ (hashCode >>> 16)

计算 key1

10000 的二进制(只看低 16 位):
00100111 00010000

右移 16 位 → 高位全是 0

异或后:
hash1 = 10000

计算 key2

10001 的二进制:
00100111 00010001

右移 16 位 → 0

异或后:
hash2 = 10001


步骤3:计算数组下标(核心!)

公式:

复制代码
index = (n - 1) & hash

n=16 → n-1=15 → 二进制 1111

算 key1

10000 & 15

= 0

算 key2

10001 & 15

= 1

结果:
key1 → 下标 0
key2 → 下标 1


重点来了:如果不做 hash 运算会怎样?

假设 直接用原始 hashCode 算下标

key1:10000 & 15 = 0

key2:10001 & 15 = 1

看起来也没问题?

换一组数字,灾难立刻出现

用:

keyA = 65536

keyB = 65537

不做 hash 运算(直接 &15)

65536 → 二进制低 4 位:0000 → &15 = 0

65537 → 二进制低 4 位:0001 → &15 = 1

还是没问题?

再换一组:真正的坑

keyX = 16384
keyY = 32768
keyZ = 49152

它们的 低4位全部是 0000

直接 &15 结果都是:
0 → 全部碰撞在下标 0!

链表直接变长,性能暴跌。


做了 hash 运算后

公式:hash = h ^ h>>>16

这三个数的高位不同,异或后会把高位特征揉进低位:

  • 16384 → hash 后低4位:0100
  • 32768 → hash 后低4位:1000
  • 49152 → hash 后低4位:1100

下标变成:
4、8、12
完全分散!


一句话总结全过程

  1. 拿到 hashCode
  2. hash 运算:高16位 异或 低16位(把高位特征揉进低位)
  3. & (n-1) 取数组下标

不做 hash 运算:高位白给,只看低位,极易碰撞
做了 hash 运算:所有位都参与,散列超级均匀


总结

  • 扰动 hash 就是把高位特征揉进低位
  • (n-1) & hash 时,高位也能影响下标
  • 最终效果:哈希冲突大幅减少,HashMap 更快更稳

需要我再给你一组极端碰撞案例,让你更直观感受吗?

相关推荐
咸鱼2.01 小时前
【java入门到放弃】计算机网络
java·开发语言·计算机网络
Zzxy1 小时前
MyBatis-Plus入门
java·mybatis
木井巳1 小时前
【递归算法】找出所有子集的异或总和再求和
java·算法·leetcode·决策树·深度优先
悟空码字2 小时前
【保姆级】实现APP分享至微信,看完就能落地
java·后端·微信
froginwe112 小时前
SVN 创建版本库
开发语言
2501_924952692 小时前
C++中的枚举类高级用法
开发语言·c++·算法
常利兵2 小时前
Android 开发探秘:View.post()为何能获取View宽高
java·数据库·sql
闭关苦炼内功2 小时前
使用Java语言实现二分查找
java·开发语言
毕设源码-郭学长2 小时前
【开题答辩全过程】以 基于Spring Boot“活力青春”健身房管理系统的设计与实现为例,包含答辩的问题和答案
java·spring boot·后端