🚀 深入理解Java并发"王牌":ConcurrentHashMap
ConcurrentHashMap 是一个专为高并发设计的、线程安全的 Map 实现。在它出现之前,我们实现线程安全的 Map 主要依赖两种"上古神器":
Hashtable:非常古老。它简单粗暴地在 几乎所有 方法(get,put,remove)上都使用了synchronized关键字。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 数据时,流程是这样的:
- 计算 
key的hash值。 - 通过 
hash值定位到具体的Segment(例如,100万个key均分到16个Segment)。 - 锁住这一个 
Segment。 - 在 
Segment内部的HashEntry[]中进行put操作(这个过程和HashMap类似)。 - 释放 
Segment锁。 
为什么1.7要这样设计?
- 实现并发: 如果线程A在 
Segment 0中put,线程B在Segment 1中put,它们操作的是不同的锁,互不干扰,实现了真正的并发。 - 避免锁表: 最坏情况下(所有 
key都hash到同一个Segment),才会退化成Hashtable的性能。但正常情况下,锁的粒度被缩小了16倍(默认)。 
get 操作如何工作?
get 操作是 1.7 设计的高光时刻 :它几乎是无锁的!
Segment 内部的 HashEntry 的 value 和 next 指针被声明为 volatile。
volatile关键字: 保证了"可见性"(一个线程修改了值,其他线程立刻能看到)和"有序性"(禁止指令重排序)。
get 时,不需要加锁,直接去读 volatile 变量。这使得 CHM 的读操作效率极高。
1.7 的扩容与 Rehash
1.7 的扩容不是 对整个 ConcurrentHashMap 进行的,而是对单个 Segment 内部进行的。
- 当某个 
Segment内部的HashEntry[]数组满了(达到阈值),这个Segment会锁住自己。 - 它会创建一个2倍大小的新 
HashEntry[]数组。 - 将旧数组的数据 
rehash(重新计算哈希位置)到新数组中。 - 这个过程只锁住当前 
Segment,其他15个Segment仍然可以正常工作。 
1.7 的缺点
concurrencyLevel固化:Segment的数量(并发度)在初始化时就定死了(默认16),无法更改。- 哈希冲突: 如果哈希算法不好,导致大量 
key集中在少数几个Segment,并发性能会急剧下降。 - 内存开销: 每个 
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 来存数据。
- 它放弃了 ReentrantLock,转而使用了 CAS 和 synchronized。
 
锁粒度:桶(Bucket)的头节点
1.8 的锁粒度细化到了数组的每个槽位(Bucket) 。
put 操作(putVal 核心逻辑):
- 
计算
hash值,定位到Node[]数组的槽位i。 - 
情况一:
table[i]为null(该槽位为空)- 不加锁! 使用 
CAS(Compare-And-Swap)操作,尝试将newNode原子性地放到table[i]上。 - 如果 
CAS成功,put完成。如果失败(说明有其他线程抢先了),则进入情况二。 
 - 不加锁! 使用 
 - 
情况二:
table[i]不为null(发生哈希冲突)- 加锁! 
synchronized(table[i]) { ... } synchronized锁住的是table[i]这个头节点(Head Node)。- 锁住之后,遍历这个槽位后面的链表(或红黑树),找到合适位置插入新节点。
 - 插入完成后,释放锁。
 - (如果链表长度超过8,会触发 
treeifyBin将链表转为红黑树)。 
 - 加锁! 
 
为什么1.8要这样修改?
- 更细的粒度: 1.7 默认锁16个 
Segment。1.8 理论上可以锁table.length个节点。在默认table长度(16)时,粒度相似。但当table扩容到几千几万时,1.8 的锁粒度(几千个锁)远远细于 1.7(固定的16个锁)。 synchronized优化: 在 JDK 1.8 中,JVM 对synchronized做了巨大优化(偏向锁、轻量级锁、锁膨胀、自旋),其性能在低竞争下(锁住一个桶的竞争通常是低度的)已经不亚于甚至优于ReentrantLock。- 解决哈希冲突: 引入红黑树,解决了 1.7 中当一个 
Segment内部链表过长时,get操作也会变慢的问题(1.8get在红黑树上是 <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)机制
- 
触发: 当
put操作使得Map的总元素数超过阈值时,第一个put线程会触发扩容。 - 
创建新表: 创建一个2倍大小的
nextTable[]。 - 
多线程协作: 扩容的核心是
transfer方法。这个方法很聪明,它允许多个线程一起来帮忙迁移数据。- 系统会为每个 CPU 分配一个"迁移任务"(比如,线程A负责迁移 0-15 槽位,线程B负责 16-31 槽位...)。
 - 线程通过 
CAS去"认领"自己的任务区间。 
 
transfer 一个槽位(Bucket)时:
- 锁住旧桶: 假设线程A正在迁移 
table[i]。它会synchronized(table[i]),锁住这个桶的头节点。 - Rehash: 遍历 
table[i]的链表/红黑树,根据hash值计算每个节点在新表nextTable中的位置(要么在j,要么在j + oldCapacity)。 - 构建新链: 这个线程会构建两个新链表(
low链和high链)。 - 放入新表: 将 
low链放到nextTable[j],high链放到nextTable[j + oldCapacity]。 - 标记旧桶: [关键 ] 迁移完成后,将 
table[i]替换为一个特殊的**ForwardingNode**(转发节点)。 
扩容时插入(put)了怎么办?
这是最精彩的!假设扩容正在进行中...
Case 1:put 的 key 所在的桶 table[i] 还未被迁移
- 
put线程hash到table[i]。 - 
它尝试
synchronized(table[i])加锁。 - 
此时,如果扩容线程(
transfer线程)也 想迁移table[i],它也会尝试synchronized(table[i])。 - 
两者必有一个会等待。
 - 
结果: 无论谁先拿到锁,操作都是安全的。
put先:插入新节点。transfer后拿到锁,会连同新节点一起迁移。transfer先:迁移旧数据。put后拿到锁(此时拿到的其实是ForwardingNode,见 Case 2)。
 
Case 2:put 的 key 所在的桶 table[i] 已经被迁移了
put线程hash到table[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 和多线程协作解决了"扩容"时的并发(不锁表)。