Hash 全景:从 HashMap 到一致性哈希,一文吃透哈希核心

本文以 JDK 8 HashMap 为切入点,沿着"哈希取模"这条主线,逐步走向分布式系统中的一致性哈希。

主线只有一句:取模本身没有错,错的是让映射规则依赖于一个会变的数。

目录

  1. 引子:一行索引代码里的秘密
  2. Hash 基础:哈希函数、哈希表、桶与冲突
  3. HashMap:哈希取模的单机极致优化
  4. new HashMap<>(17) 到底会变成多少?
  5. 分布式取模:当基数变成节点数
  6. 一致性哈希:用"环"替代"除数"
  7. 虚拟节点:解决数据倾斜
  8. 一致性哈希的极简实现思路
  9. 哈希算法家族:非加密、加密与一致性哈希
  10. Hash 的典型应用
  11. 工程中的坑:碰撞攻击、热点与数据倾斜
  12. 收束:取模的破与立
  13. 附:初始容量 17 速查

1. 引子:一行索引代码里的秘密

JDK 8 的 HashMap 源码中,putVal 有一行关键代码:

java 复制代码
if ((p = tab[i = (n - 1) & hash]) == null)

数组下标 i 由 (n - 1) & hash 得到。

当 n 是 2 的幂时,它等价于:

java 复制代码
i = hash % n

但位运算比取模快得多。

这行代码背后藏着一个事实:

HashMap 本质上是在做哈希取模。

而这个设计会像涟漪一样扩散,最终引出一致性哈希这个分布式系统中的核心算法。


2. Hash 基础:哈希函数、哈希表、桶与冲突

2.1 哈希函数

哈希函数把任意长度的输入,映射成固定长度的输出:

text 复制代码
hash(key) -> 固定长度整数

好的哈希函数通常追求:

  • 确定性:同一个 key 每次哈希结果相同;
  • 均匀性:结果尽量均匀分布在值域;
  • 高效性:计算速度快;
  • 抗碰撞性:不同 key 尽量不产生相同哈希值。

2.2 哈希表

哈希表利用哈希函数把 key 映射到数组下标:

text 复制代码
key -> hash(key) -> index -> bucket

理想情况下,增删改查都是 O(1)。

2.3 桶是什么?

哈希表底层通常是一个数组。数组的每个下标位置,就叫一个"桶"(bucket)。

text 复制代码
数组 table:

索引:   0     1     2     3     4     5     6     7
       +-----+-----+-----+-----+-----+-----+-----+-----+
       |     |     |  ●  |     |     |     |     |     |
       +-----+-----+-----+-----+-----+-----+-----+-----+
                    ↑
                 第 2 号桶

哈希函数决定 key 去哪个桶:

java 复制代码
index = hash(key) % capacity;
// 或 (capacity - 1) & hash

capacity 是桶的数量,通常等于哈希表数组长度。

桶里放什么,取决于冲突处理方式:

冲突处理方式 桶里放什么
链地址法 链表头节点,或红黑树根节点
开放寻址法 直接放一个元素,冲突时探测下一个空桶
再哈希法 换哈希函数重新找桶

Java HashMap 用的是链地址法。一个桶可以放一个元素,也可以放一条链表或一棵红黑树。

2.4 哈希冲突

不同 key 可能算出相同下标,这叫哈希冲突。

常见解决方案:

方案 代表
链地址法 HashMap、Redis Dict
开放寻址法 ThreadLocalMap、Go map
再哈希法 冲突时换一个哈希函数
公共溢出区 冲突元素统一放溢出区

3. HashMap:哈希取模的单机极致优化

3.1 扰动函数:让低位更随机

HashMap 的 hash():

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

为什么要高 16 位异或低 16 位?

因为数组长度通常较小,(n - 1) 只保留 hash 的低几位。

如果不扰动,高位信息会被丢弃,大量 key 可能挤在少数桶里。

扰动让高位"下沉"参与索引计算,成本极低,效果显著。

3.2 容量为 2 的幂:取模变位运算

当 n = 2^k:

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

例如:

text 复制代码
n = 32
n - 1 = 31 = 0b0001 1111

