JAVA COLLECTIONS · JDK 8+

HashMap 核心技术原理

一句话结论:HashMap = 数组 + 哈希扰动 + 链表/红黑树 + 2 倍扩容 rehash,用 O(1) 的定位换 O(log n) 的兜底。

index = (n - 1) & ( h ^ (h >>> 16) )
01 全景架构 02 put 实验场 03 位运算放大镜 04 扩容机制 05 JDK7 vs JDK8 06 参数速查
01

全景架构:一次 put 发生了什么

put(k, v) 的完整链路:key → hashCode → 扰动函数 → 取模定位 → 落入桶(链表或红黑树)。

① 输入
key = "name"
→
② hashCode()
h = 3373707
→
③ 扰动 spread
h ^ (h>>>16)
→
④ 定位 (n-1)&h
index = 11
↓ 落入 table[11]
⑤ 桶数组 table[](Node<K,V>[])
0
1
2
3
4
5
6
7
8⛓
9
10
11🌳
12
13
14
15
■ 单节点   ■ 哈希冲突→链表(尾插)   ■ 链表长度≥8 且容量≥64 → 树化为红黑树
为什么这样设计?数组提供 O(1) 随机访问;哈希冲突不可避免(鸽笼原理),用链表兜底; 链表退化到 O(n) 时树化为红黑树压回 O(log n);负载过高时按 2 的幂扩容重哈希,维持均匀分布。 每个机制都是对上一层缺陷的补丁——这就是第一性原理的推演链。
02

交互实验场:亲手 put 一个 HashMap

这是一个严格按 JDK 8 规则模拟的 HashMap(初始容量 16、负载因子 0.75、树化阈值 8、最小树化容量 64)。输入 key 观察它落入哪个桶。

一键演示:
容量 16 size 0 阈值 12 树化桶 0
负载率 = size / capacity(≥0.75 触发扩容)
👆 点击任意桶查看内部结构(链表 / 红黑树)
03

位运算放大镜:扰动函数为什么存在

输入任意 key,实时观察 hashCode → h^(h>>>16) → (n-1)&h 的每一比特变化。注意:最终索引只由低位决定,扰动把高位信息混入低位,大幅降低冲突率。

容量 n = 16(掩码 0b1111,只看低 4 位)
① hashCode(32 位)
② h >>> 16(高位搬下来)
③ h ^ (h>>>16) 扰动结果
XOR ⊕
④ & (n-1) = & 0b1111
AND &
✅ 最终桶索引 index
■ 高 16 位  ■ 低 16 位   高亮背景 = 参与决定 index 的低 4 位
若不扰动:当 hashCode 只有高位不同(如自定义对象低位恒为 0),所有 key 会撞进同一个桶 → HashMap 退化为链表。 h ^ (h >>> 16) 用一次异或把高位特征"搅"进低位——开销极小,收益极大。
04

扩容机制:为什么容量永远是 2 的幂

扩容时容量 ×2,每个节点只需判断 hash 的一个新增比特位:原位置不动,或平移 oldCap——无需重新取模,这就是 2 的幂设计的精妙之处。

索引重定位公式(推导)

旧:index = hash & 0 1111 (n=16)
新:index = hash & 1 1111 (n=32)
差异仅在第 5 位:
  hash & 16 == 0 → 留在 原桶 j
  hash & 16 == 1 → 搬到 j + 16

2 的幂带来的三连收益

① (n-1) & hash 替代取模运算,位运算快一个数量级
② 扩容 rehash 只看 1 个比特,O(n) 搬家成本减半
③ 配合扰动函数,分布均匀性不依赖 key 的低位质量

🔬 点击体验:下面 8 个节点的桶,扩容时按第 5 比特自动分裂为两条链(JDK8 的 lo/hi 双链拆分,保持相对顺序)
05

JDK 7 vs JDK 8:一次教科书级的重构

JDK 8 的改动全部指向一个目标:在最坏情况下止血。

插入方式:头插 → 尾插
结构:链表 → 红黑树
rehash:重算 → 位拆分

JDK 7 · 头插法

新节点插到链表头部,无需遍历,插入 O(1)。但多线程扩容时头插会反转链表顺序,可能形成环形链表 → get() 死循环,CPU 100%。这是 HashMap 最著名的并发事故。

JDK 8 · 尾插法

遍历到尾部插入,保持原有顺序,扩容拆分后相对顺序不变,消除了成环的可能。代价:插入要遍历到链尾(树化机制恰好又兜住了遍历成本)。

⚠️ 注意:尾插只解决成环,HashMap 依然线程不安全,并发场景请用 ConcurrentHashMap。

06

参数速查表(面试高频)

每个数字背后都有工程权衡。

常量值为什么是这个值
DEFAULT_INITIAL_CAPACITY162 的幂;兼顾小 Map 的内存占用与扩容频率
MAXIMUM_CAPACITY1 << 30int 正数范围内最大的 2 的幂
DEFAULT_LOAD_FACTOR0.75空间与时间的折中:0.5 浪费内存,1.0 冲突激增;0.75 是泊松分布与经验验证的甜点
TREEIFY_THRESHOLD8理想哈希下链长 ≥8 的概率约 6×10⁻⁸(泊松分布),出现即视为哈希失效
UNTREEIFY_THRESHOLD6与 8 之间留出缓冲带,防止链长在边界抖动导致频繁树化/退化
MIN_TREEIFY_CAPACITY64容量小时冲突多应先扩容(更便宜),而非急着树化
null keyhash = 0固定放在 table[0],只允许一个 null key
🎯 一道终极面试题自检:为什么重写 equals() 必须重写 hashCode()? —— 因为定位桶靠 hashCode,桶内比价靠 equals。契约断裂则 equals 相等的对象落入不同桶,get 永远 miss。