Java随笔-ConcurrentHashMap

ConcurrentHashMap 是 Java 并发包中线程安全哈希表的标杆实现,从 JDK 1.7 到 1.8 经历了架构级重构------从"分段锁"演进为"CAS + synchronized + 红黑树",大幅提升了并发性能和代码简洁度。下面从数据结构、核心操作、并发控制、扩容机制四个维度详细展开。

一、JDK 1.7 vs JDK 1.8 架构对比

维度 JDK 1.7 JDK 1.8
数据结构 Segment 数组 + HashEntry 数组 + 链表 Node 数组 + 链表 / 红黑树
锁机制 ReentrantLock 分段锁(Segment 级别) CAS + synchronized(桶头节点级别)
并发度 最大并发度 = Segment 个数(默认 16) 理论上无上限(桶级别细粒度锁)
扩容 单个 Segment 内部扩容,其他 Segment 不受影响 多线程协作渐进式扩容
读操作 需要两次 hash(定位 Segment + 定位桶) 一次 hash 定位,全程无锁
树化 无,纯链表 链表长度 ≥ 8 且数组长度 ≥ 64 时转红黑树

二、JDK 1.8 核心数据结构

2.1 Node 数组

java 复制代码
// 基础节点,存储 key-value
static class Node<K,V> implements Map.Entry<K,V> {
    final int hash;        // 哈希值
    final K key;           // key(final,保证可见性)
    volatile V val;        // value(volatile,保证读可见)
    volatile Node<K,V> next; // next 指针(volatile)
    // ...
}

关键设计:

  • val 和 next 用 volatile 修饰,保证多线程读可见性
  • key 和 hash 用 final 修饰,创建后不可变,天然线程安全
  • 底层 Node\[\] table 也用 volatile 修饰,保证扩容后引用变更的可见性

2.2 特殊节点类型

节点类型 说明
ForwardingNode hash = -1(MOVED),扩容时标记已迁移的桶,读操作转发到 nextTable
TreeBin 红黑树的包装节点,持有根节点引用,提供树化/链表化操作
TreeNode 红黑树节点,继承自 Node,增加 parent/left/right/red 属性
ReservationNode 占位节点,用于 computeIfAbsent 等方法的占位

三、核心操作源码解析

3.1 put 流程(JDK 1.8)

java 复制代码
final V putVal(K key, V value, boolean onlyIfAbsent) {
    if (key == null || value == null) throw new NullPointerException();
    int hash = spread(key.hashCode());  // 扰动函数,减少哈希冲突
    int binCount = 0;
    
    for (Node<K,V>[] tab = table;;) {
        Node<K,V> f; int n, i, fh;
        
        // 1. 数组未初始化,CAS 初始化
        if (tab == null || (n = tab.length) == 0)
            tab = initTable();
        
        // 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;
        }
        
        // 3. 发现 ForwardingNode,说明正在扩容,协助迁移
        else if ((fh = f.hash) == MOVED)
            tab = helpTransfer(tab, f);
        
        // 4. 桶非空且未扩容,synchronized 锁住头节点
        else {
            V oldVal = null;
            synchronized (f) {
                if (tabAt(tab, i) == f) {  // 双重检查
                    if (fh >= 0) {  // 链表
                        binCount = 1;
                        for (Node<K,V> e = f;; ++binCount) {
                            K ek;
                            if (e.hash == hash && 
                                ((ek = e.key) == key || (ek != null && key.equals(ek)))) {
                                oldVal = e.val;
                                if (!onlyIfAbsent) e.val = value;
                                break;
                            }
                            Node<K,V> pred = e;
                            if ((e = e.next) == null) {
                                pred.next = new Node<K,V>(hash, key, value, null);
                                break;
                            }
                        }
                    }
                    else if (f instanceof TreeBin) {  // 红黑树
                        Node<K,V> p;
                        binCount = 2;
                        if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key, value)) != null) {
                            oldVal = p.val;
                            if (!onlyIfAbsent) p.val = value;
                        }
                    }
                }
            }
            // 5. 链表长度 ≥ 8,尝试树化
            if (binCount != 0) {
                if (binCount >= TREEIFY_THRESHOLD)
                    treeifyBin(tab, i);
                if (oldVal != null)
                    return oldVal;
                break;
            }
        }
    }
    addCount(1L, binCount);  // 更新计数 + 检查扩容
    return null;
}

