" />
文章

介绍一下 ConcurrentHashMap:JDK 1.7 分段锁与 JDK 1.8 CAS + synchronized

介绍一下 ConcurrentHashMap:JDK 1.7 分段锁与 JDK 1.8 CAS + synchronized

面试指北 · 去哪儿

题意

面试官问“介绍一下 ConcurrentHashMap”,通常不是想听 API 列表,而是想确认你是否真正理解它为什么线程安全、为什么比 Hashtable 和 Collections.synchronizedMap() 并发性能更好,以及 JDK 1.7 到 JDK 1.8 的实现演进。

这道题可以拆成几个考察点:

  1. ConcurrentHashMap 的基本定位:线程安全的哈希表,允许多线程并发读写。
  2. JDK 1.7 的 Segment + HashEntry 分段锁结构。
  3. JDK 1.8 的 Node 数组 + 链表/红黑树 结构,以及 CAS + synchronized 的并发控制。
  4. 扩容机制:为什么能多线程协助扩容,ForwardingNode、sizeCtl 起什么作用。
  5. 与 Hashtable、Collections.synchronizedMap()、HashMap 的区别。
  6. 常见追问:为什么 key/value 不能为 null、size() 准不准、链表什么时候树化、get 要不要加锁。

回答时建议先给总述,再展开 JDK 1.8 的核心流程,最后补充版本对比和易错点。

结论

ConcurrentHashMap 是 Java 并发包中提供的高并发哈希表实现,核心目标是:在多线程环境下保证线程安全,同时尽量缩小锁的范围,提高并发度。

关键结论可以概括为:

  • JDK 1.7 采用 Segment + HashEntry 结构,Segment 继承 ReentrantLock,通过分段锁降低锁竞争。并发度受 Segment 数量限制,默认是 16,初始化后 Segment 数量固定。
  • JDK 1.8 放弃了 Segment 分段锁,改为和 HashMap 类似的 Node 数组 + 链表/红黑树 结构。
  • JDK 1.8 的并发控制是 CAS + synchronized:
  • 桶为空时,先尝试 CAS 放入新节点,成功就直接返回,不进入锁。
  • 桶非空或 CAS 失败时,使用 synchronized 锁住该桶的头节点,再操作链表或红黑树。
  • 锁的粒度从 JDK 1.7 的“一个 Segment”缩小到 JDK 1.8 的“一个桶”,并发能力更强。
  • 扩容时 JDK 1.8 支持多线程协助迁移,通过 ForwardingNode 标记已迁移的桶,通过 sizeCtl 控制初始化和扩容状态。
  • 与 Hashtable 和 Collections.synchronizedMap() 的全表同步不同,ConcurrentHashMap 允许多个线程并发访问不同桶,因此并发性能更好。
  • ConcurrentHashMap 不允许 null key 和 null value,迭代器是弱一致性的,size() 在并发场景下是一个估计值,不保证绝对精确。

一句话总结:JDK 1.7 靠分段锁降低竞争,JDK 1.8 靠 CAS 处理无竞争快路径、靠 synchronized 锁桶头节点处理冲突慢路径,再配合多线程协助扩容,从而兼顾线程安全与高并发性能。

要点展开

1. 基本定位:线程安全的 Map,但不是“所有操作都加一把大锁”

ConcurrentHashMap 是 java.util.concurrent 包下的线程安全哈希表。它适用于多线程共享读写 Map 的场景,比如本地缓存、并发统计、路由表、配置映射等。

它有几个需要先明确的特点:

  • 不允许 null key 和 null value,源码中在 put、putVal 等入口会直接抛 NullPointerException。
  • 提供了一些原子复合操作,例如 putIfAbsent、computeIfAbsent、compute、merge、replace 等。
  • 迭代器是弱一致性的:迭代过程中不保证看到最新的全部修改,但通常不会抛出 ConcurrentModificationException。
  • size()、isEmpty()、containsValue() 在并发修改下属于估计值,不保证某一瞬间绝对精确。

面试里经常拿它和 Hashtable、Collections.synchronizedMap() 对比,核心区别就是:后两者基本是全表锁,而 ConcurrentHashMap 把锁的粒度做细了。

2. JDK 1.7:Segment + HashEntry 的分段锁

JDK 1.7 及之前的 ConcurrentHashMap 内部采用 Segment + HashEntry 结构:

  • 整个 Map 被分成多个 Segment,每个 Segment 持有一部分数据。
  • 每个 Segment 本质上是一个继承自 ReentrantLock 的锁对象。
  • 每个 Segment 内部又是一个 HashEntry 数组,数组中的元素以链表形式解决哈希冲突。
  • 对某个 Segment 进行写操作时,只锁住这个 Segment,而不是锁整个 Map。

这样做的好处是:多个线程如果访问不同的 Segment,可以并行写入,从而提升并发度。

但 JDK 1.7 的实现也有局限:

  • Segment 数量在初始化时确定,默认是 16,之后不会动态增加。也就是说,并发度上限受 Segment 数量限制。
  • 锁的粒度仍然偏粗:一个 Segment 下可能有很多桶,操作不同桶也可能竞争同一把 Segment 锁。
  • 每个 Segment 都是一个锁对象,结构和实现相对复杂。

另外,JDK 1.7 中 get 操作通常是不加锁的,主要依靠 HashEntry 中 value 的 volatile 语义保证可见性。写操作加 Segment 锁,读操作尽量无锁,这是分段锁设计的一部分。

