← 返回题目列表

ConcurrentHashMap 是如何保证线程安全的?

高频 困难 第 15 / 30 题 更新于 2026/07/25
ConcurrentHashMap并发容器CAS

简化版

JDK 8 的 ConcurrentHashMap 结构和 HashMap 一样是「数组 + 链表/红黑树」,线程安全靠 CAS + synchronized 锁单个桶:空桶用 CAS 无锁写入,非空桶只对该桶的头节点synchronized。锁粒度细到一个桶,不同桶的读写互不阻塞,并发度远高于早期的分段锁。读操作则基本无锁。

详细版

JDK 7 vs JDK 8 是这道题的核心对比:

  • JDK 7——分段锁(Segment):按 concurrencyLevel 估算并创建若干 Segment(常见默认值为 16),每段一把 ReentrantLock。同一段内的写操作串行、不同段可并发;Segment 数量创建后不随 Map 扩容增长,因此并发上限受初始分段数约束,并非任何配置都固定为 16。
  • JDK 8——桶级锁(CAS + synchronized):去掉 Segment,直接用 Node[] table
    • 空桶:用 CAS 把新节点原子地放进去,无锁,失败就自旋重试;
    • 非空桶:只对该桶的头节点synchronized,锁住这一个桶,其他桶完全不受影响;
    • 并发度不再是固定 16,而是约等于桶的数量,随容量增长而提升。

其他关键机制:

  • 读无锁Nodevalnext 都是 volatile,读操作直接读,不加锁,保证可见性。
  • 多线程协同扩容:扩容时多个线程能一起帮忙迁移(transfer),已迁移完的桶用 ForwardingNode 标记,别的线程看到它就知道该去新表操作。
  • 分散计数sizebaseCount + CounterCell[] 分散累加,避免所有线程抢一个计数器(借鉴了 LongAdder 的思路)。

完整版教学

一、演进主线:锁粒度越拆越细

把整个演进史串起来看:并发容器的进化,就是锁粒度不断变细的过程。

  • Hashtable:一把大锁锁整个表,任何操作都互斥,并发度 = 1,最慢。
  • JDK 7 ConcurrentHashMap:锁「段」,并发度 = 段数(16)。
  • JDK 8 ConcurrentHashMap:锁「桶」,并发度 ≈ 桶数(可能上千)。

锁的范围从「整张表」缩到「一个桶」,冲突概率大幅下降,这就是它高并发的根本原因。

二、为什么用 synchronized 而不是 ReentrantLock

JDK 7 用 ReentrantLock(Segment 继承自它),JDK 8 反而换回了 synchronized,看着像「退步」,其实是因为:

  • 现代 HotSpot 对 synchronized 提供轻量级快速路径、锁消除等优化,低竞争下不必直接进入重量级阻塞;偏向锁是较早 JDK 的历史优化,已在新版本中移除,不能当作永久机制来背;
  • 锁的对象是单个桶的头节点,竞争本就很低,大多数时候是轻量级锁,性能优秀;
  • synchronized 由 JVM 管理,出锁不用手动 unlock,不会因忘记释放而死锁,代码更简洁。

三、为什么读操作能不加锁

get 全程无锁,靠的是 volatile

static class Node<K,V> {
    final int hash;
    final K key;
    volatile V val;        // volatile 保证写入立即可见
    volatile Node<K,V> next;
}

valnext 都是 volatile,一个线程的写入对读线程立即可见。所以读线程直接遍历桶就能拿到最新值,不需要加锁。这让「读多写少」场景(绝大多数缓存场景)性能极好。

四、为什么不允许 null 键和 null 值

HashMap 允许 null,但 ConcurrentHashMap 禁止 key 和 value 为 null,会抛 NPE。原因是并发下的二义性

map.get(key);   // 返回 null

在并发 map 里,返回 null 有两种可能:「key 不存在」或「key 存在但值就是 null」。单线程可以再用 containsKey 确认,但并发下这两步之间值可能被改,无法区分。所以干脆禁止 null,消除歧义。

五、复合操作仍需原子 API

ConcurrentHashMap 保证的是单个方法的线程安全,「先检查再写入」这种复合操作它管不了:

// ❌ 非原子!check 和 put 之间可能被别的线程插入
if (!map.containsKey(k)) map.put(k, v);

// ✅ 用原子方法
map.putIfAbsent(k, v);
map.computeIfAbsent(k, key -> createValue(key));

记忆点:容器线程安全 ≠ 你的业务逻辑线程安全。涉及「读-改-写」组合时,必须用 putIfAbsentcomputemerge 这类原子方法。

六、协同扩容与近似 size 怎么理解

扩容不是让一个线程独自搬完整张表。线程发现容量不足后会申请两倍新表,并通过共享的 transferIndex 领取一段桶区间;迁移完成的旧桶放入 ForwardingNode。其他线程遇到该标记,会沿 nextTable 去新表查找,或参与剩余迁移,因此扩容期间读写仍能推进。

旧表 16 桶                  新表 32 桶
线程 T1:迁移桶 12..15 ──→ 对应低位/高位桶
线程 T2:迁移桶  8..11 ──→ 对应低位/高位桶
已迁移桶:ForwardingNode ─→ nextTable

size() 也不是每次都锁住全表统计。更新先尝试 CAS 修改 baseCount,竞争激烈时分散到多个 CounterCell,读取时再求和;并发更新过程中得到的是一个瞬时近似值,不能把 size()==0 当成后续操作仍为空的事务条件。

场景内部策略并发意义
空桶插入CAS成功时无需阻塞
冲突桶更新锁桶头节点只串行同桶写入
普通读取volatile 可见性不获取桶锁
扩容多线程分段迁移分摊 O(n) 搬迁工作
计数baseCount + CounterCell降低单计数器热点

七、常见误区与追问

  • 误区:JDK 8 ConcurrentHashMap 仍然使用 Segment。 Segment 是 JDK 7 的分段锁结构,JDK 8 已改为 Node 数组、CAS 与桶级 synchronized。
  • 误区:用了 ConcurrentHashMap,任意多步业务操作都自动原子。 单方法安全不覆盖“先读再写”,应使用 compute、merge、replace 等原子 API。
  • 误区:get 完全没有同步语义。 它不加互斥锁,但依赖 volatile 读和安全发布获得可见性。
  • 追问:computeIfAbsent 的映射函数能做慢 I/O 吗? 不宜;计算可能占用桶级同步路径,还必须避免递归更新同一映射和不可控副作用。
  • 追问:为什么禁止 null value? null 被保留为“不存在”的无歧义信号,使单次 get 足以判断结果,不需要并发竞态下再 containsKey。
  • 追问:size 能否用于并发配额控制? 不能把瞬时统计当强一致事务条件,严格配额应另用锁、信号量或专门计数协议。

记忆钩子:ConcurrentHashMap 的核心不是“没有锁”,而是让锁只落在真正冲突的桶上,其余读写和迁移尽量并行。

八、加强记忆

JDK 8 ConcurrentHashMap 以 Node 数组为骨架:空桶 CAS 插入,冲突桶锁住头节点,读路径靠 volatile 保持可见;扩容时线程按区间协作迁移,计数则分散到 baseCount 与 CounterCell。它禁止 null 来消除并发判空歧义,并提供 compute、merge、putIfAbsent 等原子复合 API。记住“桶级协调、读不互斥、迁移可协作、业务复合操作仍要选原子接口”,就能把线程安全与业务原子性分开。