put 流程总结:

  1. 计算 hash → 2. CAS 初始化数组 → 3. CAS 空桶插入 → 4. 发现扩容则协助迁移 → 5. synchronized 锁头节点插入链表/树 → 6. 检查树化 → 7. 更新计数并检查扩容

3.2 get 流程(全程无锁)

java 复制代码
public V get(Object key) {
    Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
    int h = spread(key.hashCode());
    
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (e = tabAt(tab, (n - 1) & h)) != null) {
        
        // 头节点就是要找的
        if ((eh = e.hash) == h) {
            if ((ek = e.key) == key || (ek != null && key.equals(ek)))
                return e.val;
        }
        // hash < 0,可能是 ForwardingNode 或 TreeBin
        else if (eh < 0)
            return (p = e.find(h, key)) != null ? p.val : null;
        
        // 遍历链表
        while ((e = e.next) != null) {
            if (e.hash == h &&
                ((ek = e.key) == key || (ek != null && key.equals(ek))))
                return e.val;
        }
    }
    return null;
}

get 为什么不需要加锁

  • Node 的 val 和 next 是 volatile,保证写操作立即可见
  • table 数组引用是 volatile,扩容后新数组引用立即可见
  • 即使读到正在扩容的 ForwardingNode,也会被转发到 nextTable 继续查找

3.3 size / mappingCount(分段计数)

java 复制代码
public int size() {
    long n = sumCount();
    return ((n < 0L) ? 0 :
            (n > (long)Integer.MAX_VALUE) ? Integer.MAX_VALUE : (int)n);
}

public long mappingCount() {
    long n = sumCount();
    return (n < 0L) ? 0L : n;
}

final long sumCount() {
    CounterCell[] as = counterCells; CounterCell a;
    long sum = baseCount;
    if (as != null) {
        for (int i = 0; i < as.length; ++i) {
            if ((a = as[i]) != null)
                sum += a.value;
        }
    }
    return sum;
}

计数机制(类似 LongAdder)

  • baseCount:基础计数器,无竞争时 CAS 更新
  • counterCells:计数器数组,竞争时将增量分散到不同槽位
  • 每个线程根据 ThreadLocalRandom.getProbe() 映射到特定槽位,减少 CAS 竞争

四、扩容机制(JDK 1.8 核心难点)

4.1 触发条件

java 复制代码
// addCount 中检查扩容
if (check >= 0) {
    Node<K,V>[] tab, nt; int n, sc;
    while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
           (n = tab.length) < MAXIMUM_CAPACITY) {
        // 达到扩容阈值,触发扩容
    }
}

4.2 多线程协作扩容(transfer)

