🚀 深入理解Java并发“王牌”:ConcurrentHashMap

🚀 深入理解Java并发"王牌":ConcurrentHashMap

ConcurrentHashMap 是一个专为高并发设计的、线程安全的 Map 实现。在它出现之前,我们实现线程安全的 Map 主要依赖两种"上古神器":

  1. Hashtable :非常古老。它简单粗暴地在 几乎所有 方法(get, put, remove)上都使用了 synchronized 关键字。
  2. Collections.synchronizedMap(new HashMap<>()) :与 Hashtable 类似,它返回一个包装过的 HashMap,同样是锁住了整个 Map 对象。

这两种方式的致命缺点是: 锁粒度太大。无论你做什么操作,都会锁住整张表 。当一个线程在 put 时,其他所有线程(包括那些只想 get 的线程)都必须排队等待。这导致并发性能极低,在高并发场景下形同虚设。

ConcurrentHashMap 的诞生就是为了解决这个问题:在保证线程安全的前提下,尽可能地提高并发性能。

它是如何做到的?这就要从 1.7 和 1.8 两个"世代"的设计说起。


1. JDK 1.7:分段锁(Segment)的智慧

ConcurrentHashMap 在 1.7 版本的设计堪称"分而治之"的典范。它没有锁住整张表,而是将表"切"成了N份(默认16份)。

核心思想:Segment[]

  • ConcurrentHashMap 内部包含一个 Segment 数组。
  • 每个 Segment 都是一个独立的、可重入的锁(ReentrantLock)。
  • 每个 Segment 内部又包含一个 HashEntry 数组,这是真正存储数据的地方。

你可以把 1.7 的 CHM 想象成一个大楼,它有16个(默认值,concurrencyLevel)大门(Segment)。每个大门(Segment)后面是一个独立的房间(HashEntry[],类似一个小的 HashMap)。

锁粒度:Segment 级别

当你要 put 数据时,流程是这样的:

  1. 计算 keyhash 值。
  2. 通过 hash定位到具体的 Segment (例如,100万个 key 均分到16个 Segment)。
  3. 锁住这一个 Segment
  4. Segment 内部的 HashEntry[] 中进行 put 操作(这个过程和 HashMap 类似)。
  5. 释放 Segment 锁。

为什么1.7要这样设计?

  • 实现并发: 如果线程A在 Segment 0put,线程B在 Segment 1put,它们操作的是不同的锁,互不干扰,实现了真正的并发。
  • 避免锁表: 最坏情况下(所有 keyhash 到同一个 Segment),才会退化成 Hashtable 的性能。但正常情况下,锁的粒度被缩小了16倍(默认)。

get 操作如何工作?

get 操作是 1.7 设计的高光时刻 :它几乎是无锁的!

Segment 内部的 HashEntryvaluenext 指针被声明为 volatile

volatile 关键字: 保证了"可见性"(一个线程修改了值,其他线程立刻能看到)和"有序性"(禁止指令重排序)。

get 时,不需要加锁,直接去读 volatile 变量。这使得 CHM 的读操作效率极高。

1.7 的扩容与 Rehash

1.7 的扩容不是 对整个 ConcurrentHashMap 进行的,而是对单个 Segment 内部进行的。

  • 当某个 Segment 内部的 HashEntry[] 数组满了(达到阈值),这个 Segment 会锁住自己。
  • 它会创建一个2倍大小的新 HashEntry[] 数组。
  • 将旧数组的数据 rehash(重新计算哈希位置)到新数组中。
  • 这个过程只锁住当前 Segment ,其他15个 Segment 仍然可以正常工作。

1.7 的缺点

  1. concurrencyLevel 固化: Segment 的数量(并发度)在初始化时就定死了(默认16),无法更改。
  2. 哈希冲突: 如果哈希算法不好,导致大量 key 集中在少数几个 Segment,并发性能会急剧下降。
  3. 内存开销: 每个 Segment 都是一个 ReentrantLock,并且有自己的 HashEntry[],这在 Map 较小时内存开销偏大。

2. JDK 1.8:CAS + synchronized 与红黑树

JDK 1.8 对 CHM 进行了颠覆性 的重构。它抛弃了 Segment,回归到了 HashMap 1.8 类似的 Node[] 数组 + 链表/红黑树的结构。

核心思想:更细的锁粒度

1.8 不再使用 Segment。它直接使用一个(volatile 的)Node[] 数组 table 来存数据。

  1. 它放弃了 ReentrantLock,转而使用了 CAS 和 synchronized。

锁粒度:桶(Bucket)的头节点

1.8 的锁粒度细化到了数组的每个槽位(Bucket)

