考察点
这道题几乎是 HashMap 题的必然后续,面试官想确认你不只知道「ConcurrentHashMap 线程安全」,还理解两版实现的设计取舍:JDK 7 的分段锁解决了什么问题、为什么 JDK 8 又放弃了它。追问常往 size() 怎么统计、和 Hashtable/Collections.synchronizedMap 的区别、get 为什么不用加锁这些点走。
参考答案
JDK 7:分段锁 Segment
JDK 7 的 ConcurrentHashMap 内部是一个 Segment 数组,每个 Segment 继承 ReentrantLock,持有一个小的 HashEntry 表,相当于把一个大 Map 拆成若干个小 Map,每个小 Map 一把锁。默认并发度 16,意味着最多 16 个线程同时写不同的段。put 时先按 hash 定位到 Segment,抢到该段的锁再操作段内的链表。
这个设计的局限很明显:并发度在构造时就定死了,Segment 数组一旦创建不能扩容,只有段内的表能扩;锁的粒度还是偏大,同一段内的不同桶依然互斥;遍历和 size() 时要对所有段做处理,逻辑笨重。
JDK 8:CAS + synchronized 锁桶
JDK 8 彻底重写了实现,回归 Node 数组 + 链表 + 红黑树的结构,和 HashMap 一致。并发控制的核心变化是:
- 桶为空时,用 CAS 直接插入新节点,无锁,一次原子操作搞定。
- 桶不为空时,synchronized 锁住该桶的头节点,锁的粒度从段缩小到桶。数组越长并发度越高,理想情况下并发度等于桶的数量。
- 读操作完全不加锁。Node 的 value 和 next 都是 volatile 的,写线程对节点的修改对读线程立即可见,所以 get 是无锁的,这是它吞吐量远超 Hashtable 的根本原因。
扩容也很有意思:支持多线程协同扩容(transfer)。一个线程发现正在扩容时会帮忙搬运节点,搬完自己的份再退出,通过 sizeCtl 字段协调参与扩容的线程数和进度。
size 统计的变化
JDK 7 的 size() 会先尝试两次不加锁累加各段的 count,两次结果一致就直接返回(乐观策略),不一致再锁住所有段统计。JDK 8 引入了 baseCount + CounterCell 的方案,思路类似 LongAdder:低并发时直接 CAS 累加 baseCount,竞争大了就把增量分散到 CounterCell 数组的不同槽位上,size() 时把所有槽位加起来。用空间换时间,避免了高并发下所有线程 CAS 同一个计数器的激烈冲突。
为什么不能存 null value
ConcurrentHashMap 不允许 null 的 key 和 value。根本原因是并发语义:单线程下 map.get(key) == null 后可以再用 containsKey 区分「key 不存在」和「value 是 null」,但并发下这两次调用之间可能有其他线程插入,歧义无法消除。 Doug Lea 本人的解释就是这个二义性问题,所以干脆禁掉。这一点经常被拿来对比 HashMap 追问。
工程实践上,大数据框架里 ConcurrentHashMap 常用于本地缓存、指标统计这类场景。要注意 size()、clear() 这类全局操作返回的是「某一瞬间」的近似值,不保证强一致,做精确计数要靠它就得小心。如果需求是计数,优先考虑 LongAdder;如果是「不存在才放入」的原子语义,用 putIfAbsent 或 computeIfAbsent,后者在 JDK 8 早期版本里对同一个 key 递归调用会死锁,这个坑在 1.8.0_152 之后才修掉,老版本生产环境要避开。
可能的追问
- JDK 8 为什么放弃分段锁? 段数固定限制了并发度上限,锁粒度大;改成桶级锁后并发度随数组扩容自然增长,配合 CAS 和 volatile 读,整体吞吐更好,代码也更简单。
- synchronized 锁头节点会不会成为瓶颈? 单个桶的竞争才有冲突,配合红黑树和扩容机制,同一个桶被高频并发写的概率很低;而且 JDK 6 之后 synchronized 有锁升级优化,低竞争下开销很小。
- 和 Collections.synchronizedMap 比呢? 后者是在所有方法上加同一把对象锁的包装器,读写互斥;ConcurrentHashMap 读无锁、写锁桶,高并发下完全不是一个量级。
- get 不加锁,怎么保证读到最新值? 靠 volatile 语义:Node 的 val 和 next 是 volatile 修饰,写入有 happens-before 关系,读线程能看到最新的引用和值。