java 复制代码
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
    int n = tab.length, stride;
    
    // 1. 计算每个线程负责的迁移范围(步长)
    if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
        stride = MIN_TRANSFER_STRIDE; // 最小步长 16
    
    // 2. 初始化新表(2倍容量)
    if (nextTab == null) {
        Node<K,V>[] nt = (Node<K,V>[])new Node[n << 1];
        nextTab = nt;
        nextTable = nextTab;
        transferIndex = n;  // 从数组末尾开始分配任务
    }
    
    ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
    boolean advance = true;
    boolean finishing = false;
    
    // 3. 自旋迁移
    for (int i = 0, bound = 0;;) {
        Node<K,V> f; int fh;
        
        // 3.1 分配迁移任务(CAS 更新 transferIndex)
        while (advance) {
            int nextIndex, nextBound;
            if (--i >= bound || finishing)
                advance = false;
            else if ((nextIndex = transferIndex) <= 0) {
                i = -1; advance = false;  // 无任务可分配
            }
            else if (U.compareAndSwapInt(this, TRANSFERINDEX, nextIndex,
                      nextBound = (nextIndex > stride ? nextIndex - stride : 0))) {
                bound = nextBound;
                i = nextIndex - 1;
                advance = false;
            }
        }
        
        // 3.2 迁移完成判断
        if (i < 0 || i >= n || i + n >= nextn) {
            int sc;
            if (finishing) {  // 全部迁移完成
                nextTable = null;
                table = nextTab;
                sizeCtl = (n << 1) - (n >>> 1);  // 新阈值 = 0.75 * 新容量
                return;
            }
            // 当前线程完成,CAS 减少扩容线程数
            if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
                if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
                    return;  // 还有其他线程在扩容
                finishing = advance = true;
                i = n;  // 最后检查一遍
            }
        }
        
        // 3.3 迁移空桶
        else if ((f = tabAt(tab, i)) == null)
            advance = casTabAt(tab, i, null, fwd);  // 标记 ForwardingNode
        
        // 3.4 已迁移过
        else if ((fh = f.hash) == MOVED)
            advance = true;
        
        // 3.5 迁移非空桶(synchronized 锁住头节点)
        else {
            synchronized (f) {
                if (tabAt(tab, i) == f) {
                    Node<K,V> ln, hn;
                    if (fh >= 0) {  // 链表迁移
                        // 高低位拆分:hash & oldCap == 0 留在原位置,否则移到 i + n
                        int runBit = fh & n;
                        Node<K,V> lastRun = f;
                        for (Node<K,V> p = f.next; p != null; p = p.next) {
                            int b = p.hash & n;
                            if (b != runBit) {
                                runBit = b;
                                lastRun = p;
                            }
                        }
                        // 构建高低位链表
                        if (runBit == 0) { ln = lastRun; hn = null; }
                        else { hn = lastRun; ln = null; }
                        for (Node<K,V> p = f; p != lastRun; p = p.next) {
                            int ph = p.hash; K pk = p.key; V pv = p.val;
                            if ((ph & n) == 0)
                                ln = new Node<K,V>(ph, pk, pv, ln);
                            else
                                hn = new Node<K,V>(ph, pk, pv, hn);
                        }
                        setTabAt(nextTab, i, ln);      // 低位放原位置
                        setTabAt(nextTab, i + n, hn);  // 高位放 i + n
                        setTabAt(tab, i, fwd);         // 旧桶标记已迁移
                        advance = true;
                    }
                    else if (f instanceof TreeBin) {  // 红黑树迁移
                        // 同样拆分为高低两棵树
                        TreeBin<K,V> t = (TreeBin<K,V>)f;
                        TreeNode<K,V> lo = null, loTail = null;
                        TreeNode<K,V> hi = null, hiTail = null;
                        int lc = 0, hc = 0;
                        for (Node<K,V> e = t.first; e != null; e = e.next) {
                            int h = e.hash;
                            TreeNode<K,V> p = new TreeNode<K,V>(h, e.key, e.val, null, null);
                            if ((h & n) == 0) {
                                if ((p.prev = loTail) == null) lo = p;
                                else loTail.next = p;
                                loTail = p; ++lc;
                            } else {
                                if ((p.prev = hiTail) == null) hi = p;
                                else hiTail.next = p;
                                hiTail = p; ++hc;
                            }
                        }
                        // 树节点数 < 6 则退化为链表
                        ln = (lc <= UNTREEIFY_THRESHOLD) ? untreeify(lo) :
                             (hc != 0) ? new TreeBin<K,V>(lo) : t;
                        hn = (hc <= UNTREEIFY_THRESHOLD) ? untreeify(hi) :
                             (lc != 0) ? new TreeBin<K,V>(hi) : t;
                        setTabAt(nextTab, i, ln);
                        setTabAt(nextTab, i + n, hn);
                        setTabAt(tab, i, fwd);
                        advance = true;
                    }
                }
            }
        }
    }
}