3. JDK 1.8:Node 数组 + 链表/红黑树

JDK 1.8 的 ConcurrentHashMap 在数据结构上向 HashMap 靠拢:

  • 主体是 Node<K,V>[] table,也就是一个 Node 数组。
  • 每个数组位置称为一个“桶”,桶内用链表解决冲突。
  • 当链表过长时,会转换为红黑树,降低极端冲突下的查询复杂度。
  • 红黑树节点由 TreeBin 和 TreeNode 相关结构组织,TreeBin 作为桶的头节点参与锁控制。

链表转红黑树有两个条件:

  1. 链表长度达到阈值,常见说法是长度超过 8;
  2. 当前 table 数组长度至少为 64。

如果数组长度还不够 64,ConcurrentHashMap 会优先选择扩容,而不是立刻树化。也就是说,“链表长度到 8 就一定变红黑树”是不准确的。

4. 核心并发控制:CAS 快路径 + synchronized 慢路径

JDK 1.8 不再使用 Segment 分段锁,而是采用 CAS + synchronized。以 put 为例,主干流程可以简化为:

// JDK 1.8 putVal 简化伪代码,只保留主干
if (key == null || value == null) throw new NullPointerException();

int hash = spread(key.hashCode());

for (Node<K,V>[] tab = table;;) {
    if (tab == null || tab.length == 0) {
        initTable();
        continue;
    }

    int i = (tab.length - 1) & hash;
    Node<K,V> f = tabAt(tab, i);

    if (f == null) {
        // 桶为空:CAS 直接放入新节点
        if (casTabAt(tab, i, null, new Node<>(hash, key, value, null))) {
            break;
        }
    } else if (f.hash == MOVED) {
        // 当前桶是 ForwardingNode,说明正在扩容,帮助迁移
        tab = helpTransfer(tab, f);
    } else {
        // 桶非空:锁住桶头节点
        synchronized (f) {
            // 在链表或红黑树中查找:
            // 已存在则更新 value;
            // 不存在则尾插新节点,并视情况触发树化。
        }
        break;
    }
}
addCount(1L, binCount);

这段流程体现了两个关键点:

  1. 无竞争时走 CAS 快路径
    如果目标桶是空的,直接 CAS 放入新节点。CAS 成功就结束,不需要加锁。大量线程访问不同空桶时,可以快速完成写入。

  2. 有竞争时走 synchronized 慢路径
    如果桶非空,说明可能发生哈希冲突或并发写入,此时用 synchronized 锁住桶的头节点,再操作链表或红黑树。锁的范围只在这个桶,不是整个 table。

这里锁的对象通常是桶的头节点 f。如果桶里是链表,就锁链表头节点;如果是红黑树,就锁 TreeBin 头节点。这样不同桶之间互不影响,锁粒度比 JDK 1.7 的 Segment 更小。

get 操作则基本是无锁的:

  • 根据 hash 定位桶;
  • 如果头节点匹配,直接返回;
  • 如果头节点是 ForwardingNode 或树节点,调用对应 find 方法;
  • 否则沿着链表向后遍历。

读操作不加锁,主要依赖 Node 中 val 和 next 等字段的 volatile 语义保证可见性。

5. 为什么是 CAS + synchronized,而不是 ReentrantLock?

JDK 1.7 的 Segment 本身就是 ReentrantLock,JDK 1.8 却改成了 CAS + synchronized,原因可以从几个角度看:

  • 快路径不需要锁:桶为空时 CAS 一次就完成,低冲突场景下几乎无锁开销。
  • synchronized 在现代 JVM 中已经经过大量优化:轻量级锁、锁消除、自适应自旋等机制让它在多数场景下性能并不差。
  • 实现更简单、内存开销更小:如果每个桶都配一个 ReentrantLock,对象数量和内存开销都比较大;而 synchronized 直接锁桶头节点,不需要额外维护锁对象。
  • 锁粒度更细:只锁发生冲突的桶,而不是一个大范围 Segment。

所以更准确的说法是:CAS 负责无竞争场景的快速插入,synchronized 负责有竞争场景下的桶内互斥,二者配合,而不是“1.8 完全用 CAS 替代了锁”。

6. 扩容:ForwardingNode 与多线程协助迁移

JDK 1.8 的扩容也是高频考点。它不像 JDK 1.7 那样由单个线程在 Segment 内完成迁移,而是允许多个线程一起帮助扩容。

扩容过程中有几个关键角色:

  • sizeCtl:控制 table 初始化和扩容状态。不同取值表示不同含义,例如初始化中、扩容中、下次扩容阈值等。扩容线程会通过 CAS 修改 sizeCtl 来“注册”自己参与扩容。
  • ForwardingNode:一个特殊的占位节点,表示该桶已经迁移或正在迁移。它的 nextTable 指向新 table。读线程遇到它时会去新 table 查找;写线程遇到它时会调用 helpTransfer 帮助扩容。
  • transfer:负责把旧数组中的桶迁移到新数组。多个线程会分片领取迁移任务,加快扩容速度。

迁移单个桶时,通常会锁住旧桶头节点,然后把链表拆成低位链和高位链,分别放到新数组的 i 和 i + n 位置。这样既保证迁移过程中的线程安全,也尽量减少对其它桶的影响。

面试中不要简单说“扩容是一个线程做的”。更准确的描述是:**JDK 1.8 支持多线程协助扩容

License:  CC BY 4.0