你以为的 put 和 get 很简单,其实背后暗藏玄机!本篇推文从源码视角深入剖析 HashMap,带你构建真正扎实的理解体系👇
一、底层结构:数组 + 链表(+ 红黑树)
-
HashMap 的核心是一个 Node[] table,也就是哈希桶数组;
-
每个数组槽位存储的是一个链表或红黑树的 头节点;
-
当链表长度超过阈值(默认 8),且数组容量 ≥ 64,就会转为红黑树,优化查找性能。
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
}
二、核心操作解析:put、get
1. put():插入元素流程
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
核心步骤:
-
计算哈希值:扰动函数 + 位运算避免哈希碰撞;
-
定位桶位置:index = (n - 1) & hash;
-
判断桶是否为空:为空则直接插入,否则:
遍历链表查找是否存在相同 key;
如果超过 8 个节点就树化;
-
插入元素并可能触发 resize。
2. get():查找元素流程
public V get(Object key) {
Node<K,V> e = getNode(hash(key), key);
return e == null ? null : e.value;
}
核心步骤:
-
根据 hash 值定位桶;
-
遍历链表或红黑树比对 key(equals());
-
返回对应的 value。
三、哈希扰动函数(JDK 1.8+)
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
-
利用高位参与运算,提高分布均匀性;
-
减少 hash 冲突,提升性能。
四、扩容机制:容量翻倍 + 再哈希迁移
扩容条件:
if (size >= threshold) resize();
关键点:
-
容量为 2 的幂,便于位运算取模;
-
扩容时,每个元素要重新计算 index;
-
链表不会倒置! 新旧桶位置与 hash 位模式相关;
-
每次扩容成本高,建议提前初始化容量。
五、红黑树机制(树化)
JDK 1.8+ 优化:
-
当某个桶中的链表长度 > 8 且数组容量 ≥ 64,会转为红黑树;
-
TreeNode 替代 Node,拥有左右子节点和颜色信息;
-
查找、插入复杂度从 O(n) 优化到 O(log n)。
转换入口方法:
if (binCount >= TREEIFY_THRESHOLD - 1)
treeifyBin(tab, hash);
六、线程安全问题
-
HashMap 是 非线程安全 的;
-
多线程同时 put 可能导致死循环(链表环)、数据丢失;
-
并发环境应使用:ConcurrentHashMap或加锁封装后的Collections.synchronizedMap
七、JDK 1.7 vs 1.8 差异
特性 | JDK 1.7 | JDK 1.8 |
---|---|---|
链表插入位置 | 头插法 | 尾插法(避免扩容死循环) |
树化支持 | 不支持 | 支持红黑树 |
resize 机制 | 双重 for 循环 + rehash | hash 位与原容量比较直接判断位置 |
并发死循环风险 | 高(链表倒置 + 多线程) | 降低(尾插 + 树化) |
八、源码解读核心方法
putVal()的核心逻辑片段:
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
// 遍历链表或树查找 key
// 如果 key 已存在则覆盖,否则新增节点
}
resize():
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
for (Node<K,V> e : oldTab) {
// 按位迁移 old -> new
}
九、HashMap 适用建议
-
不适合高并发写入场景;
-
大量读取 + 少量更新场景性能优秀;
-
支持 null key(唯一一个)和 null value;
-
初始容量建议估算设置,减少扩容成本。
十、红黑树在 HashMap 中的结构源码
JDK 1.8 中引入红黑树是为了优化 hash 冲突严重时的查找性能。
当链表长度超过阈值(默认 8),HashMap 会将该桶的链表转化为红黑树。核心实现类是 TreeNode<K, V>:
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
TreeNode<K,V> parent;
TreeNode<K,V> left;
TreeNode<K,V> right;
TreeNode<K,V> prev;
boolean red;
}
-
parent、left、right:构建红黑树节点关系;
-
prev:保留双向链表结构(用于还原);
-
red:节点颜色标记,用于维护红黑树平衡;
树化入口方法:
if (binCount >= TREEIFY_THRESHOLD - 1)
treeifyBin(tab, hash);
树化条件:
-
链表长度 ≥ 8;
-
数组长度 ≥ 64,否则优先扩容;
查找时,采用 TreeNode.find() 递归二叉查找逻辑:
final TreeNode<K,V> find(int h, Object k, Class<?> kc)
结构复杂,查找复杂度降为 O(log n)。
十一、ConcurrentHashMap 和 HashMap 的底层设计对比
关键点 | HashMap | ConcurrentHashMap (JDK 1.8+) |
---|---|---|
是否线程安全 | ❌ 否 | ✅ 是 |
实现结构 | 数组 + 链表 / 红黑树 | 数组 + 链表 / 红黑树 |
并发策略 | 无 | 分段锁 + CAS(synchronized + volatile) |
put 实现方式 | 直接修改链表 | synchronized + Unsafe CAS 更新 |
容量扩容 | resize 会触发整体重哈希 | 多线程协作扩容 |
性能 | 单线程场景表现优秀 | 高并发写入场景稳定 |
允许 key/value 为 null | ✅ 允许 null key 和 null value | ❌ 禁止 key 和 value 为 null |
结论:并发场景优先使用 ConcurrentHashMap,并可结合 LongAdder 做计数场景优化。
十二、自定义 key 的 hashCode 与 equals 方法最佳实践
自定义对象作为 HashMap 的 key 时,必须同时重写 hashCode() 和 equals() 方法,否则会导致 key 无法命中,出现“丢失”现象。
示例错误场景:
class User {
String name;
int age;
}
未重写 hashCode,以下操作将出现问题:
map.put(new User("Alice", 20), "A");
System.out.println(map.get(new User("Alice", 20))); // null
✅ 正确写法:
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
User user = (User) o;
return age == user.age && Objects.equals(name, user.name);
}
@Override
public int hashCode() {
return Objects.hash(name, age);
}
📌 注意事项:
-
建议使用 Objects.hash(...) 简洁表达;
-
保证两个相同对象具有 相同的 hashCode 值与 equals 为 true;
-
不要让 hashCode() 随时间变化,否则会导致 key 命中失败。
十三、多线程 put 演示死循环现象(JDK 1.7 特性)
在 JDK 1.7 中,HashMap 的 resize() 采用头插法重建链表,多线程并发扩容时可能发生环形链表,造成死循环。
⚠️ 问题复现(JDK 1.7 下):
Map<Integer, String> map = new HashMap<>();
for (int i = 0; i < 10000; i++) {
final int key = i;
new Thread(() -> map.put(key, "value" + key)).start();
}
问题症状:
-
CPU 100%;
-
永远卡在 get() / put() 中无法返回;
-
链表头插+rehash 导致环形链表。
🧪 原因:
-
resize() 中多线程操作 next 指针;
-
指针串联错误,形成闭环结构;
-
get() 时遍历链表陷入死循环。