扩容核心要点:

  1. 渐进式扩容:不是一次性迁移所有数据,而是分批次、多线程协作
  2. 任务分配:通过 transferIndex CAS 分配迁移范围,每个线程负责一段
  3. 高低位拆分:利用 hash & oldCap 判断节点留在原位置还是移到 i + n
  4. ForwardingNode 标记:已迁移的桶用 hash = -1 的 ForwardingNode 标记,读操作转发到新表
  5. 协助扩容:其他线程发现桶正在迁移时,调用 helpTransfer 协助迁移

五、JDK 1.7 分段锁回顾(对比理解)

java 复制代码
// 1.7 结构:Segment 数组,每个 Segment 继承 ReentrantLock
static final class Segment<K,V> extends ReentrantLock implements Serializable {
    transient volatile HashEntry<K,V>[] table;
    transient int count;
    // ...
}

// put 流程:先定位 Segment,再获取锁
public V put(K key, V value) {
    Segment<K,V> s;
    int hash = hash(key);
    int j = (hash >>> segmentShift) & segmentMask;  // 定位 Segment
    if ((s = (Segment<K,V>)UNSAFE.getObject(segments, (j << SSHIFT) + SBASE)) == null)
        s = ensureSegment(j);  // CAS 初始化 Segment
    return s.put(key, hash, value, false);  // Segment 内部加锁 put
}

// Segment.put:tryLock → scanAndLockForPut(自旋)→ lock(阻塞)
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
    HashEntry<K,V> node = tryLock() ? null : scanAndLockForPut(key, hash, value);
    // ... 链表操作
    finally { unlock(); }
}

1.7 的局限:

  • Segment 个数初始化后不可变,最大并发度受限
  • 每个 Segment 内部是独立的小 HashMap,扩容时只扩容该 Segment
  • 读操作需要两次 hash 计算,且可能读到过期数据(需重试)

六、关键设计总结

设计点 实现方式 目的
无锁读 volatile 保证可见性 + ForwardingNode 转发 读操作完全并行,不加锁
CAS 写 sun.misc.Unsafe 的 CAS 操作 空桶插入无锁竞争
细粒度锁 synchronized 锁住桶头节点 冲突时只锁一个桶,其他桶并发
分段计数 baseCount + CounterCell[] 避免所有线程竞争同一计数器
协作扩容 transferIndex CAS 分配任务 + 多线程迁移 减少单线程扩容停顿
红黑树优化 链表长度 ≥ 8 且数组长度 ≥ 64 时树化 极端冲突下 O(n) → O(log n)

七、使用注意事项

1. key/value 不能为 null:设计如此,避免 get(key) 返回 null 时无法区分"key 不存在"和"value 为 null"

java 复制代码
// 以下代码会抛出 NullPointerException
map.put(null, "value");   // ❌
map.put("key", null);    // ❌

设计原因:

  • get(key) 返回 null 时,无法区分"key 不存在"和"value 为 null"
  • ConcurrentHashMap 作者 Doug Lea 认为多线程下 null 语义模糊,干脆禁止

对比:HashMap 允许 null key/value,Hashtable 也禁止。

2. size() 是估计值:并发环境下返回的是近似值,非精确值

java 复制代码
public int size() {
    long n = sumCount();
    return (n < 0L) ? 0 : (n > Integer.MAX_VALUE) ? Integer.MAX_VALUE : (int)n;
}

问题:

  • sumCount() 累加 baseCount + CounterCell\[\],但计数器更新有延迟
  • 并发写入时,size() 返回的是近似值,不是精确值
  • 如果需要精确计数,需外部加锁或使用 AtomicInteger 辅助

对比:Collections.synchronizedMap 的 size() 是精确的(因为全表锁),但代价是性能。

3. computeIfAbsent 原子性:保证"判断+计算+插入"整体原子,但计算逻辑不能太重

4. 迭代器弱一致性:不会抛出 ConcurrentModificationException,但可能读到已删除或读不到新插入的元素