put 操作(putVal 核心逻辑):

  1. 计算 hash 值,定位到 Node[] 数组的槽位 i

  2. 情况一:table[i]null(该槽位为空)

    • 不加锁! 使用 CAS(Compare-And-Swap)操作,尝试将 newNode 原子性地放到 table[i] 上。
    • 如果 CAS 成功,put 完成。如果失败(说明有其他线程抢先了),则进入情况二。
  3. 情况二:table[i] 不为 null(发生哈希冲突)

    • 加锁! synchronized(table[i]) { ... }
    • synchronized 锁住的是 table[i] 这个头节点(Head Node)。
    • 锁住之后,遍历这个槽位后面的链表(或红黑树),找到合适位置插入新节点。
    • 插入完成后,释放锁。
    • (如果链表长度超过8,会触发 treeifyBin 将链表转为红黑树)。

为什么1.8要这样修改?

  1. 更细的粒度: 1.7 默认锁16个 Segment。1.8 理论上可以锁 table.length 个节点。在默认 table 长度(16)时,粒度相似。但当 table 扩容到几千几万时,1.8 的锁粒度(几千个锁)远远细于 1.7(固定的16个锁)。
  2. synchronized 优化: 在 JDK 1.8 中,JVM 对 synchronized 做了巨大优化(偏向锁、轻量级锁、锁膨胀、自旋),其性能在低竞争下(锁住一个桶的竞争通常是低度的)已经不亚于甚至优于 ReentrantLock
  3. 解决哈希冲突: 引入红黑树,解决了 1.7 中当一个 Segment 内部链表过长时,get 操作也会变慢的问题(1.8 get 在红黑树上是 <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( log ⁡ n ) O(\log n) </math>O(logn))。

3. 终极难题:1.8 的并发扩容(Rehash)

这是 1.8 中最复杂、最巧妙的部分,完美地回答了你"怎么扩容"、"怎么rehash"、"扩容时插入怎么办"的问题。

1.7 的扩容是 Segment 内部自己搞。1.8 抛弃了 Segment,扩容必须是全表扩容。

会锁表吗?

绝对不会! 1.8 实现了一种"多线程协作"的并发扩容。

扩容(transfer)机制

  1. 触发:put 操作使得 Map 的总元素数超过阈值时,第一个 put 线程会触发扩容。

  2. 创建新表: 创建一个2倍大小的 nextTable[]

  3. 多线程协作: 扩容的核心是 transfer 方法。这个方法很聪明,它允许多个线程一起来帮忙迁移数据。

    • 系统会为每个 CPU 分配一个"迁移任务"(比如,线程A负责迁移 0-15 槽位,线程B负责 16-31 槽位...)。
    • 线程通过 CAS 去"认领"自己的任务区间。

transfer 一个槽位(Bucket)时:

  1. 锁住旧桶: 假设线程A正在迁移 table[i]。它会 synchronized(table[i]),锁住这个桶的头节点。
  2. Rehash: 遍历 table[i] 的链表/红黑树,根据 hash 值计算每个节点在新表 nextTable 中的位置(要么在 j,要么在 j + oldCapacity)。
  3. 构建新链: 这个线程会构建两个新链表(low 链和 high 链)。
  4. 放入新表:low 链放到 nextTable[j]high 链放到 nextTable[j + oldCapacity]
  5. 标记旧桶: [关键 ] 迁移完成后,将 table[i] 替换为一个特殊的**ForwardingNode**(转发节点)。

扩容时插入(put)了怎么办?

这是最精彩的!假设扩容正在进行中...

Case 1:putkey 所在的桶 table[i] 还未被迁移

  • put 线程 hashtable[i]

  • 它尝试 synchronized(table[i]) 加锁。

  • 此时,如果扩容线程(transfer 线程) 想迁移 table[i],它也会尝试 synchronized(table[i])

  • 两者必有一个会等待。

  • 结果: 无论谁先拿到锁,操作都是安全的。

    • put 先:插入新节点。transfer 后拿到锁,会连同新节点一起迁移。
    • transfer 先:迁移旧数据。put 后拿到锁(此时拿到的其实是 ForwardingNode,见 Case 2)。

Case 2:putkey 所在的桶 table[i] 已经被迁移了

  • put 线程 hashtable[i]
  • 它发现 table[i] 是一个 ForwardingNode
  • 这个 ForwardingNode 内部保存了 nextTable 的引用。
  • put 线程立刻明白:"数据已经搬到新家了!"
  • 它不会阻塞,反而会去"帮忙"!
  • put 线程会调用 helpTransfer() 帮助扩容,然后在新表 nextTable 重试自己的 put 操作。

总结(1.8扩容):

  • 不锁表 ,只锁住正在迁移的那个桶
  • get 操作几乎不受影响(ForwardingNode 也能指引 get 去新表)。
  • put 操作如果遇到 ForwardingNode,会自动参与到扩容中,实现了多线程协同。

4. 详细代码赏析(JDK 1.8 伪代码)

putVal 核心逻辑

