⭐⭐⭐ Spring Boot 项目实战 ⭐⭐⭐ Spring Cloud 项目实战
《Dubbo 实现原理与源码解析 —— 精品合集》 《Netty 实现原理与源码解析 —— 精品合集》
《Spring 实现原理与源码解析 —— 精品合集》 《MyBatis 实现原理与源码解析 —— 精品合集》
《Spring MVC 实现原理与源码解析 —— 精品合集》 《数据库实体设计合集》
《Spring Boot 实现原理与源码解析 —— 精品合集》 《Java 面试题 + Java 学习指南》

摘要: 原创出处 小哈学Java 「犬小哈」欢迎转载,保留摘要,谢谢!


🙂🙂🙂关注微信公众号:【芋道源码】有福利:

  1. RocketMQ / MyCAT / Sharding-JDBC 所有源码分析文章列表
  2. RocketMQ / MyCAT / Sharding-JDBC 中文注释源码 GitHub 地址
  3. 您对于源码的疑问每条留言都将得到认真回复。甚至不知道如何读源码也可以请教噢。
  4. 新的源码解析文章实时收到通知。每周更新一篇左右。
  5. 认真的源码交流微信群。

面试考察点

面试官提出这个问题,通常意在考察以下几个层面的能力:

  1. 对并发编程基础的理解:面试者是否清楚线程安全的本质,以及实现线程安全的核心手段(如锁、CAS、不可变性)。
  2. 对 ConcurrentHashMap 核心设计思想的掌握:不仅仅是知道它 “是线程安全的”,更要理解它如何在高并发环境下,通过精巧的设计在保证线程安全的同时,兼顾高性能。这比简单地回答 “用了锁” 要深入得多。
  3. 对 JDK 版本演进的关注:是否能清晰阐述 ConcurrentHashMap 在 JDK 1.7 和 JDK 1.8 及之后版本中实现的重大差异,这反映了面试者跟进技术发展的能力。
  4. 源码级理解能力:能否深入到关键数据结构(如 Node、TreeNode)和核心操作(如 putVal、helpTransfer)的层面进行解释,展示出扎实的内功。
  5. 结合实际场景的思考:是否能正确评价其优缺点,并在实际开发中做出恰当的使用选择(例如,理解其弱一致性的迭代器)。

核心答案

ConcurrentHashMap 保证线程安全的核心思想是:在保证数据一致性的前提下,最大限度地减少锁的竞争,提升并发性能。 其具体实现随着 JDK 版本发生了重大演进:

  • JDK 1.7:采用 分段锁(Segment) 机制。整个哈希表由多个 Segment 组成,每个 Segment 独立加锁。写操作只锁住对应的 Segment,不同 Segment 的写操作可以并发进行。
  • JDK 1.8 及之后:摒弃了分段锁,采用了 CAS(Compare-And-Swap)乐观锁 + synchronized 悲观锁 的精细粒度锁方案。将锁的粒度缩小到单个哈希桶(Node 数组的每个下标位置)的头节点。只有在发生哈希冲突时,才会对链表的头节点或红黑树的根节点使用 synchronized 加锁。

深度解析

JDK 1.8 的实现是现代 Java 并发设计的典范,下面我们进行深入剖析。

原理与机制

  1. 数据结构:底层结构与 HashMap(JDK 1.8+)类似,是 “Node 数组 + 链表 / 红黑树”。关键节点 Node 的 val 和 next 字段都用 volatile 修饰,保证了线程间的可见性。

  2. 核心安全策略:CAS + synchronized

  • 初始化与扩容(initTable, transfer):使用 CAS 操作来竞争地设置数组的初始大小或标记扩容状态,避免了不必要的同步开销。例如,多个线程可能同时发现需要初始化,但只有一个线程能通过 CAS 成功设置初始化标识。

  • 插入元素(putVal):

    • 第一步:计算哈希定位到桶。如果桶为空(null),则尝试使用 CAS 将新节点放入。此操作是无锁的,性能极高。
    • 第二步:如果桶不为空(说明发生了哈希冲突),则使用 synchronized 锁住这个桶的头节点(链表头或树根)。然后在锁内进行链表遍历插入或树节点插入。
  • 读取元素(get):全程无锁。由于 Node 的字段是 volatile 的,get 操作总能读到最新写入的值。这是它高并发读性能的基石。

  • 扩容(协助扩容):这是一个精妙的设计。当有线程进行写操作时,如果发现当前哈希表正在扩容,它不会阻塞等待,而是会主动 helpTransfer,协助其他线程一起完成数据迁移工作。这种 “共同奋斗” 的机制大大提高了扩容效率。