可能读到过期/缺失数据

java 复制代码
// 迭代期间,其他线程的修改可能反映也可能不反映
for (Map.Entry<K, V> entry : map.entrySet()) {
    // 可能读到已被删除的元素
    // 可能读不到刚插入的元素
}

问题

  • 不会抛出 ConcurrentModificationException(这是好处也是缺点)
  • 如果需要强一致性快照,需自己加锁或使用 Collections.unmodifiableMap(new HashMap<>(map)) 拷贝

5. 避免热点 key:如果所有线程都竞争同一个桶,性能会退化到接近 Hashtable

java 复制代码
// 极端场景:所有线程都 put/get 同一个 key
// 所有操作都竞争同一个桶的头节点锁

问题:

  • 虽然锁粒度是桶级别,但如果所有操作都命中同一个桶,性能退化到接近 Hashtable
  • 解决方案:key 设计分散(好的 hashCode),或使用 Striped 锁分散热点

6. 内存开销高于 HashMap

开销来源 说明
volatile 字段 每个 Node 的 valnext 都是 volatile,有内存屏障开销
CounterCell[] 分段计数器数组,无竞争时也存在
红黑树节点 TreeNodeNode 多 parent/left/right/red 等字段
扩容时的双表 扩容期间同时存在 tablenextTable

估算:同等数据量下,ConcurrentHashMap 内存占用约为 HashMap 的 1.2~1.5 倍。

7. computeIfAbsent 的坑:计算逻辑可能阻塞其他线程

java 复制代码
map.computeIfAbsent(key, k -> {
    // 如果这里执行很慢(如网络请求、复杂计算)
    // 其他线程对该桶的读写都会被阻塞!
    return loadFromDatabase(k);
});

问题:

  • computeIfAbsent 内部会 synchronized 锁住桶头节点
  • 计算逻辑在锁内执行,重计算会阻塞该桶的所有并发操作
  • 解决方案:先 get 判断,不存在时再用 putIfAbsent 或外部缓存

8. 扩容期间的写操作有一定开销

java 复制代码
// 扩容期间,发现 ForwardingNode(hash = -1)
// 需要调用 helpTransfer() 协助迁移,增加额外操作

问题:

  • 虽然渐进式扩容避免了全表停顿,但扩容期间的写入操作需要额外判断和协助迁移
  • 如果写入频繁且容量设计不当,可能频繁触发扩容,影响吞吐量

9. 序列化/克隆开销

java 复制代码
// ConcurrentHashMap 的序列化不是原子操作
// 序列化期间其他线程的修改可能部分反映

七、好处

1. 极高的并发性能

对比项 Hashtable Collections.synchronizedMap ConcurrentHashMap
锁粒度 整张表(synchronized 方法) 整张表(synchronized 代码块) 桶级别(CAS + synchronized 头节点)
读操作 阻塞(所有读写互斥) 阻塞 完全无锁,多线程并行读
写操作 串行 串行 不同桶并行写入,冲突桶才串行
并发度 1 1 理论上无上限(取决于桶数)

核心原理 :JDK 1.8 中,空桶插入用 CAS(无锁),冲突桶用 synchronized 锁头节点,读操作全程依赖 volatile 保证可见性,不加任何锁。

2. 渐进式扩容(无停顿扩容)

java 复制代码
// 扩容时,多个线程通过 transferIndex CAS 分配迁移任务
// 每个线程负责一段范围的桶迁移,逐步完成

好处:

  • 不会像 HashMap 那样一次性全表迁移导致长时间停顿
  • 扩容期间,读操作不受影响(ForwardingNode 转发到新表)
  • 其他线程可以协助扩容,加快迁移速度

3. 读操作完全无锁

java 复制代码
public V get(Object key) {
    // 全程无 synchronized、无 Lock、无 CAS
    // 仅靠 volatile 保证可见性
    Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
    // ... 直接读取
}

