📑 Hash算法入门指南
- [一、 什么是哈希算法?](#一、 什么是哈希算法?)
- [二、 Hash 值是如何计算的?(通用思路)](#二、 Hash 值是如何计算的?(通用思路))
- [三、 图解:HashMap 的 Hash 映射全过程](#三、 图解:HashMap 的 Hash 映射全过程)
- [四、 核心揭秘:JDK 1.8 的扰动函数](#四、 核心揭秘:JDK 1.8 的扰动函数)
- [五、 哈希冲突与存储结构](#五、 哈希冲突与存储结构)
- [六、 总结](#六、 总结)
一、 什么是哈希算法?
哈希(散列)是一种单向映射算法 。它接收任意长度、任意类型的输入数据(字符串、对象、数字等),通过特定的数学函数运算,输出一个固定长度的二进制数值,这个结果被称为**哈希值(Hash Value)**或摘要。
用一个简单的公式表示:
原始数据 → Hash函数 Hash值 \text{原始数据} \xrightarrow{\text{Hash函数}} \text{Hash值} 原始数据Hash函数 Hash值
核心特性:
- 单向不可逆:你无法通过哈希值反推出原始数据(这对于密码存储至关重要)。
- 确定性:相同的输入,永远得到相同的输出。
- 雪崩效应:输入哪怕只改变一个比特,输出的哈希值也会发生巨大变化。
- 冲突不可避免:由于输入空间无限而输出空间有限,不同的输入可能会产生相同的哈希值(虽然优秀的算法会让这种情况极难发生)。
二、 Hash 值是如何计算的?
计算 Hash 值通常分为两步:原生哈希计算 和 区间映射(寻址)。
1. 原生哈希计算(通俗演示)
- 场景 A:纯数字
最简单的规则:Hash = 数字本身。- 输入:1024 → Hash 值:1024
- 场景 B:字符串(简易版)
规则:将每个字符的 ASCII 码相加。- 输入
abc:a(97) + b(98) + c(99) = 294 - 输入
acb:a(97) + c(99) + b(98) = 29·4
- 输入
注意 :这里出现了哈希冲突 。虽然 abc 和 acb 不同,但算出的 Hash 值相同。这就是为什么简单的累加算法不能用于生产环境,因为它的离散性太差。
2. 映射到数组下标(HashMap 核心寻址)
在 HashMap 中,底层是一个固定长度的数组。假设数组长度(容量)为 16,下标范围是 0~15。我们需要把算出来的巨大 Hash 值(比如 294)映射到这个范围内。
-
方法一:传统取模运算(%)(低性能)
公式:
下标 = Hash值 % 数组长度- 例子:
294 % 16 = 6。数据存入数组下标 6 的位置。 - 缺点:取模运算(%)涉及除法,在计算机底层运算中相对较慢。
- 例子:
-
方法二:HashMap 专属位运算(高性能)
前提 :HashMap 的容量(len)永远是 2 的整数次幂 (如 16, 32, 64...)。
公式 :
下标 = Hash值 & (数组长度 - 1)- 例子:长度 16,
16-1=15(二进制1111)。 294 & 15的结果同样只保留二进制的后 4 位,效果等同于取模,但位运算(&)的速度远高于取模(%)。
- 例子:长度 16,
三、 图解:HashMap 的 Hash 映射全过程
在 HashMap 中,从 Key 到数组下标的映射过程比简单的取模要复杂,它包含了一个关键的"扰动"步骤。
整体流程链路:
java
[原始 Key 对象]
↓
1. 调用 Object.hashCode() → 得到原生 int 哈希码
↓
2. 哈希扰动函数(高低位混合)→ 优化后 Hash 值
↓
3. 位运算寻址 hash & (容量-1) → 算出数组桶下标
↓
4. 存入对应下标位置(链表/红黑树)
图 1:哈希映射链路示意图
java
[Key: "Java"]
|
▼
原生hashCode = 123456
|
▼
扰动函数计算 → 优化Hash = 78901
|
▼
数组长度=16 → 16-1=15 (二进制 1111)
|
▼
78901 & 15 → 下标 = 5
|
▼
存入 table[5] 哈希桶
但是上述方法有个问题:当数组长度较小时(例如默认初始容量 16),length - 1 的二进制表示为 0000 0000 0000 0000 0000 0000 0000 1111(只有低 4 位是 1,注意java 中int是4个字节32位)。我们知道按位与运算规则如下:
- 和 0 相与:结果一定是 0
- 和 1 相与:保留原 bit 值
这意味着,在做 & 运算时,哈希值的高 28 位完全被丢弃了,只有低 4 位参与了索引的计算 !如果多个对象的 hashCode() 只是在高位不同,而低位相同,它们就会发生严重的哈希碰撞,全部挤在同一个链表或红黑树里 ,导致 HashMap 退化为 O(n) 的时间复杂度。
所以需要对原生的 hashCode(),进行"扰动"。
四、 核心揭秘:JDK 1.8 的扰动函数
HashMap 的源码中有一个极简但高效的函数:
java
static final int hash(Object key) {
int h;
// 原生哈希码 异或 (右移16位后的哈希码)
// 如果 key 为 null,直接返回 0(HashMap 允许 null 键,且默认放在数组下标 0 的位置)
// 否则,获取原始哈希码 h,并将其与自身无符号右移 16 位后的值进行异或运算
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
为什么要右移 16 位?为了把"高位"的信息也利用起来,JDK 设计了 (h = key.hashCode()) ^ (h >>> 16) 这一行代码。
- 16(无符号右移 16 位):因为 Java 的 int 类型正好是 32 位。右移 16 位刚好把高 16 位移到了低 16 位的位置,高 16 位补 0。
- ^(异或运算):将原始哈希值的低 16 位与高 16 位进行异或。
- 异或操作把高位的特征"融合"到了低位中。即使两个对象的高位不同、低位相同,经过异或后,它们的最终低位也会变得不同。
图 2:JDK 1.8 扰动原理图解(二进制演示)
java
原 hashCode: [高16位 ABCD] [低16位 EFGH]
右移16位: [0000 0000] [高16位 ABCD] (高位补0)
-------------------------------------------------
异或运算(^): [高16位 ABCD] [低16位 EFGH ^ ABCD]
通俗解释 :
这行代码 (h >>> 16) 的作用是将哈希码的高 16 位 移动到低 16 位,并与原来的低 16 位进行异或(^)混合。
- 目的:让高位的信息参与到寻址运算中来,弥补原生哈希低位重复多的问题。
- 效果:只做一次位运算,既保证了高性能,又极大地增加了数据的离散度,减少了哈希冲突。
- 最终新哈希值的低 16 位= 原高 16 位 ^ 原低 16 位。而下标计算 hash & (n-1) 只取用低位,相当于:原本被抛弃的高 16 位哈希特征,通过异或,叠加到了参与下标运算的低 16 位上。
再来举个直观的例子,假设 32 位 int 简化成 16 位演示(原理一致):
go
原h(16位): 1101 0110 0011 1001
高8位A:1101 0110,低8位B:0011 1001
h >>> 8: 0000 0000 1101 0110
异或 h: 1101 0110 ^ 0011 1001 = 1110 1111
低位不再只是原来的 B,而是 A 和 B 混合后的新值,高位差异成功传递到决定下标的低位。
五、 哈希冲突与存储结构
尽管有了扰动函数,冲突依然无法完全避免(因为无限的输入映射到有限的数组下标)。
HashMap 如何解决冲突?
JDK 1.8 采用了链表 + 红黑树的组合结构。
图 3:哈希冲突示意图
java
Key1: "abc" → Hash=294 → 下标=6
Key2: "acb" → Hash=294 → 下标=6
↓
同一数组下标 bucket[6]
↓
链表串联节点
[ bucket[6] ] → Node("abc") → Node("acb") → null
- 链表:当发生冲突时,新节点以链表形式挂在数组元素后面。
- 红黑树 :当链表长度超过 8 且数组容量大于 64 时,链表会转换为红黑树,将查询时间从 O(n) 降低到 O(log n)。
六、 总结
- 计算两步走 :HashMap 的 Hash 计算分为"原生 hashCode"和"扰动函数优化"两步。
- 高性能寻址 :利用容量为 2 的幂次方特性,使用
hash & (len-1)位运算替代取模运算,提升性能。 - 扰动是关键 :JDK 1.8 的
hash ^ (hash >>> 16)是经典设计,通过混合高低位来减少冲突。 - 冲突不可避免:使用"数组 + 链表 + 红黑树"的数据结构来兜底解决冲突问题。