hash & 31 只保留低 5 位

所以容量必须是 2 的幂,否则不能用 & 替代 %。

3.3 扩容:只多查一位

容量从 n 翻倍到 2n,索引计算从:

java 复制代码
hash & (n - 1)

变成:

java 复制代码
hash & (2n - 1)

本质上只是多考察 hash 的某一个二进制位。

  • 这一位为 0:元素留在原下标;
  • 这一位为 1:元素移到"原下标 + 旧容量"。

JDK 8 的 resize() 正是利用这一点,把链表拆成低位链和高位链,避免重新计算 hash。

3.4 树化

当链表长度达到 8,且数组长度达到 64,链表转为红黑树,查询从 O(n) 降到 O(log n)。

如果数组长度小于 64,优先扩容,而不是树化。

3.5 负载因子

HashMap 默认负载因子是 0.75。

  • 太低:浪费空间;
  • 太高:冲突多,查询慢。

阈值 threshold = capacity * loadFactor。

元素数量超过阈值,就扩容。


4. new HashMap<>(17) 到底会变成多少?

HashMap 要求数组长度必须是 2 的幂。

传入 17,会被 tableSizeFor 向上取到最近的 2 的幂:

text 复制代码
17 -> 32

所以:

项目 值
传入初始容量 17
实际数组长度 32
索引计算 (32 - 1) & hash
等价取模 hash % 32
负载因子 0.75
扩容阈值 24
多考察的 hash 位 低 5 位

如果你问的是"hash 值会设置多少",那要注意:

  • hash() 的输出只取决于 key 的 hashCode();
  • 初始容量影响的是索引计算时的模数基数;
  • 你传 17,它偏要给你 32,因为只有 2 的幂才能让 % 变成 &,才能让扩容只多查一位。

5. 分布式取模:当基数变成节点数

把 HashMap 的逻辑搬到分布式缓存:

java 复制代码
nodeIndex = hash(key) % N;

N 是节点数量。

当 N 固定时,它工作得很好:均匀、简单、无额外元数据。

但节点数一变,问题爆发。

假设 3 台缓存服务器,6 个 key:

key hash % 3 归属
key1 10 1 B
key2 21 0 A
key3 32 2 C
key4 43 1 B
key5 54 0 A
key6 65 2 C

现在加一台节点,节点数从 3 变成 4,取模基数变成 4:

key % 3 % 4 是否迁移
key1 1 2 迁移
key2 0 1 迁移
key3 2 0 迁移
key4 1 3 迁移
key5 0 2 迁移
key6 2 1 迁移

6 个 key 全部迁移。

这就是普通取模的致命问题:

节点数量一变,几乎所有 key 的归属都会改变。

迁移比例约为:

text 复制代码
N / (N + 1)

从 3 台扩到 4 台,迁移约 75%;

从 10 台扩到 11 台,迁移约 90.9%。

这意味着:加一台机器,几乎全部缓存失效,请求穿透到数据库,可能引发缓存雪崩。

问题根源在于:

基数一变,所有映射关系同时断裂。

HashMap 用"容量总是 2 的幂"把断裂限制在"只多查一位",但分布式节点数不可能永远是 2 的幂。


6. 一致性哈希:用"环"替代"除数"

6.1 核心思想

一致性哈希不再对节点数量取模,而是对固定的哈希空间取模。

把 0 ~ 2^32 - 1 首尾相接成一个环。

节点和数据都映射到环上,数据顺时针找到的第一个节点,就是归属节点。

规则是:

java 复制代码
node = 从 hash(key) 的位置顺时针找到的第一个节点;

这个规则里没有 N。

节点数量变了,只是环上多了或少了几个点,规则本身不变。

6.2 节点怎么上环?

节点用 IP 或 IP:端口 做哈希:

text 复制代码
hash("节点A") = 100
hash("节点B") = 300
hash("节点C") = 600

环上的顺序是:

text 复制代码
0 -> A(100) -> B(300) -> C(600) -> 回到 0

数据也用同样的哈希函数映射到环上。

举例:

key 的 hash 顺时针第一个节点 归属
50 节点A(100) A
150 节点B(300) B
400 节点C(600) C
700 绕回节点A(100) A

6.3 加节点时发生了什么?

现在加入节点 D,假设:

text 复制代码
hash("节点D") = 200

环上的顺序变成:

text 复制代码
0 -> A(100) -> D(200) -> B(300) -> C(600) -> 回到 0

原来 hash 在 100 到 300 之间的数据,顺时针第一个是 B。

现在 100 到 200 之间的数据,顺时针第一个变成了 D。

所以:

新节点 D 只接管它前一个节点到它之间的那一段数据。

其他数据完全不动。

如果节点数从 3 变成 4,新节点期望接管约:

text 复制代码
1 / (N + 1)

也就是约 1/4,即 25%。

对比普通取模:

方案 3 台扩到 4 台 10 台扩到 11 台
普通取模 约 75% 约 90.9%
一致性哈希 约 25% 约 9.1%

6.4 为什么它有效?

关键区别在于:映射规则是否依赖节点数量。

普通取模:

java 复制代码
hash(key) % N

规则里有 N,所以 N 一变,规则就变了。

一致性哈希:

scss 复制代码
从 hash(key) 顺时针找第一个节点

规则里没有节点数量。

节点数量只影响环上有哪些点,不影响"顺时针找第一个"这个规则。

所以它把"规则"和"节点数量"解耦了。

6.5 节点下线时呢?

节点下线时,它负责的数据会交给环上顺时针的下一个节点。

比如 B 下线,原来归 B 的数据会顺时针找到 C,归 C。

生产环境通常还要配合数据复制,避免某个节点下线后数据丢失或压力全压到下一个节点。


7. 虚拟节点:解决数据倾斜

如果只有 3 个物理节点,它们在环上可能分布很不均匀。

比如:

text 复制代码
A 在 100
B 在 120
C 在 600

那么从 120 到 600 这一大段数据,都会归 C。

C 压力远大于 A 和 B,这就是数据倾斜。

解决办法:虚拟节点。

不再让一个物理节点只在环上占一个点,而是给它生成多个虚拟身份:

text 复制代码
节点A -> A#1, A#2, A#3 ...
节点B -> B#1, B#2, B#3 ...
节点C -> C#1, C#2, C#3 ...

这些虚拟节点分别哈希到环上。

数据落到哪个虚拟节点,就路由到对应的物理节点。

虚拟节点把每个物理节点"打散"成很多小段,整体分布就均匀了。

经验值:每个物理节点配 100 到 200 个虚拟节点。

但不是越多越好,虚拟节点越多,环的元数据和路由表也越大。


8. 一致性哈希的极简实现思路

用 TreeMap 存环:

java 复制代码
TreeMap<Long, Node> ring = new TreeMap<>();

// 节点上环
ring.put(hash("nodeA"), nodeA);
ring.put(hash("nodeB"), nodeB);
ring.put(hash("nodeC"), nodeC);

// 查找归属
Long keyHash = hash(key);
Map.Entry<Long, Node> entry = ring.ceilingEntry(keyHash);
if (entry == null) {
    entry = ring.firstEntry(); // 绕回环起点
}
Node target = entry.getValue();

虚拟节点就是往 ring 里放更多:

java 复制代码
hash(nodeId + "#" + i)

核心查找逻辑就是:

  • ceilingEntry:找第一个大于等于 keyHash 的节点;
  • 找不到就取 firstEntry,形成环。

9. 哈希算法家族:非加密、加密与一致性哈希

9.1 非加密哈希

追求速度和均匀性:

  • MurmurHash
  • xxHash
  • CityHash
  • CRC32
  • Java hashCode()

适合:HashMap、分片、布隆过滤器、一致性哈希。

9.2 加密哈希

追求抗碰撞、单向性:

  • MD5
  • SHA-1
  • SHA-256
  • SHA-3

适合:数字签名、文件校验、密码存储、区块链。

但 MD5、SHA-1 已不推荐用于安全场景。

9.3 一致性哈希常用算法

早期常用 MD5,后来多用 MurmurHash、xxHash,因为更快且分布足够均匀。


