深度剖析:HashMap 并发死循环与 ConcurrentHashMap 两代演进全解

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 流程图直观展示:

flowchart TD subgraph 初始状态 Old["旧数组链表: A → B → C"] end subgraph 线程A挂起 A1["线程A: e = A, next = B"] A2["线程A被挂起"] end subgraph 线程B完成迁移 B1["线程B完成扩容"] B2["新数组链表: C → B → A"] end subgraph 线程A恢复 C1["线程A恢复执行"] C2["头插法迁移 A 和 B"] C3["形成环形链表: A → B → A"] end Old --> A1 --> A2 A2 -.-> B1 --> B2 B2 -.-> C1 --> C2 --> C3

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 并发底层原理的深刻理解。

相关推荐
如意猴43 分钟前
【C++】001--C++入门(1)
开发语言·c++
秋名RG1 小时前
2026/5/27 生产事故复盘与整改方案
java
传奇开心果编程1 小时前
【Rust入门知识点学与练】第10课:Option 和 Result(错误处理入门)
开发语言·学习·rust
leonkay1 小时前
C# 锁机制——【2】深入原理
开发语言·后端·spring·c#·个人开发
血小板要健康2 小时前
链表 阶段算法总结
java·数据结构·笔记·算法·leetcode·链表
坐吃山猪2 小时前
【多线程】CompletableFuture使用
java·开发语言·多线程
CHPCWWHSU2 小时前
DeepSeek-Harness-Qt将浏览器端Agent装入桌面应用
开发语言·qt
m0_587383002 小时前
点餐预约核销系统的架构脉络
java·架构·系统架构·需求分析
前端 贾公子2 小时前
第10章:RAG(2)
开发语言·python