Java 复制代码
final V putVal(K key, V value, boolean onlyIfAbsent) {
    int hash = spread(key.hashCode());
    int binCount = 0;
    for (Node<K,V>[] tab = table; ; ) { // 无限循环,直到成功
        Node<K,V> f; int n, i, fh;
        if (tab == null || (n = tab.length) == 0)
            tab = initTable(); // 1. 初始化表

        // 2. [CAS] 尝试无锁插入
        else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
            if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null)))
                break; // CAS成功,直接退出
        }

        // 3. [帮助扩容]
        else if ((fh = f.hashCode()) == MOVED) // MOVED 就是 ForwardingNode
            tab = helpTransfer(); // 发现正在扩容,帮助扩容

        // 4. [synchronized] 锁住桶,插入数据
        else {
            V oldVal = null;
            synchronized (f) { // f 是 table[i] 的头节点
                if (tabAt(tab, i) == f) { // 再次检查,防止锁f后f被替换
                    if (fh >= 0) { // 链表
                        // ... 遍历链表 ...
                        // (找到) -> 覆盖 oldVal
                        // (未找到) -> 插入新节点
                    }
                    else if (f instanceof TreeBin) { // 红黑树
                        // ... 在红黑树中操作 ...
                    }
                }
            } // synchronized 结束

            if (binCount != 0) {
                if (binCount >= TREEIFY_THRESHOLD)
                    treeifyBin(tab, i); // 5. 链表转红黑树
                // ... (省略 break)
            }
        }
    }
    addCount(1L, binCount); // 6. 增加 size,并检查是否需要触发扩容
    return oldValue;
}

transfer 扩容核心逻辑

Java 复制代码
// 这是一个简化的逻辑,原码非常复杂
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
    // ... (初始化 nextTab, 计算迁移步长 stride) ...

    for (int i = 0; i < n; ) { // 遍历旧表 table
        Node<K,V> f = tabAt(tab, i);
        if (f == null) {
            // 这个桶是空的,CAS设置一个 ForwardingNode
            casTabAt(tab, i, null, fwd); 
        }
        else if (f.hash == MOVED) {
            // 这个桶已经被其他线程处理过了
            i++;
        }
        else {
            // [关键] 锁住这个桶
            synchronized (f) {
                if (tabAt(tab, i) == f) {
                    Node<K,V> ln, hn; // low 链, high 链
                    
                    // ... (遍历 f 后的链表/树) ...
                    // ... (根据 hash & n 将节点分到 ln 和 hn) ...

                    // [关键] 把两个新链表放到 nextTab 的新位置
                    setTabAt(nextTab, i, ln);
                    setTabAt(nextTab, i + n, hn);

                    // [关键] 在旧表放置 ForwardingNode
                    setTabAt(tab, i, fwd);
                }
            } // synchronized 结束
        }
    }
}

总结:1.7 vs 1.8 对比

特性 JDK 1.7 JDK 1.8
底层结构 Segment[] + HashEntry[] Node[] + 链表/红黑树
锁机制 ReentrantLock synchronized + CAS
锁粒度 Segment (默认16个) Bucket 的头节点 (数组槽位)
并发度 固定 (默认16),由 concurrencyLevel 决定 理论上是 table.length
get 实现 volatile 读 (几乎无锁) volatile 读 (几乎无锁)
扩容方式 Segment 内部 扩容 (锁住单个Segment) 全表并发扩容 (多线程协作)
哈希冲突 链表过长导致 <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( n ) O(n) </math>O(n) 链表过长转红黑树 ,优化为 <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( log ⁡ n ) O(\log n) </math>O(logn)

为什么 1.8 是革命性的?

它在保证线程安全的前提下,实现了极致的细粒度锁 。通过 CAS 解决了"无冲突"时的插入(无锁),通过 synchronized 解决了"有冲突"时的插入(锁住桶),通过 ForwardingNode 和多线程协作解决了"扩容"时的并发(不锁表)。

相关推荐
金銀銅鐵6 小时前
[Java] 浅析 Map.of(...) 方法和 Map.ofEntries(...) 方法
java·后端
间彧7 小时前
如何通过多阶段构建优化SpringBoot应用的Docker镜像大小?
后端
他在笑7 小时前
Mybatis-plus 源码执行全流程解析
后端
华仔啊7 小时前
提升 Java 开发效率的 5 个神级技巧,超过 90% 的人没用全!
java·后端
间彧7 小时前
Docker Compose如何编排包含数据库、缓存等多个服务的SpringBoot应用?
后端
码农刚子7 小时前
ASP.NET Core Blazor 核心功能一:Blazor依赖注入与状态管理指南
前端·后端
是你的小恐龙啊7 小时前
自动化信息交付:深度解析AI驱动的每日简报系统架构与实现
后端
小码编匠8 小时前
WPF 动态模拟CPU 使用率曲线图
后端·c#·.net
我是谁的程序员8 小时前
让调试成为团队优势,如何把Charles融入前端与测试的工作流
后端