ConcurrentHashMap 是 Java 并发编程里绕不开的一个类,几乎每个做后端、中间件、并发编程的人都会跟它打交道。只要稍微看过一点并发源码,就一定会遇到那个经典问题:JDK 1.7 和 JDK 1.8 的 ConcurrentHashMap 到底有什么区别?网上的结论几乎统一是“1.7 用分段锁,1.8 用 CAS 加 synchronized”,但如果面试官再追问一句“为什么”,很多人就卡住了。
这篇文章我不想罗列干巴巴的差异表,而是把每个差异背后的设计动机、数据结构变化、核心方法实现逻辑都拆开讲。读完之后你能回答的不是“它改了”,而是“它为什么这么改,改完带来了什么收益和代价”。
适合两类人看:一类是刚开始啃 Java 并发源码,想系统搞明白 ConcurrentHashMap 原理的;另一类是准备面试,需要一套有深度、有条理的答案的人。我会把 put、get、size、扩容这些关键点在两个版本里的处理方式都拿出来逐项对比,最后再分享一些我在实际排查问题上踩过的坑。
1. 整体设计思路:从“多把锁管多段”到“一把锁管一个桶”
1.1 1.7 的分段锁结构
先看 1.7 的设计。它的底层是 Segment 数组加 HashEntry 数组的两层结构。Segment 本身继承自 ReentrantLock,也就是说每个 Segment 天生就是一把锁。默认情况下 Segment 数组长度是 16,所以整个 Map 被切成了 16 个独立的“小段”,每个小段内部再维护一张 HashEntry 数组,真正存数据的是 HashEntry 链表。
往容器里写数据时,第一步先通过 hash 定位到具体是哪个 Segment,然后对这个 Segment 加锁。加锁只影响当前这一段,其他 15 段完全不受影响,所以理论上 16 个线程可以同时写不同段,互不干扰。这就是“分段锁”这个名字的由来,也是它对比 HashTable 那种粗粒度全表锁最大的进步。
默认 16 这个数字有讲究。并发量不高的时候,16 个段足够用;段数越多,锁竞争越分散,但内存开销也会变大。1.7 允许通过构造函数传 concurrencyLevel 指定初始段数,但运行过程中段数是固定的,不能动态扩展。这就意味着 1.7 的并发上限在构造时就已经被定死了。
1.2 1.8 的锁粒度大幅缩小
1.8 的结构完全重写了。最直观的变化是去掉了 Segment,底层变成了 Node 数组加链表再加红黑树。锁的粒度从“段”直接降到了“桶”,也就是 Node 数组里的某一个下标位置的元素。
写入时,如果目标桶是空的,就直接用 CAS 把新节点放进去,整个过程无锁。如果目标桶不是空的,就用 synchronized 锁住这个桶的头节点,然后在这个桶内部的链表或红黑树上做插入。锁的持有时间被压缩到“只处理一个桶内的操作”,比 1.7 的“锁住整个 Segment 下的所有桶”范围小得多。一个 64 长度的数组,理论上支持 64 个线程同时写入不同桶,比 16 个段的上限明显更高。
1.3 为什么 1.8 要抛弃 Segment
这里必须说到 ReentrantLock 与 synchronized 在 JDK 层面的变化。1.6 之后 synchronized 做了大量锁优化,引入了偏向锁、轻量级锁、锁粗化、锁消除这些机制。在锁竞争不激烈、临界区很短的情况下,synchronized 的开销可能比显式 ReentrantLock 更小,因为它能借助 JIT 编译期优化做锁升级与降级。1.8 里锁持有时长被压缩到一两个链表节点的操作,属于典型的“短临界区”,这个场景正好是 synchronized 擅长的。
另外还有代码维护层面的考虑。锁粒度从 Segment 降到桶之后,整个 class 结构更简洁,不需要两套哈希定位逻辑,红黑树的引入也使得单个桶内操作从 O(n) 降到 O(log n),高冲突场景下性能更有保障。所以 1.8 不是单纯为了换一种锁的 API,而是在整体结构上配合数据组织方式做了一次彻底重构。
2. 核心数据结构对比:节点类型与链表树化
2.1 HashEntry 和 Node 的差异
1.7 的数据节点叫 HashEntry。它的 key、hash、next 都是 final 的,value 是 volatile 的。next 不可变意味着一个节点一旦被插入,它在链表上的后继关系就不能改,所以移除节点时需要重建前面的链表节点,这也让锁内的链表操作变得更简单可控。
1.8 的节点叫 Node。它的 key、hash 同样是 final,但 val 和 next 都用 volatile 修饰,作用是保证无锁场景下的可见性。Node 本身主要服务于普通链表桶,而当某个桶树化之后,桶里放的就不是普通 Node 了,而是 TreeBin 这个代理对象。
2.2 链表、TreeNode 和 TreeBin 的分工
1.8 的树化是渐进式的,不是某个桶链表稍微长一点就直接转树。每个桶先维持链表结构,当冲突达到一定规模后,链表中的节点会被封装成 TreeNode,再由 TreeBin 统一管理。TreeBin 不直接存业务数据,它负责锁、读写锁协调、树旋转等内部事务。
这里的关键点在查询路径上。普通链表桶查询时,遍历链表逐个比对 hash 和 key;树化桶查询时,先通过头节点判断这是 TreeBin,再走红黑树的查找逻辑。两种结构混用,意味着 1.8 的代码里到处都要判断节点的实际类型。源码里用 hash 值的几个特殊常量来做这种判断,例如 MOVED 表示扩容转发节点、TREEBIN 表示红黑树代理节点,这些标记值都是负数,不会和正常 key 的 hash 冲突。
2.3 ForwardingNode 与扩容状态机
ForwardingNode 是 1.8 引入的一个很关键的角色,它只在扩容期间出现。扩容发生时,原数组里的某个桶如果已经迁移完,桶位置就会放一个 ForwardingNode,它的 hash 固定为 MOVED。任何线程执行 put 或 get 时,只要发现目标桶是 ForwardingNode,就知道扩容正在进行,然后要么协助扩容,要么转到新数组中继续查找。
这种设计让扩容从“独占式”变成了“协作式”,不再需要阻塞整个 Map 的读写。相比 1.7 那种单个线程在一个 Segment 内独自搬数据,1.8 的迁移过程对所有读写线程是透明的。代价是并发逻辑变复杂,但换来的是扩容期间系统依然能持续提供服务,这在长生命周期的高并发系统里意义非常大。
3. 核心操作逐项对比:put、get、size 和扩容
3.1 put 方法:从“二次哈希定位”到“CAS 自旋加锁桶头”
1.7 的 put 流程分两步定位。先对 key 的 hashCode 再做一次扩散哈希,减少哈希冲突;然后通过(hash >>> segmentShift) & segmentMask算出 Segment 下标,再在 Segment 内部用(tab.length - 1) & hash定位 HashEntry 桶。定位到 Segment 后,会先尝试用 tryLock 快速获取锁,获取不到就进入循环等待,直到加锁成功。整个过程是阻塞式的,线程会一直占用 CPU 等待锁,锁持有时间越长,等待成本越高。
1.8 的 put 流程完全不同。它在循环里先判断目标桶是否为空,为空就直接用 CAS 插入,成功就退出循环;不为空且是 ForwardingNode 就帮忙扩容;否则用 synchronized 锁住桶头节点,再在链表或红黑树里查找并插入。如果链表长度超过阈值,会调用 treeifyBin 尝试树化,但这里有个前提条件,数组长度必须大于等于 64,否则会优先去扩容数组而不是直接树化。
下面是 1.8 put 核心逻辑的简化示意,方便理解:
final V putVal(K key, V value, boolean onlyIfAbsent) { if (key == null || value == null) throw new NullPointerException(); int hash = spread(key.hashCode()); int binCount = 0; for (Node<K,V>[] tab = table;;) { int n = tab.length; int i = (n - 1) & hash; Node<K,V> f = tabAt(tab, i); if (f == null) { // 桶空,直接 CAS 插入,无锁 if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null))) break; } else if (f.hash == MOVED) { // 扩容中,协助迁移 tab = helpTransfer(tab, f); } else if (f.hash == TREEBIN) { // 红黑树桶,走树节点插入 synchronized (f) { binCount = 2; // 树节点 putTreeVal } } else { // 普通链表桶,锁住头节点再遍历 synchronized (f) { // 遍历链表,找到则覆盖,找不到则尾插 } } } addCount(1L, binCount); return null; }如果你之前只背过“1.8 是 CAS 加 synchronized”这个结论,可能一直没理解为什么还需要 for 循环和自旋。其实 CAS 插入失败只是说明目标桶被其他线程抢先了,外层循环保证线程重新读取最新数组并再次尝试。这种乐观重试机制是为了减少无谓的锁竞争,只有在桶内已经有节点,确实需要操作链表时才会升级到 synchronized。
3.2 get 方法:两个版本都几乎无锁
get 方法值得单独说一下,因为在两个版本里,读操作都不需要加锁。1.7 的 HashEntry 的 value 和 next 是 volatile 的,所以读线程能拿到最新值。1.8 的 Node 的 val 和 next 同样是 volatile,并且整个 table 数组引用本身也是 volatile 的,这样数组扩容时读线程也能感知到新数组。
1.8 的 get 流程是先通过tabAt(tab, (n - 1) & hash)取出头节点,然后判断头节点 hash 是否小于 0。如果小于 0,说明这个节点可能是 ForwardingNode 或 TreeBin,需要走特殊查找逻辑;否则直接遍历链表。过程中不需要加任何锁,读并发非常高。这里有个容易忽略的细节:volatile 修饰的是数组引用和节点里的字段,但数组每个元素的普通读并不能保证看到最新值,所以源码里用了 Unsafe 的 getObjectVolatile 来读取数组元素,这就保证了元素级别的可见性。
从实战角度说,ConcurrentHashMap 的读路径在 1.8 下非常轻快,这也是为什么很多读多写少的系统敢放心大量使用它,而不必担心锁竞争拖垮吞吐。
3.3 size 方法:从“二次无锁统计”到“分段累加计数”
size() 是两个版本差别很大的一个方法,也是面试高频点。1.7 里统计 size 时先不加锁,对所有 Segment 的 count 字段求和两次。如果两次结果一致,说明统计期间没有写操作发生,直接返回;如果不一致,说明统计过程中有并发写,这时会锁住所有 Segment,再重新统计一遍,保证返回的是精确值。
1.8 的计数方式改成了类似 LongAdder 的思路。Map 内维护一个 baseCount 字段,每次 put 或 remove 成功后调用 addCount 累加。累加时先尝试对 baseCount 做 CAS,如果竞争激烈导致 CAS 失败,就用 CounterCell 数组分散计数。每个线程基于 ThreadLocalRandom 选择一个 CounterCell 槽位,把增量累加到自己的槽上。size() 方法最终把 baseCount 和所有 CounterCell 的值加在一起返回。
这种设计最大的收益是“写并发越高,计数不需要全局锁”,代价是 size() 返回的是一个近似值,并不保证绝对精确。如果你真的需要精确总数,官方也不建议用 size() 做强一致性的业务判断,而是推荐用 mappingCount() 拿 long 类型的总数。
3.4 扩容机制:从“段内独占 rehash”到“多线程协助迁移”
1.7 的扩容时机和 HashMap 类似,当某个 Segment 内的数量超过 threshold 时,这个 Segment 内部的数组会 rehash,容量扩大一倍。扩容过程只锁住当前 Segment,其他 Segment 不受影响,但当前 Segment 内的所有写操作在这期间都会阻塞。
还有一个细节很容易被忽略:1.7 迁移链表时用的是头插法,逐个把旧链表的节点插入到新链表头部,这就造成了节点顺序反转。曾经 HashMap 在并发扩容下出过著名的死循环问题,ConcurrentHashMap 1.7 因为对单个 Segment 加了锁,不会出现同一批节点的并发 rehash,所以那段历史问题在这里没有复现,但头插反转的代码风格维持到了 1.7 的末尾。
1.8 的扩容完全不同。它把迁移任务拆到数组桶一级,多个线程可以同时协助迁移不同的桶。源码里有一个 nextTable 字段,是扩容后的新数组;还有一个 transferIndex,用来记录迁移进度,多个线程各自领取一段区间的桶来搬。迁移完一个桶,就在原位置放一个 ForwardingNode 作为标记。
链表迁移时 1.8 采用了一个优化点:把原链表按hash & n拆分成低位链表和高位链表,整段整体搬到新数组的对应位置,而不是逐个节点重复头插。这样既保留了节点相对顺序,又大大减少了重建链表的时间。源码里的 lastRun 节点利用了链表尾部的连续同组节点直接复用,这个技巧很微妙,也正是 1.8 源码阅读里容易让人眼前一亮的地方。
4. 参数细节与边界行为:树化阈值、负载因子与 null 限制
4.1 树化阈值 8 与退化阈值 6 的设计逻辑
1.8 源码里定义了几个重要常量。TREEIFY_THRESHOLD = 8,表示链表长度达到 8 时尝试树化;UNTREEIFY_THRESHOLD = 6,表示扩容拆分后如果链表长度降到 6 以下,就把红黑树退化成普通链表;MIN_TREEIFY_CAPACITY = 64,表示只有数组长度大于等于 64 时才会真的树化,否则优先扩容。
为什么树化而不是一直用链表?因为当哈希冲突严重时,链表查询是 O(n),红黑树是 O(log n),桶内数据多时后者的优势非常明显。但红黑树的节点体积更大,维护成本更高,所以不能链表稍长就立刻树化,阈值设成 8 是综合了空间开销和查询效率的选择。小于 8 时链表遍历本身很快,树化的收益体现不出来。
退化阈值设成 6 而不是 7 或者 8,是因为要留出缓冲区间。如果退化阈值就是 8,那么插入到 8 转树,删除到 7 转链表,再插入又转树,极端情况下会反复横跳,带来树和链表之间不必要的高频切换开销。设置 6 作为下限,能有效避免这种边界抖动。
4.2 初始容量、负载因子与并发级别的差异
1.7 构造器里的 concurrencyLevel 参数对应的就是 Segment 数组长度,默认是 16。Segment 内部 HashEntry 数组的初始容量也可以单独指定,负载因子的作用依然是控制每个 Segment 内数组的扩容时机。这里的并发度受 Segment 个数限制,也就是最大并发写线程数约等于 Segment 数,实际运行中不能动态调大。
1.8 里构造器仍然保留了 concurrencyLevel 参数,但含义变了。它只作为初始容量估算的一个依据,不再代表锁定单元的个数。1.8 的锁单元是数组桶,数组容量随扩容增长,扩得越大能同时锁住的桶就越多,所以理论上并发写能力是随容量动态扩展的。扩容后桶数量翻倍,并发上限几乎同步翻倍,这是 1.8 和 1.7 在伸缩性上的根本区别。
4.3 为什么 ConcurrentHashMap 不允许 null 键和 null 值
这个问题面试官经常追问。ConcurrentHashMap 的 put 方法里直接抛异常:if (key == null || value == null) throw new NullPointerException()。原因是 get 方法返回 null 时,调用方无法区分“这个 key 不存在”和“这个 key 对应的 value 本身就是 null”。
HashMap 允许 null 值,是因为单线程场景下可以通过 containsKey 再查一次来确认。但 ConcurrentHashMap 是并发容器,如果允许 null 值,那么读线程在没有锁的情况下看到一个 null,就需要额外的同步机制去确认键是否存在,这会显著增加读路径的复杂度,而且 null 在很多并发算法里被当作特殊哨兵使用。与其引入这种歧义,不如直接禁止。很多人会把这一点记成“ConcurrentHashMap 不能存 null,HashMap 能”,但如果能说清楚背后的语义模糊问题,面试层次会完全不同。
5. 常见问题与排查技巧实录
5.1 面试高频追问:1.7 的死循环、迭代器弱一致性、size 的坑
面试官不会只满足于“1.7 分段锁、1.8 CAS 加 synchronized”这种一句话答案。以下几个追问出现的频率非常高。
第一个追问:1.7 的 ConcurrentHashMap 会像 HashMap 那样在并发扩容时造成死循环吗?答案是不会。因为 1.7 对单个 Segment 的 rehash 是加锁的,同一个 Segment 不会同时被两个线程扩容,所以不存在多线程同时操作同一个链表导致环的问题。但 1.7 的性能瓶颈在锁竞争,而不是功能障碍。
第二个追问:ConcurrentHashMap 的迭代器是弱一致还是强一致?两个版本都是弱一致。遍历时迭代器不会加锁,也不会立刻反映当前时刻所有未完成的修改。某个线程在遍历过程中 put 了一个新元素,正在遍历的迭代器不一定能看到。这是为了不阻塞读,而选择的一种折中策略。
第三个追问:size() 到底准不准?1.8 的 size() 和 mappingCount() 都返回统计时刻的近似值,因为计数是分散累加的,相加过程中可能又有新的写操作。如果你需要精确总数来做容量规划,建议在业务低峰期统计,或者直接维护一个自己的计数器。
5.2 实战选型:什么时候该用 ConcurrentHashMap
虽然 ConcurrentHashMap 的名气很大,但也不是万能钥匙。如果你的业务需要按插入顺序遍历元素,或者需要范围查询,它并不适合,这时应该考虑 ConcurrentSkipListMap。如果你的场景是读多写少,ConcurrentHashMap 的读路径几乎无锁,是一个非常合适的选择。如果写并发极高,想要更高的写吞吐,可以评估一下 LongAdder 的计数思想,或者考虑分片多个 ConcurrentHashMap 来进一步分散热点。
还有一个容易踩的坑:不要把 ConcurrentHashMap 当成无限并发安全的“万能容器”。如果业务逻辑本身是“先读后写,再根据读到的旧值决定新值”,单纯靠 Map 的原子操作是不够的,这里推荐用 compute 或 merge 这类原子 API,而不是自己先 get 再 put。很多线上数据覆盖问题,根因都是忽略了复合操作的原子性。
5.3 排查技巧:扩容期 GC 日志与线程栈分析
在实际生产环境里,我见过很多线程全部 Blocked 在 ConcurrentHashMap 的 synchronized 代码块上的情况。用 jstack 看线程栈,如果大量线程停在 putVal 的 synchronized 块里,通常有两种可能:一种是对应桶的链表或红黑树操作异常耗时,比如 key 的 hashCode 实现很差导致 hash 冲突严重;另一种是正在扩容,其他写线程在协助迁移或者等待迁移完成。
如果怀疑是后者,可以观察 GC 日志和线程状态的时间点是否与扩容时间吻合。扩容会消耗一定 CPU,并可能让单次 put 耗时明显抬升。定位到了之后,考虑在业务低峰期预热扩容,或者根据数据量合理设置初始容量,减少扩容发生频率。这里我自己的习惯是,在创建 ConcurrentHashMap 时就根据预估最大数据量除以 0.75 来设定初始容量,这样能减少很多扩容带来的延迟尖刺。
最后再补充一个细节。1.7 的扩容只发生在某个 Segment 内部,所以不同时间点可能不同 Segment 各自扩容,整体表现是局部的;1.8 的扩容却是全表范围的,一旦触发,所有写线程都可能参与协助迁移。因此 1.8 在扩容期间的“惊群效应”反而比 1.7 更明显,但换来的是扩容总时长大幅缩短。这是收益与代价并存的设计,理解了这个,才算真正把 1.7 和 1.8 的差异看通透。