
咱们搞 Java 的尤其是准备面试或者做并发相关的项目几乎都会被问到 ConcurrentHashMap。这个类在 JDK 1.7 和 1.8 里变化之大好比重新写了一个类。理解这两个版本的差异不仅能应付面试更能帮你理解并发编程的核心思想锁到底是怎么一点一点“变细”的无锁操作到底是怎么一点一点“变稳”的。1. 为什么这两代版本差异这么大先说点背景。JDK 1.7 时代的 ConcurrentHashMap 设计思路核心就是“分段锁”。当时的想法很直接一个全局锁比如 Hashtable锁得太多那我只锁一部分哪一部分数据在写我就只锁那一块区域其他区域还能被别的线程访问并发度自然就上去了。所以 1.7 的内部结构是一整个 ConcurrentHashMap 内部有一个 Segment 数组默认长度是 16。每个 Segment 本质上就是一个小的 HashMap它继承自 ReentrantLock自带一把锁。数据先定位到 Segment再定位到 Segment 内部的 HashEntry 数组中的某个桶。这个设计在当时真的很不错但它有个明显的痛点锁的粒度还是太大了。比如某个 Segment 里有 100 个桶那任意两个线程访问这 100 个桶里不同的两个桶还是会产生竞争因为锁都是同一把。并发量上不去尤其在机器核数越来越多之后锁竞争反而成了瓶颈。JDK 1.8 的团队干了件狠事把粒度再切小不搞 Segment 了直接把锁的粒度切到“单个桶”上。一个桶对应数组里一个槽位谁要操作这个桶就 lock 这个桶的头节点head。这还不够8 里面大量用 CAS 无锁操作桶是空的我直接用 CAS 把新节点放进去连锁都不用加读操作更彻底完全无锁。所以两代版本的本质差异一句话可以概括从“每 16 个桶共用一把锁锁 16 次”变成了“CAS 抢空桶 锁单桶”并发度直接提升一个量级。下面我拆开讲把每个关键细节都拿出来对比一遍不止说区别还要说为什么这么改以及实际编码中这些问题对应哪些坑。2. 4 个关键维度区别对比为了先有个全局印象我把最核心的差异列成一张速查表下文再逐个深入。对比维度JDK 1.7 ConcurrentHashMapJDK 1.8 ConcurrentHashMap底层数据结构Segment 数组 HashEntry 数组 链表Node 数组 链表 红黑树锁粒度锁一个 Segment继承 ReentrantLock锁桶头节点synchronized CAS 无锁操作定位逻辑两次 hash先定位 Segment再定位 HashEntry一次 hash直接定位到 Node 数组下标初始化时机构造时创建 Segments第一次 put 时懒加载初始化读操作无锁读无锁读size() 实现先无锁统计两次若变动则每个 Segment 加锁统计baseCount CounterCell 数组累加无锁统计扩容机制Segment 内部 rehash只锁当前 Segment多线程协助迁移切割任务区间通过 ForwardingNode 衔接KV 是否允许 nullkey/value 均不可为 nullkey/value 均不可为 null这个表看起来简单但每个点背后都有很深的门道。接下来我逐个拆解。3. 底层结构与定位逻辑两代设计的骨架3.1 1.7 的结构Segment 数组套 HashEntry 数组1.7 的存储结构用两句话能说清楚外面是一个 Segment 数组Segment 里面又是一个 HashEntry 数组HashEntry 本身是链表的节点。我写一个简化版的结构说明方便你对照源码最外层ConcurrentHashMapSegmentK,V[] segments每个 Segment 内部维护HashEntryK,V[] tableSegment继承了ReentrantLock所以它自带加锁能力。HashEntry节点final K keyvolatile V valuevolatile HashEntryK,V nextfinal int hash这里有一个容易被忽视的细节value和next都是 volatile。这是 1.7 能支持无锁读的关键所在——读的时候不用加锁因为线程 A 改了 value线程 B 能立刻通过 volatile 语义看到最新值而不会有可见性问题。1.7 进行 put 时要定位两次根据 key 的 hash 值高位与 segments 数组长度 - 1 求与定位到某个 Segment。在 Segment 内部再用 hash 值低位与HashEntry[] table.length - 1求与定位到具体桶。这两次定位让数据分散到不同的 Segment实现“逻辑上是并发安全的物理上锁的是不同区域”。3.2 1.8 的结构Node 数组 红黑树1.8 去掉了 Segment结构改成一个volatile NodeK,V[] table就一个数组数组中每个元素是一个桶的头节点。桶里面可能是链表当链表长度超过 8且数组长度超过 64时转换成红黑树。这个树结构由TreeBin作为桶的头节点保存。节点类型有几种普通Node、TreeBin、ForwardingNode扩容时的过渡节点、ReservationNode占位节点。1.8 做 put 时只定位一次hash 值对(n - 1) hash定位数组下标。如果这个下标位置是空的直接用 CAS 放进去。如果这个下标位置有节点用 synchronized 锁住这个头节点然后往链表/树里插入。这个结构变化带来了一个巨大的优势锁的粒度从“Segment 内 16 个桶共用一把锁”变成了“每个桶一把锁锁头节点”。如果大量线程在访问不同的桶那么它们之间完全没有竞争这就能和 CPU 核心数很好地匹配。并发 16 个线程和并发 64 个线程在 1.8 里获得的吞吐量不是线性上升而是可能接近线性上升如果哈希分布均匀。顺带说一句很多人以为 1.8 用 synchronized 是“退化了”比不上 ReentrantLock。其实不然。Java 的 synchronized 在 JDK 1.6 之后做了大量偏向锁、轻量级锁的优化而且 JVM 可以对其进行锁消除和锁粗化等编译期优化此外桶的竞争通常很短用 synchronized 反而减少内存开销和上下文切换。这也是很多并发专家后来推荐在低竞争场景优先用 synchronized 的原因。4. put 操作全对比从两次加锁到 CAS 抢入这是面试最容易追问的部分也是工作中最容易踩坑的细节。4.1 1.7 的 put 流程1.7 的 put 操作大体流程如下对 key 的 hash 再次进行扰动求出用于定位 Segment 的 hash。定位到 Segment如果该 Segment 为 null先创建并初始化这一步也有并发控制但有额外的调用开销。尝试获取 Segment 的锁tryLock()如果失败就进入scanAndLockForPut会循环尝试拿到锁同时还会顺便遍历链表来预创建节点减少持锁时间。在 Segment 内部定位到具体桶遍历链表找到了 key 相同equals的节点覆盖value。没找到把新节点插入链表头部头插法。如果当前 Segment 内的HashEntry[]长度超过阈值进行扩容Segment 内部的 rehash。写入完成后释放锁。这里注意一下1.7 是头插法插入新节点这会导致在新节点插入时如果有其他线程在读读线程可能看到的是旧链表的中间状态但因为有 volatile 的 next 和 value当节点插入成功后读线程最终能看到完整的新链表。4.2 1.8 的 put 流程1.8 的 put 流程在源码里putVal方法中几个关键分支数组为空调用initTable()初始化。初始化也有竞争但通过sizeCtl这个字段的 CAS 操作来控制哪个线程 CAS 成功哪个线程负责建表其他线程自旋让出。定位桶 i如果table[i]为 null调用casTabAt尝试直接放置节点。这里用到的是Unsafe.getAcquire和Unsafe.compareAndSetObject一类操作。成功则 put 结束失败则说明有竞争进入下一步。如果table[i].hash MOVED说明这个桶正在被扩容迁移当前线程不会阻塞而是调用helpTransfer()一起去帮忙扩容。这一点非常妙别的容器碰到扩容其他线程只能干等1.8 的 ConcurrentHashMap 让其他线程也参与搬迁化竞争为协作。其他情况就synchronized (table[i])锁住这个桶的头节点。然后进去遍历链表/树找到了相同 key 的节点就替换 value没找到就尾插法新增一个节点注意1.8 改成了尾插法。如果这是链表且节点数达到TREEIFY_THRESHOLD 8并且数组长度 64就把链表转换成红黑树。从两次定位变成一次定位从必须加锁变成空桶 CAS 无锁从锁 Segment 变成锁 Node 头节点这三件事是 1.8 put 的核心竞争力。4.3 为什么 1.8 用尾插法这里值得多说一句1.8 改成尾插法不是随便改的。1.7 头插法在扩容时可能出现“环形链表”的严重问题虽然 ConcurrentHashMap 通过持锁来避免多线程同时扩容一个 Segment但头插法在并发迁移时仍然比较容易出错而 HashMap 在 1.7 因为头插法在并发 put 扩容时造成死循环已经是个著名的坑。1.8 的 ConcurrentHashMap 采用尾插法保证链表顺序在新旧数组迁移过程中不会发生反转配合多线程协助迁移就不会形成环。虽然 ConcurrentHashMap 不会像 HashMap 那样直接出现死循环因为有锁但 1.8 采用尾插法主要是为了配合多线程分段迁移的安全性从设计上来讲更稳健。5. size() 方法从加锁统计到无锁统计size()也是面试高频问题越是看似简单的方法越体现出设计功力。5.1 1.7 的 size 实现1.7 的做法是第一次先不加锁尝试统计所有 Segment 的 count 总和并记录每一次统计后得到的modCount。如果前后两次统计的modCount完全一致说明没有写操作发生这个 size 就是准确的。如果不一致就说明统计过程中有写操作于是退化为对每个 Segment 都加锁然后重新统计一次最后释放所有锁。这个方案能保证最终一致性但代价是一旦存在并发写size() 会锁所有 Segment这在频繁写操作时代价非常高。5.2 1.8 的 size 实现1.8 引入了两个核心字段baseCount一个 long 型的基础计数。CounterCell[] counterCells一个数组用来分散并发计数时的竞争。每次put成功新增元素都会调用addCount(1L, binCount)先尝试 CAS 更新baseCount。如果 CAS 失败竞争激烈就随机选一个CounterCell对象然后 CAS 更新这个 cell 的 value。sumCount()方法在统计时不需要加锁直接读取baseCount以及所有CounterCell的 value累加即可。这个思路其实和 LongAdder 一脉相承事实上CounterCell的代码几乎就是 LongAdder 里的 Cell 的翻版。它的好处是读多写少和写多场景都能 scale统计 size 时完全不阻塞写操作。所以 1.8 的 size() 准确度其实也不是“强一致”但足够满足大多数业务需求。要注意如果一边写一边调用 size()严格来说你不能拿到一个精确的“某个瞬间 size”但拿到的值基本反映了当前规模这个“弱一致性”是 ConcurrentHashMap 一贯的设计哲学。6. 扩容机制从锁一个 Segment 到多线程协作迁移扩容是我个人觉得 1.8 最有亮点的地方也是“1.7 和 1.8 之间差别最大”的另一个角落。6.1 1.7 扩容怎么做的1.7 扩容发生在 Segment 内部。当某个 Segment 中的 HashEntry 数组长度超过阈值就会对这个 Segment 的数组进行一次扩容扩容成原来的两倍。关键点在于这个 rehash 只锁当前 Segment其他 Segment 是自由的。也就是说1.7 的扩容是局部扩容不涉及全表全局并发度不会被扩容拖垮。但一个 Segment 扩容时这个 Segment 里的所有桶都会阻塞即并发粒度还是 Segment 级别。6.2 1.8 扩容怎么做的1.8 是“全表扩容 多线程协作迁移”整个过程分几个阶段触发某个桶的元素个数超过阈值比如 0.75 * 总长度调用transfer()开始扩容。创建新表nextTable被创建长度为原来两倍。分配任务区间把老表的桶划分成一个个任务区间每个任务区间由transferIndex这个字段维护线程通过 CAS 争夺一段区间。比如老表长度是 64每个线程可以领 16 个桶的区间一个线程迁移完 16 个桶再去领下一段。迁移单个桶对一个桶进行迁移时会把这个桶的链表或树进行处理把节点按照 (hash n) 分成“留在低位桶”和“去高位桶”两组然后放到nextTable的对应位置。迁移完该桶后在老表那个位置放一个ForwardingNode它的 hash 是MOVED (-1)。协作其他线程如果此时要访问这个桶的 key发现桶头节点是ForwardingNode就知道老桶已经搬走了于是通过helpTransfer()加入扩容过程或者直接去新表继续操作。这套机制的精妙之处在于迁移过程不需要锁住整个表只需要锁住正在迁移的那个桶。迁移完成的桶能被立刻使用其他线程可以越过 ForwardingNode 直接访问新表。扩容期间读线程不需要阻塞。读到 ForwardingNode 时继续到新表里查读到还没迁移的桶直接写老桶就行。这比 1.7 优雅太多了。在 1.7 中如果一个 Segment 扩容这个 Segment 内的读写全部要等重新哈希完成在 1.8 中扩容过程和普通读写可以交错进行系统整体几乎无感知。6.3 迁移时的线程安全问题迁移一个桶时要确保两点迁移过程中其他线程不会往老桶写数据。其他线程能正确判断桶是否已迁移。这个通过synchronized锁住老桶头节点实现。迁移线程锁住老桶头节点之后其他线程要想操作这个桶也得先抢把这个头节点的锁抢不到就在那里等着。等迁移完成头节点被替换成 ForwardingNode其他线程再抢锁时会发现锁的对象已经变了于是重新循环最终定位到新表。这种“锁对象替换”是扩容不会造成死锁的关键。读线程不需要锁。它读老桶时看到头节点 hash 值是 MOVED就会顺着ForwardingNode.nextTable到新表里去继续找看到普通节点就直接读。因为Node的next字段是 volatile迁移过程中链表已经被完整重组读不会读到中间状态的脏数据。7. 读操作的正确理解两代版本读操作都是无锁。这里我着重讲一下为什么无锁读是安全的、以及它的边界在哪。在 1.7 里读线程定位到某个 Segment 里的某个 HashEntry然后遍历链表。因为value是 volatile读线程总是能读到最新写入的 value。但这不意味着读操作能读到“最新一致状态”它只是“能读到某次已经写入成功的值”。在 1.8 里读线程遍历 Node 链表同样因为Node的value和next是 volatile不会出现读到半初始化节点的情况。而且Node构造完成后其 key 是 final所以 key 固定不可变后续变更只有 value不会出现 key 不一致的现象。但要理解的重要边界是ConcurrentHashMap 的读是弱一致的。具体表现线程 A 调用get(k)可能读不到线程 B 刚刚put(k,v)的值因为 B 可能还没完成 CAS 写入或者 A 已经拿到了某个旧桶头的旧链表。迭代器遍历时不会抛ConcurrentModificationException但不保证能遍历到遍历过程中新加入的元素也不保证能把遍历前已存在的所有元素全部遍历到位如果你边遍历边删除可能漏掉或重复遇到某些节点。这个弱一致性对大多数应用比如本地缓存、统计计数器完全够用但如果你的业务要求“上一次 put 必须立刻在下一次 get 中被看到”或者要求“遍历期间必须是一致快照”那 ConcurrentHashMap 并不适合需要加外置锁或改用其他机制。8. 常见问题与排查实战这里我分享几个我在实际项目和面试中遇到过的真实问题帮助大家少踩坑。8.1 ConcurrentHashMap 的 key/value 为什么不能为 null这是个经典问题。答案不是“不能存”而是设计上刻意不支持。原因在于并发语义的歧义如果map.get(key)返回 null该怎么判断是 key 不存在还是 key 对应的 value 就是 null在非并发场景比如 HashMap你可以用containsKey再确认一次但在并发场景这两个操作之间可能又插入了一次 put导致判断失效。ConcurrentHashMap 的作者 Doug Lea 在并发场景下避免这种二义性所以直接禁止 null key 和 null value一旦存入就抛NullPointerException。实际开发中如果确实需要用 null 表达“不存在”建议用containsKey先判断但要注意它同样是弱一致的。更稳妥的方式是用Optional包装 value或者约束业务层不允许 null value 出现。8.2 put 之后立刻 get 不到这是典型的弱一致性“坑”。比如线程 A 做了map.put(k, v)线程 B 紧接着map.get(k)返回了 null。很多同事第一次碰到会以为出了并发 bug其实不是。原因通常是A 的 put 是 CAS 写到新桶B 的 get 在此之前已经定位到了老数组位置比如扩容刚发生B 还没读到 ForwardingNode。或者 A 的 put 还在持锁插入链表中B 读的是旧链表的尾部恰好还没遍历到新插入节点。解决办法如果你需要“写后立即读”的强语义需要在业务代码中加同步机制比如用CountDownLatch或者在有状态变更时加一个全局锁或者换支持强一致的方案例如数据库或使用锁保护的读操作。好消息是在绝大多数场景下这种“写后立即读”的极端时序并不会出现因为 JMM 里一旦 put 操作返回通常会伴随着其他同步操作比如线程退出、传递 volatile 字段这些都会触发 happens-before让 get 看到值。8.3 复合操作不是原子的这是很多面试者容易回答错的地方。ConcurrentHashMap对单个操作get、put、remove、containsKey是线程安全的但对于多个操作的组合它并不保证原子性。举个例子if (!map.containsKey(key)) { map.put(key, value); }这段代码放到并发环境里完全可能两个线程同时判断 key 不存在然后一起 put导致 value 被覆盖。虽然最终 map 里只有一个值但业务层的逻辑已经被破坏了。正确姿势是用 1.8 提供的新接口putIfAbsent(key, value)只有当 key 不存在时才插入返回旧值。computeIfAbsent(key, mappingFunction)当 key 不存在时用函数计算一个新值插入这个函数执行期间会锁住桶头保证同一 key 的计算只发生一次。compute/merge允许以原子方式基于当前值做计算。我见过很多老项目仍用 1.7 时代的写法来处理“没有就插入”的逻辑改一行代码就有质的提升。8.4 遍历时修改的不一致如果遍历 ConcurrentHashMap 的过程中其他线程在增删元素你可能会遇到遍历已经遍历过的桶时新加元素恰好加在那个桶的链表尾部遍历器看不到它而如果新元素加在还没遍历到的桶里遍历器可能会看到它。这其实也是弱一致性的表现。如果你需要遍历期间得到一个相对一致的快照有几个思路先用Map copy new HashMap(map)做一次拷贝但拷贝本身也是弱一致的不过大小较小然后遍历 copy。如果数据量很大考虑用entrySet().stream().collect(Collectors.toMap(...))结合并行但同样不保证强一致。如果业务要求绝对一致的遍历建议换用Collections.synchronizedMap或在线程内自行加锁。实际项目中上述场景最常见的场景是“定时任务扫描一遍 map 清理过期数据”此时弱一致完全可以接受只要我们在清理数据时用remove(key, value)带 value 版本来避免并发覆盖即可。8.5remove(key, value)与replace(key, oldValue, newValue)这两个方法在 1.8 中有条件版本即只有当期望的 oldValue 和当前值相等时才会执行删除/替换类似 CAS。并发环境下非常实用。比如缓存清理时// 只有 value 仍然是 expected 时才能删掉避免把别的线程刚改的新值误删 map.remove(key, expectedValue);这个方法底层会锁桶头检查节点值是否匹配再删除能有效防止“读-改-写”丢失更新。9. 面试怎么回答最出彩如果面试官问“ConcurrentHashMap 1.7 和 1.8 的区别”你可以按三个层次回答既显深度又显高度。第一层直接讲结构区别1.7 是 Segment继承 ReentrantLock HashEntry锁粒度是 Segment。1.8 是 Node 数组 链表/红黑树锁粒度是桶头节点大量使用 CAS。1.8 定位一次 hash1.7 两次 hash。第二层讲影响并发度变化1.7 默认 16 个 Segment 也就是最多 16 个写线程1.8 可以同时支持 N 个不同桶的写线程实际并发度取决于桶的数量和哈希质量。写性能变化1.8 在空桶场景下无锁插入写写竞争显著减少。扩容性能变化1.7 扩容锁整个 Segment1.8 多线程协作搬迁扩容不阻塞整个表。存储结构变化1.8 引入红黑树解决哈希冲突严重时链表查询 O(n) 退化为 O(log n) 的问题。第三层讲原理和边界把 CAS 不用于所有场景讲清楚链表尾插或树节点变更时仍需要锁因为不能只靠 CAS 维护链表一致顺序。讲弱一致性主动说明 ConcurrentHashMap 的迭代器和 get 不保证立即读到最新值。讲复合操作不原子以及computeIfAbsent的适用场景。一个合格的加分回答是顺带讲一下ForwardingNode的 hash 值是 -1TREEBIN 是 -2以及为什么看到 MOVED 就要继续去 nextTable 找。面试官往往喜欢再追加提问既然 1.8 的并发度更高那还有必要用 1.7 吗答案是没有必要。如果你的 JDK 还支持 1.8 及以上就无脑用 1.8它的设计更先进甚至可以替代一部分 ConcurrentSkipListMap 的场景当然有序容器另说。如果受限于老版本 JDK 只能用 1.7那么要特别注意不要用 ConcurrentHashMap 作为强一致缓存同时避免在遍历中更新业务逻辑因为它不是为“强一致”设计的。10. 实际项目中我的一些体会我在项目中真正体会到 1.8 的提升是在一个高频读写的本地缓存场景。数据量大概几万条写线程大约 20 个读线程 60 个。JDK 7 下跑一阵子监控里能看到频繁的 Segment 锁竞争CPU 使用率也不算平滑大概在 65% 到 80% 之间跳动。切到 JDK 8 后相同的业务代码几乎不动CPU 使用率稳定了不少吞吐也随并发线性上涨。原因是大部分时间操作的是不同桶根本不需要抢锁偶尔某个热桶有并发写也只是锁那个桶其他桶完全不受影响。再有就是当时踩过一个computeIfAbsent的坑。某次写了一个递归调用computeIfAbsent来填充目录树缓存结果同一线程在函数里又对同一个 key 发起 computeIfAbsent触发了IllegalStateException: Recursive update。原因就是computeIfAbsent在映射函数执行期间对这个桶加了锁函数内如果再去操作同一个 key 就会死锁所以 JDK 用异常来保护你。实际编码时要特别小心不要在 mappingFunction 里再次调用当前 map 的操作尤其是同一个 key。还有一个经验用 ConcurrentHashMap 做缓存时最好用compute代替“先 get 后 put”一方面避免中间状态另一方面代码更简洁。比如做计数统计时map.compute(key, (k, v) - v null ? 1L : v 1L);这一行就保证了多个线程同时计同一个 key 时最终结果是精确加了多少次而不是丢失更新。如果你手头还在维护老代码特别是 JDK 7 的项目看到有人用new ConcurrentHashMap(initialCapacity)以为容量就是这么多其实在 1.7 里这个容量会向上取整到 2 的幂然后除以默认并发级别 16 得到每个 Segment 的初始容量。这也会导致某些场景下容量“虚高”白白浪费内存。在 1.8 中initialCapacity会被直接用来计算 Node 数组长度行为更直观但依然会尝试补到 2 的幂。如果你真的为了省内存需要明确知道哈希桶数量的计算公式。我的最终建议是理解这些差异不是为了去背面试答案而是为了在写并发代码时知道哪些问题这个容器能帮你扛哪些问题它扛不了。能用好putIfAbsent、compute这类原子方法比单纯切换一个 JDK 版本更关键。毕竟真正的并发 bug通常不出在 ConcurrentHashMap 本身而出在把若干个简单操作拼在一起却以为它们是原子的。