1. 引言:并发场景下的数据结构之痛
在 Java 并发编程中,HashMap 是最常用的键值对集合,但它并非线程安全。在多线程环境下,如果多个线程同时对 HashMap 进行 put 操作,尤其是在触发扩容(resize)时,极有可能导致 CPU 飙升 100% 的死循环 问题。为了解决这一顽疾,Java 官方在 JDK 1.5 中引入了线程安全的 ConcurrentHashMap,并在 JDK 8 中对其进行了革命性的重构。本文将深入剖析 HashMap 并发死循环的底层原理,并完整梳理 ConcurrentHashMap 从 JDK 7 到 JDK 8 的两代演进之路。
2. HashMap 并发死循环的根源
2.1 死循环发生的场景
死循环问题几乎都发生在 JDK 7 及之前版本 的 HashMap 中。其核心触发条件是:多个线程同时触发扩容,并且链表中的节点在迁移过程中形成环形链表。一旦环形链表形成,当某个线程在 get 或 put 时遍历到该链表,就会陷入无限循环,导致 CPU 占用率飙升。
2.2 扩容机制:头插法的隐患
JDK 7 的 HashMap 在扩容时,会将旧数组中的每个链表节点重新散列到新数组。其迁移代码采用头插法(新节点插入链表头部),这在单线程下没有问题,但在多线程并发迁移时,两个线程可能同时操作同一个链表,导致节点之间的引用关系错乱,最终形成环形链表。
下面是一个典型的 JDK 7 扩容迁移代码示意(简化版):
java
// JDK 7 HashMap.transfer() 简化逻辑(头插法)
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while (null != e) {
Entry<K,V> next = e.next; // 线程A在这里暂停
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i]; // 头插法
newTable[i] = e;
e = next;
}
}
}
2.3 环形链表形成过程图解
假设有两个线程 A 和 B 同时对同一个 HashMap 扩容,原始链表为 A→B→C。当线程 A 执行完 Entry next = e.next 后(此时 e=A, next=B),线程 A 被挂起。线程 B 完整执行完扩容,将链表迁移到新数组,此时新数组中链表顺序变为 C→B→A。随后线程 A 恢复执行,它持有的 e=A, next=B,继续按头插法迁移,最终导致 A 和 B 互相指向,形成环形链表。
这个过程可以用 Mermaid 流程图直观展示:
2.4 JDK 8 的改进与残留风险
JDK 8 对 HashMap 进行了重大优化:将数组+链表改为数组+链表+红黑树 ,并且将扩容迁移方式从头插法改为尾插法 。尾插法保证了节点迁移后相对顺序不变,因此从根本上避免了环形链表的产生 。然而,JDK 8 的 HashMap 仍然不是线程安全的,多线程并发 put 仍可能导致数据丢失 或size 计数不准确等问题,只是不再出现死循环而已。
3. ConcurrentHashMap 第一代:JDK 7 的分段锁设计
3.1 设计思想:锁分段
JDK 7 的 ConcurrentHashMap 采用**锁分段(Segment)**技术。它将整个哈希表分成多个 Segment(默认 16 个),每个 Segment 内部维护一个类似 HashMap 的结构。不同 Segment 之间的读写操作互不干扰,可以并发执行,从而大幅提升并发性能。
3.2 核心数据结构
ConcurrentHashMap 由 Segment 数组 和 HashEntry 链表组成。Segment 继承自 ReentrantLock,因此每个 Segment 本身就是一个锁。写入操作只需要锁定对应的 Segment,而读取操作通过 volatile 关键字保证可见性,无需加锁。
java
// JDK 7 ConcurrentHashMap 核心结构
static final class Segment<K,V> extends ReentrantLock implements Serializable {
transient volatile HashEntry<K,V>[] table; // 每个Segment内部是一个HashEntry数组
// ...
}
static final class HashEntry<K,V> {
final int hash;
final K key;
volatile V value;
volatile HashEntry<K,V> next;
// ...
}
3.3 并发控制机制
JDK 7 的 ConcurrentHashMap 在 put 操作时,会先定位到对应的 Segment,然后尝试获取锁。如果获取失败,会进行自旋重试 ,重试次数达到阈值后改为阻塞等待。这种机制在低并发下性能优秀,但在高并发下,锁竞争仍然存在,且 Segment 数量固定,扩容时需要对整个 Segment 进行扩容,无法动态调整并发度。
4. ConcurrentHashMap 第二代:JDK 8 的 CAS + Synchronized
4.1 设计思想:放弃分段锁
JDK 8 的 ConcurrentHashMap 彻底放弃了 Segment 分段锁,转而采用 CAS(Compare And Swap) + Synchronized 的组合策略。它直接使用 Node 数组存储数据,并发控制粒度更细,从「锁一个 Segment」细化为「锁一个桶(数组下标)」,并发性能进一步提升。
4.2 核心数据结构
JDK 8 的 ConcurrentHashMap 结构与 JDK 8 的 HashMap 类似,采用数组 + 链表 + 红黑树。当链表长度超过阈值(默认 8)且数组长度大于等于 64 时,链表会转换为红黑树,以降低查询时间复杂度。
java
// JDK 8 ConcurrentHashMap 核心结构
transient volatile Node<K,V>[] table; // Node数组
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
volatile V val;
volatile Node<K,V> next;
// ...
}
// 红黑树节点
static final class TreeNode<K,V> extends Node<K,V> {
TreeNode<K,V> parent;
TreeNode<K,V> left;
TreeNode<K,V> right;
TreeNode<K,V> prev;
boolean red;
// ...
}
4.3 并发控制机制详解
JDK 8 的 put 操作流程如下:
- 第一步:计算 key 的 hash 值,定位到数组下标。
- 第二步 :如果该位置为空,则通过 CAS 操作直接插入新节点,无需加锁。
- 第三步 :如果该位置不为空,则使用 synchronized 锁住该桶的头节点,然后进行链表插入或红黑树插入。
- 第四步:如果链表长度达到阈值,则转换为红黑树。
这种设计使得无竞争时通过 CAS 快速完成 ,有竞争时只锁住单个桶,大大降低了锁竞争的范围和概率。
4.4 扩容机制:多线程协助扩容
JDK 8 的 ConcurrentHashMap 引入了多线程协助扩容(Transfer) 机制。当某个线程触发扩容时,其他线程在 put 操作时如果发现正在扩容,会主动加入协助迁移数据。扩容过程将原数组分成多个区间,每个线程负责迁移一部分,通过 ForwardingNode 标记已迁移完成的桶,从而大幅提升扩容效率。
5. 两代 ConcurrentHashMap 对比总结
| 对比维度 | JDK 7 ConcurrentHashMap | JDK 8 ConcurrentHashMap |
|---|---|---|
| 底层结构 | Segment 数组 + HashEntry 链表 | Node 数组 + 链表 + 红黑树 |
| 锁粒度 | 锁一个 Segment(较粗) | 锁一个桶(较细) |
| 锁实现 | ReentrantLock(继承) | CAS + synchronized |
| 并发度 | 固定为 Segment 数量(默认 16) | 动态,取决于数组长度 |
| 扩容方式 | 单线程扩容整个 Segment | 多线程协助扩容 |
| 查询性能 | 链表 O(n) | 链表 O(n),红黑树 O(log n) |
| 死循环风险 | 无(内部加锁) | 无(内部加锁) |
6. 实战建议与总结
6.1 如何选择
在实际开发中,如果确定集合只在单线程中使用,可以使用 HashMap 以获得最佳性能;如果存在多线程并发读写,则必须使用 ConcurrentHashMap 。需要注意的是,Hashtable 虽然线程安全,但其所有方法都使用全局锁,并发性能远不如 ConcurrentHashMap,应避免使用。
6.2 核心要点回顾
- JDK 7 HashMap 的死循环根源是头插法 + 并发扩容导致环形链表。
- JDK 8 HashMap 改用尾插法解决了死循环,但仍非线程安全。
- JDK 7 ConcurrentHashMap 通过分段锁提升并发度。
- JDK 8 ConcurrentHashMap 通过 CAS + synchronized 实现更细粒度的并发控制,并支持多线程协助扩容。
理解这两代数据结构的演进,不仅能帮助我们写出更健壮的并发代码,也能在面试中展现出对 Java 并发底层原理的深刻理解。