代码示例

以下是 JDK 1.8 putVal 方法关键步骤的简化伪代码,展示了其线程安全逻辑:

final V putVal(K key, V value, boolean onlyIfAbsent) {
// ... 参数校验,计算 hash ...
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(); // 内部使用 CAS 控制并发初始化
// 情况2: 目标桶为空,CAS插入新节点
elseif ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value)))
break; // CAS成功,插入完成,跳出循环
// CAS失败,说明有其他线程抢先插入了,进入下一轮循环重试
}
// 情况3: 检测到正在扩容,当前线程协助扩容
elseif ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
// 情况4: 目标桶非空且未在扩容,synchronized锁住头节点进行操作
else {
V oldVal = null;
synchronized (f) { // 锁粒度仅为单个桶的头节点
// 再次确认头节点未变(防止在加锁前瞬间被其他线程修改)
if (tabAt(tab, i) == f) {
if (fh >= 0) { // 链表
// ... 遍历链表,插入或更新 ...
} elseif (f instanceof TreeBin) { // 红黑树
// ... 操作红黑树 ...
}
}
}
// 锁释放后,检查是否需要将链表转换为红黑树
if (binCount != 0) {
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
// ... 返回旧值 ...
}
}
}
// ... 更新元素计数,检查扩容 ...
}

对比分析与最佳实践

  • 与 Hashtable、Collections.synchronizedMap 对比:

    • Hashtable 等使用对象级锁(synchronized 修饰方法),任何写操作都会锁住整个表,并发性能极差。
    • ConcurrentHashMap 的锁粒度小得多(JDK 1.7 是段,JDK 1.8 是单个桶),并发写冲突的概率大大降低,实现了高并发下的高吞吐量。
  • 最佳实践与注意事项:

    1. 适用场景:它是高并发读写的首选。对于读多写少或写多但冲突不严重的场景,性能优势极其明显。
    2. 弱一致性:它的迭代器、size()、isEmpty() 等方法返回的是近似值或快照,不保证强一致性(反映所有最新更新),但保证了单次调用的性能。这是 CAP 理论中偏向 AP(可用性、分区容错性)的一种权衡。
    3. 无锁读:get() 操作完全无锁,这是其设计的一大亮点。
  • 常见误区:

    • 误区一:认为 ConcurrentHashMap 的所有操作都是无锁的。实际上,写冲突时的 synchronized 锁是存在的,只是粒度很细。
    • 误区二:认为它的迭代器是 “失败安全” 的(Fail-Safe)。严格来说,它是弱一致性的,迭代过程中可能反映部分更新,但不会抛出 ConcurrentModificationException。
    • 误区三:在任何并发场景下都无脑选用。如果业务需要强一致性的原子复合操作(如 “若没有则添加”),仍可能需要额外的外部同步,或使用其提供的原子方法(如 putIfAbsent、compute 等)。

总结

ConcurrentHashMap 通过 CAS 无锁化尝试 + synchronized 精细化锁 的组合拳,辅以 volatile 可见性保证 和 协助扩容机制,在数据结构层面实现了线程安全与高并发性能的卓越平衡,是 Java 并发容器中的标杆实现。理解其设计演进(从分段锁到 CAS+synchronized)和核心原理,是衡量 Java 并发编程功底的重要标尺。

文章目录
  1. 1. 面试考察点
  2. 2. 核心答案
  3. 3. 深度解析
    1. 3.1. 原理与机制
    2. 3.2. 代码示例
    3. 3.3. 对比分析与最佳实践
  4. 4. 总结