好处:

  • 读操作零阻塞,性能接近 HashMap
  • 高并发读场景(如配置缓存、热点数据)表现极佳
  • 无锁读意味着无线程上下文切换开销

4. 分段计数器(低竞争计数)

java 复制代码
// 类似 LongAdder 的设计
private transient volatile long baseCount;
private transient volatile CounterCell[] counterCells;

好处

  • 无竞争时 CAS 更新 baseCount
  • 高竞争时分散到 CounterCell\[\] 数组,各线程写不同槽位
  • 避免了所有线程竞争同一个计数器导致的 CAS 失败重试

5. 红黑树优化极端冲突

条件 行为
链表长度 < 8 保持链表,插入 O(1)
链表长度 ≥ 8 且数组长度 ≥ 64 转为红黑树,查询 O(log n)
树节点数 < 6 退化为链表,节省内存

好处:避免哈希冲突极端情况下(所有 key 哈希相同)链表退化为 O(n) 查询。

6. 丰富的原子复合操作

java 复制代码
// 原子性:判断 + 计算 + 插入
V v = map.computeIfAbsent(key, k -> expensiveCompute(k));

// 原子性:判断 + 删除
map.remove(key, value);

// 原子性:判断 + 替换
map.replace(key, oldValue, newValue);

好处:避免了传统 if (!map.containsKey(k)) map.put(k, v) 的非原子竞态。

八、适用场景与选型建议

场景 推荐方案 原因
高并发读 + 少量写 ConcurrentHashMap 读无锁,性能最优
高并发读写均衡 ConcurrentHashMap 桶级别锁,读写并行
需要精确 size() Collections.synchronizedMap + 外部锁 ConcurrentHashMap.size() 是近似值
需要有序 ConcurrentSkipListMap ConcurrentHashMap 无序
单线程 / 低并发 HashMap 无并发开销,内存更省
需要 null key/value HashMapCollections.synchronizedMap ConcurrentHashMap 禁止 null
需要事务性批量操作 外部锁 + HashMap ConcurrentHashMap 无事务支持

九、总结

bash 复制代码
ConcurrentHashMap 的核心设计哲学:
    "在绝大多数情况下保证线程安全,同时尽可能减少锁的范围"
    
好处:
    ✓ 读完全无锁,性能接近 HashMap
    ✓ 写锁粒度细(桶级别),并发度高
    ✓ 渐进式扩容,无全表停顿
    ✓ 分段计数,低竞争开销
    ✓ 红黑树优化极端冲突

缺点:
    ✗ size() 是近似值,非精确
    ✗ 迭代器弱一致性,无快照保证
    ✗ 不支持 null key/value
    ✗ 热点 key 导致性能退化
    ✗ 内存开销高于 HashMap
    ✗ computeIfAbsent 重计算会阻塞桶
    ✗ 扩容期间写入有额外开销

如果你的场景是高并发读写、不要求精确计数、不需要 null、key 分布均匀,ConcurrentHashMap 是最佳选择。如果需要精确语义或有序性,则需配合其他工具或改用其他实现。

相关推荐
秋田君1 小时前
QT_QT布局详解
开发语言·数据库·qt
ximen502_1 小时前
Python 语言知识总结
开发语言·python
m0_738120721 小时前
PHP代码审计基础——超全局变量(三)
开发语言·安全·网络安全·php
烟锁池塘柳01 小时前
【C/C++】解决C++控制台输出中文乱码问题
c语言·开发语言·c++
C+-C资深大佬2 小时前
Java 变量:从入门到精通
java·开发语言·python
辞旧 lekkk2 小时前
【Qt系统相关】鼠标事件
linux·开发语言·qt·学习·计算机外设·萌新
Flittly2 小时前
【日常小问】Docker 部署 Nacos 2.4.3,Dubbo 3.3 连接 Config Service 失败排查
java·spring boot·dubbo
chouchuang2 小时前
day-027-面向对象-下
开发语言·python
誰能久伴不乏2 小时前
C++11 随机数生成——告别 rand()
开发语言·c++