← 每日面试题 学习看板

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

面试题/00java/01Java 基础/05 集合/ConcurrentHashMap是如何保证线程安全的?.md

面试记录 2026-10-09

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

难度:high

标签:java/collections, general/knowledge

评分:⭐⭐⭐☆☆(3/5)

我的回答

ConcurrentHashMap 保证线程安全还是使用的锁,synchronized 的关键字。在 JDK1.8 之前,它使用的是从区域分段锁。它的结构在 1.8 之前是用的分段,然后分桶,然后链表的模式。它锁一次是锁一个段的内容,主要还是为了控制锁的力度。在 JDK1.8 之后,把 synchronized 的移动到桶上了,也就是说只有出现哈希冲突,往对应的桶中添加元素的时候才会上锁,这时候它的力度更低。它在添加元素的时候如果出现哈西冲突,才走相关于锁的那一套东西。核心还是通过锁去保证线程安全的,只不过它的力度对比 HashMap 要小得多。HashMap 直接在 put 方法上加锁,而 ConcurrentHashMap 是在添加元素对应的桶上加锁,只有当冲突出现的时候才会有锁竞争、锁的问题、线程安全,否则是不会触发对应的锁的。

点评

优点:

不足:

补充(JDK8 ConcurrentHashMap 完整机制):

完整讲解

第一部分:知识重点剖析

核心考点定位:验证对 ConcurrentHashMap JDK8 完整实现的理解,尤其是 CAS + synchronized 混用、协助扩容、分片计数三大机制。

知识点拆解:

  1. 底层原理:ConcurrentHashMap 是"数组 + 链表 + 红黑树"结构,JDK8 把 JDK7 的 Segment 分段锁拆掉,改用桶头节点的 synchronized + CAS 混用。桶为空用 CAS 无锁写入;桶非空 synchronized 桶头节点,锁粒度到单桶。
  2. putVal 完整流程:①hash 定位 index;②table[index]==null → CAS 写入空桶(无锁);③桶头是 FORWARD_NODE → 迁移中,调 helpTransfer 或等迁移;④桶头 key 相同 → synchronized 覆盖 value;⑤TREEBIN → synchronized(treebin) 走树内 put;⑥链表尾插 → synchronized(node) 尾插;⑦count++ 检查扩容阈值;⑧触发 resize 时可能协作扩容。
  3. size 统计(LongAdder 思路):baseCount 单变量 CAS 累加,竞争激烈时降级到 counterCells[] 数组分片累加;size() 遍历累加返回值是"某个瞬间的近似值",不保证强一致。
  4. 协助扩容 helpTransfer:JDK8 用 transferIndex 变量记录迁移进度(从右向左),每个线程迁移 16 个桶(STRIDE 步长);已迁移桶用 FORWARD_NODE 标记;其他线程遇到迁移中的桶会主动 helpTransfer,读操作可 helpTransfer 或走 find 兜底。
  5. 弱一致迭代器:迭代器基于桶数组快照,遍历时允许修改,不抛 CME;可能读到修改前后的任意快照——非常适合读多写少的高并发统计场景。
  6. 设计权衡:synchronized 替代 ReentrantLock 的原因——JDK6+ 优化后性能相当;粒度小更容易在 JIT 层优化;语义简单。CAS 用于空桶快速写入,避免不必要的锁开销。
按「底层原理 → 设计权衡 → 工程实践」递进排列。

第二部分:考察方向与延伸递进

考察方向:

延伸递进面试题:

  1. 为什么 JDK8 取消 Segment 改用桶级 synchronized?
  1. ConcurrentHashMap 的 size() 为什么可能不准?
  1. computeIfAbsent 在 ConcurrentHashMap 上有什么特殊行为?
  1. JDK8 扩容时多线程如何协作?

第三部分:面试者参考回答(300-600 字口语化)

面试官您好,ConcurrentHashMap 的线程安全机制在 JDK7 和 JDK8 差异很大。

JDK7 用分段锁。整个 Map 分成 16 个 Segment,每个 Segment 是一个独立的锁对象(ReentrantLock)+ 一个小 HashEntry 数组。加锁只锁一个 Segment,所以并发度上限是 16——16 个线程可以同时写不同的 Segment。

JDK8 大改,去掉了 Segment,把锁直接下放到桶头节点。整体结构变成"数组 + 链表 + 红黑树"。put 的时候分几种情况:如果目标桶为空,用 Unsafe 的 CAS 操作直接写入,完全不需要加锁;如果桶非空,synchronized 锁住桶头节点,然后在链表或红黑树里做操作。这样锁的粒度就是单个桶,表多大并发度就多大。

读操作几乎完全无锁。桶头节点用 volatile 修饰,读线程直接 volatile 读就能看到最新值,不需要加锁。所以读多写少的场景性能特别好。

size 统计用了类似 LongAdder 的分片思路。有个 baseCount 用 CAS 累加,竞争激烈的时候降级到 counterCells 数组分片累加,避免单点 CAS 竞争。size() 方法遍历累加返回值是"某个瞬间的近似值",不保证强一致。

扩容有个特别巧妙的设计——协助扩容。用一个 transferIndex 变量记录迁移进度(从右向左),每个线程迁移 16 个桶;已迁移的桶用 FORWARD_NODE 特殊标记。其他线程调用 put 时如果发现 FORWARD_NODE,会主动调 helpTransfer 协助迁移;如果只想读,会走 find 兜底查新旧两个位置。

迭代器是弱一致的,基于桶数组快照,遍历时允许修改,不抛 ConcurrentModificationException,可能读到修改前后的任意快照。

对比起来,JDK7 靠"分段锁 + ReentrantLock",并发度受 Segment 数限制;JDK8 靠"桶级 synchronized + CAS 混合",空桶写入走 CAS 完全无锁,非空桶走 synchronized 锁桶头,读走 volatile 无锁,并发度可以无限扩展。JDK6 之后 synchronized 经过锁升级优化,性能和 ReentrantLock 相当,所以 JDK8 干脆用回 synchronized,语义更简洁。