10. Hash 的典型应用

10.1 布隆过滤器

用多个哈希函数把元素映射到位数组。

特点:

  • 判断"不存在"一定准确;
  • 判断"存在"可能有假阳性;
  • 不支持删除,计数布隆过滤器可删除。

应用:缓存穿透、URL 去重、垃圾邮件判断。

10.2 分库分表

java 复制代码
dbIndex = hash(userId) % dbCount;

但扩容时同样面临取模迁移问题。

所以很多系统改用一致性哈希或预分片。

10.3 哈希索引

数据库可用哈希索引做等值查询,但不支持范围查询。

10.4 去重与秒传

文件哈希作为唯一标识,实现秒传、去重。

10.5 负载均衡

一致性哈希常用于:

  • Redis Cluster
  • Memcached 客户端分片
  • Dubbo 负载均衡
  • Nginx upstream hash
  • CDN 节点调度

10.6 校验与签名

MD5、SHA 用于文件完整性校验、数字签名。


11. 工程中的坑:碰撞攻击、热点与数据倾斜

11.1 HashDoS

攻击者构造大量哈希碰撞的 key,让哈希表退化成链表,CPU 飙升。

Java 8 用红黑树缓解。

有些语言引入随机哈希种子。

11.2 负载因子

HashMap 默认 0.75:

  • 太低:浪费空间;
  • 太高:冲突多,查询慢。

11.3 热点 Key

即使哈希均匀,某些 key 访问量极高,也会打爆单节点。

需要本地缓存、热点探测、key 打散。

11.4 数据倾斜

一致性哈希中,虚拟节点不足会导致倾斜。

需要增加虚拟节点或使用带权重的哈希环。


12. 收束:取模的破与立

场景 做法 核心问题 结果
HashMap 单机 (n - 1) & hash,容量 2 的幂 取模成本、扩容重分布 局部优化到极致
分布式取模 hash % N 节点数一变,全体重映射 迁移约 N/(N+1)
一致性哈希 哈希环 + 顺时针查找 解耦规则与节点数 迁移约 1/(N+1)
虚拟节点 一个物理节点映射多个环上点 环上分布不均 用元数据换均匀性

HashMap 教给我们:

取模本身没有错,错的是让映射规则依赖于一个会变的数。

一致性哈希做的,就是把那个会变的数从"规则内部"挪到"规则外部"------环不变,规则不变,变的只是环上多了或少了几个点。

从 HashMap 的桶到分布式系统的哈希环,取模的破与立贯穿始终。


13. 附:初始容量 17 速查

java 复制代码
Map<String, String> map = new HashMap<>(17);
项目 值
传入初始容量 17
实际数组长度 32
索引计算 (32 - 1) & hash
等价取模 hash % 32
负载因子 0.75
扩容阈值 24
多考察的 hash 位 低 5 位

本文为原创技术整理,如果对你有帮助,麻烦点赞,收藏。

相关推荐
鱼弦44 分钟前
模型服务热加载实战:如何在不停服的情况下更新模型权重?
后端
AI深栈44 分钟前
第 17 章 · 编排 AI 流程:StateGraph 节点、条件边与意图路由
java·人工智能
晚安日记wanna44 分钟前
订单30分钟未支付自动取消:定时任务为什么被面试官嫌弃
redis·后端·面试
知守观44 分钟前
Java 项目 FastJSON 1.2.37 安全漏洞排查:autoType 差点让我成了安全新闻主角
后端
IT_陈寒44 分钟前
Redis误删数据后的血泪教训:我竟然这样找回来了
前端·人工智能·后端
码事漫谈1 小时前
AI圈最近爆火的"哑巴"Jev,到底是个啥?
后端
SL_staff1 小时前
城商行营销翻车复盘:JVS-Rules 如何用工程化机制保障规则变更的可溯性与稳定性
java·开源·全栈
行百里er1 小时前
Redis 核心数据结构(二)——List 与消息队列
redis·后端
知守观1 小时前
AI 代码审查实战:2022年Java老项目挑出20个坑,老炮只认15个
后端