1. 一句话讲清 1.7 和 1.8 的核心差异
这题基本是 Java 并发方向的必考题,不管是校招还是社招,只要聊到 ConcurrentHashMap,大概率会被追问一句"1.7 和 1.8 之间有哪些区别"。说实话,我早几年带新人时,很多同学能把结论背得滚瓜烂熟——一个是分段锁,一个是 CAS 加 synchronized——但真问到"为什么 1.8 要推倒重来""扩容时别的线程怎么帮忙""size() 到底准不准",就答不上来了。这篇就把整个演进过程拆开,从数据结构、锁粒度、put 流程、扩容机制到计数方案全部过一遍,适合正在准备面试的人,也适合业务代码里已经在用 ConcurrentHashMap 但想搞清楚内部原理的开发者。
1.1 一张表看差异总览
先把最重要的差异列出来,后面每一条都会展开讲。
| 维度 | JDK 1.7 | JDK 1.8 |
|---|---|---|
| 数据结构 | Segment 数组 + HashEntry 数组 + 链表 | Node 数组 + 链表 + 红黑树 |
| 锁粒度 | 按 Segment 分段加锁(ReentrantLock) | 对桶头节点加锁(synchronized) |
| 并发上限 | 受 concurrencyLevel 限制,默认 16 段 | 理论上不同的桶可并行,无固定上限 |
| 读操作 | 无锁 volatile 读 | 无锁 volatile 读,遇迁移节点会跳到新表 |
| 扩容 | Segment 内部局部扩容 | 全表扩容,多线程协助迁移 |
| 计数 | 各 Segment count 求和 + modCount 校验 | baseCount + CounterCell 数组分摊 |
| 红黑树 | 没有 | 链表长度达到 8 且数组长度达到 64 时树化 |
1.2 为什么 1.8 要重写这套实现
1.7 的设计在当年不算差,Segment 继承 ReentrantLock,把一个全局锁拆成默认 16 把锁,已经比 HashTable 那种整表锁强很多。但问题在于:锁粒度还是太粗。一个 Segment 里面可能挂着很多个桶,只要两个线程操作的是同一个 Segment 里的不同桶,依然要排队。如果 hash 分布不均匀,某些 Segment 数据特别多,并发瓶颈就会非常明显。
另外就是 JDK 6 之后对 synchronized 做了一系列重量级优化,引入了偏向锁、轻量级锁、锁消除、锁粗化。synchronized 不再是"性能差"的代名词,在不那么激烈的竞争下,它的性能甚至不输 ReentrantLock,而且不需要手动释放锁、不容易因为异常导致死锁。再加上 JDK 8 想解决"大量 key 撞到同一个桶导致链表过长"的问题,干脆引入了红黑树。几件事凑到一起,ConcurrentHashMap 在 1.8 里等于是把整个并发模型换掉了。
2. 数据结构对比:连环锁 vs 精细锁
这一段是理解后面所有行为的基础,建议先把"长什么样"刻在脑子里。
2.1 1.7 的连环结构
1.7 的结构可以拆成三层:最外层是一个 Segment 数组,每个 Segment 是一个继承了 ReentrantLock 的对象;第二层是每个 Segment 内部维护一个 HashEntry 数组;第三层是 HashEntry 数组的每个桶位后面挂着的链表。
定位一个 key 需要两次哈希:先用 key 的 hash 值的高位计算出它在哪个 Segment,再在这个 Segment 内部定位到具体桶位。如果把 Segment 比作一栋楼的楼层,HashEntry 数组就是每一层里的房间,同一层里的房间如果 hash 冲突,就顺着链表往下找。
默认的 concurrencyLevel 是 16,也就是说 ConcurrentHashMap 在最理想的情况下,同一时刻最多只有 16 个线程在并发写。注意,不是"最多 16 个桶能并行写",而是"最多 16 个段能并行写",每个段内部的写操作依然要抢同一把锁。这就是 1.7 最明显的天花板。
2.2 1.8 的轻量结构
1.8 去掉了 Segment,外层只有一个 Node 数组。Node 是一个普通的链表节点,它的 next 和 value 字段都是 volatile 修饰的,数组的每个槽位在读写时也会借助 Unsafe 的 volatile 语义来保证可见性。
定位一个 key 现在只需要一次哈希:spread(key.hashCode()),然后(n - 1) & hash找到桶位。桶位上的首节点如果是 null,就尝试用 CAS 直接写入;如果已经有人占了,就对这个首节点加 synchronized 锁,然后做链表插入或者更新。锁的范围从"一个段"缩小到了"一个桶头",两个线程只要操作的不是同一个桶,完全可以并行。
当链表长度越来越长,达到 8 且数组长度不小于 64 时,这个桶会从链表结构转换成红黑树。红黑树的根节点被包在一个叫 TreeBin 的对象里,写操作锁的是 TreeBin 的头,读操作则依赖 TreeBin 内部的读写状态来做协调。
2.3 树化阈值为什么是 8,退化阈值为什么是 6
这是面试官特别爱追问的细节。8 这个数字不是拍脑袋定的,它来自泊松分布的计算。假设 hash 足够随机、散列足够均匀,在负载因子是 0.75 的情况下,一个桶里链表长度达到 8 的概率大约是千万分之六,这个概率低到基本可以认为不会出现。所以正常情况下红黑树根本不会被触发,只有当 hashCode 写得很烂、大量 key 撞到同一个桶时,树化才会替补出场,避免查询性能从 O(1) 退化到 O(n)。
退化阈值选 6 而不是 8,是为了留出缓冲区间。如果链表长度到了 8 就树化、低于 8 就马上变回链表,那在阈值附近反复增删元素时,结构会在链表和树之间疯狂切换,带来无谓的重建开销。8 和 6 之间差两档,就是为了防止这种震荡。
3. put/get/remove 流程差异:从抢段锁到 CAS 抢头条
理解了结构,再看具体操作就顺了。这里我会贴一些关键代码片段,但不是让你背源码,而是帮你看懂流程里每一步在干什么。
3.1 1.7 的 put 是怎么抢锁的
1.7 的 put 大概分三步:先根据 key 的 hash 找到 Segment,然后调用 Segment 的 put 方法,在这个方法里先尝试tryLock(),拿不到锁就进入scanAndLockForPut,用有限次数的自旋去等锁,实在等不到就调用lock()阻塞。
final V put(K key, int hash, V value, boolean onlyIfAbsent) { HashEntry<K,V> node = tryLock() ? null : scanAndLockForPut(key, hash, value); V oldValue; try { // 此时已经持锁,可以做遍历、替换、插入 } finally { unlock(); } return oldValue; }scanAndLockForPut这个细节值得注意。为什么不自旋到底?因为多核 CPU 下无脑自旋会白白消耗 CPU,而直接阻塞又可能因为锁很快被释放而造成线程切换开销。所以实现里做了一个折中:先自旋一段时间,同时利用这段等待时间把要插入的节点预先创建好,如果真的需要锁在别的线程手里,再去真正阻塞。
这种写法在 1.8 里看不到了,因为 1.8 的 put 压根不需要抢段锁,它只需要用 CAS 去抢一个空桶位,或者用 synchronized 锁住一个非空桶位。
3.2 1.8 的 putVal 是怎么抢头条的
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()); for (Node<K,V>[] tab = table;;) { Node<K,V> f; int n, i, fh; if (tab == null || (n = tab.length) == 0) tab = initTable(); else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) { if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null))) break; } else if ((fh = f.hash) == MOVED) tab = helpTransfer(tab, f); else { V oldVal = null; synchronized (f) { // 校验头节点没变,然后遍历链表插入/更新 } if (binCount != 0) { if (binCount >= TREEIFY_THRESHOLD) treeifyBin(tab, i); break; } } } addCount(1L, binCount); return null; }这段代码里最有意思的是"抢头条"设计:如果桶位是空的,直接用 CAS 把新节点放进去,一次成功,连锁都不用;如果桶位不空,才 synchronized 锁住头节点,在锁内做链表遍历。为什么用 synchronized 而不是 ReentrantLock?除了前面说的锁优化之外,还有一个很现实的原因:synchronized 在进入和退出时不需要手动管理,代码更不容易出错。锁的对象是桶头节点,如果链表很长,整个链表的插入串行化也没办法,但正常情况下链表很短,锁持有时间极短,竞争自然就小。
3.3 get 为什么全程无锁
不管 1.7 还是 1.8,get 都是无锁的。秘密在于 volatile。1.7 里 HashEntry 的 value 和 next 都是 volatile,1.8 里 Node 的 value 和 next 也是 volatile。一个线程写入并解锁后,另一个线程通过 volatile 读一定能看到最新值,这就是 happens-before 规则的功劳。
1.8 的 get 还多了一个细节:如果读的过程中发现桶头节点的 hash 是 MOVED(说明这个桶正在被迁移),get 会顺着 ForwardingNode 里的 nextTable 引用,跳到新的哈希表继续查。也就是说,扩容过程中读线程不会读到"查不到"的假结果,要么在新表里找到,要么在新表里也确认不存在。
这里要提醒一句:ConcurrentHashMap 的读是无锁的,但它是弱一致性的。你在读的同时有线程在写,你读到的是某一时刻的一致性快照,可能是旧值,也可能漏掉刚写进去的新值。这不算 bug,是性能和一致性之间的取舍。
3.4 remove 在 1.8 里怎么做
remove 和 put 类似,先定位到桶,如果桶是空的就返回 null;如果桶头是 ForwardingNode,就协助扩容;否则锁住桶头节点,在锁内校验桶头没有变化,然后沿着链表找到目标节点,把前驱节点的 next 指向目标节点的 next,完成删除。删除后如果桶是红黑树,就走 TreeBin 的删除逻辑,树节点数降到 6 以下可能会退化回链表。
我记得有个同事在排查线上问题时问过:删除操作锁住头节点,会不会把读也堵住?不会。读走的是 Node 的 volatile 链,不需要锁;写之间互相阻塞,是因为如果不阻塞,两个线程同时改链表指针,很容易把链表搞断。读和写之间靠 volatile 保证可见性,但读到的可能是删除前或删除后的状态,这属于弱一致性的正常表现。
4. 扩容机制:从段内翻倍到全表协作迁移
扩容是 1.7 和 1.8 差异最大、也最能体现并发设计功底的一块。面试时如果能把扩容讲清楚,基本就能证明你是真懂 ConcurrentHashMap 而不是背答案。
4.1 1.7 的 rehash 和它的局限
1.7 的扩容发生在 Segment 内部。当一个 Segment 里的元素个数超过 threshold(初始容量乘负载因子),就把这个 Segment 里的 HashEntry 数组扩大成原来的两倍,然后在这个 Segment 的锁保护下,把旧数组里的元素重新哈希到新数组。
这样做的好处是:扩容不会影响其他 Segment,其他段的读写完全不受干扰。坏处也很明显:扩容操作只能由持锁线程一个人干,如果这个 Segment 里的数据特别多,迁移就会很慢,而这个段内的所有写操作都会卡在锁上。段间不平衡的问题会让某些"热点段"频繁扩容,成为瓶颈。
4.2 1.8 的多线程协助迁移
1.8 的扩容是对整个 table 进行的。触发时机有两个:一是 put 之后 addCount 发现元素总数超过阈值,二是链表太长但数组长度不足 64 时,treeifyBin 会改成触发扩容而不是直接树化。
扩容的核心是 transfer 方法,它有几个关键设计:
- sizeCtl 这个字段承担了多重身份。等于 -1 表示正在初始化;负数且不是 -1 时表示有扩容在进行,值的低位部分和参与扩容的线程数相关;正数表示下一次扩容的阈值。
- 迁移时把整个数组按
stride划分成若干连续区间,每个线程抢一段区间来迁移,迁完再抢下一段。stride 的计算和 CPU 核数有关,多核机器上一般是n >>> 3 / NCPU,最小不低于 16,这是为了避免区间太小导致频繁竞争。 - 每个桶迁移完成后,会在旧数组的那个位置放一个 ForwardingNode,它的 hash 固定为 MOVED,并且持有 nextTable 的引用。后续任何线程通过这个桶读写时,都会认出这个标记。
- 迁移过程中还有个叫 lastRun 的优化:遍历链表时,找到最后一个 next 链指向的节点 hash 全部一致的子链,可以直接整段搬走,不用一个节点一个节点地重新计算。
一个线程在 put 或者其它操作时发现桶头是 ForwardingNode,不会傻等,而是马上调用 helpTransfer 加入迁移,大家一起搬,搬完再尝试自己的写操作。这种"遇到迁移就帮忙"的设计,极大缩短了大表扩容时的阻塞时间。
4.3 迁移中的读写如何确保不丢数据
迁移期间其他线程的读写会不会读到一半的数据?这个问题得分情况说:写线程发现 ForwardingNode,就直接参与扩容,写操作会等到这个桶迁完、在新表里继续;读线程发现 ForwardingNode,就跳到 nextTable 去读,也不会丢数据。因为数组扩容都是翻倍,新表是旧表的 2 倍大小,一个 key 在旧表里的桶位和在新表里的桶位存在确定的映射关系,所以迁移过程中 "旧表找不到就去新表找" 是安全的。至于每个桶内部,迁移本身是在锁或 CAS 的保护下完成的,迁移完成的桶才能放 ForwardingNode,所以不存在读到半个桶的情况。
我在实际项目里观察过 1.8 扩容的表现:一个几百万元素的 Map 触发扩容,由于多个请求线程都会参与搬运,整体停顿明显比 1.7 短。当然,如果扩容期间完全没有并发请求,只有 put 线程自己在搬,那耗时还是差不多的,毕竟数据量摆在那。
5. 计数方案:size() 到底怎么算出来的
ConcurrentHashMap 的 size() 从来都不是实时精确值,这一点要反复强调。1.7 和 1.8 对计数的处理思路完全不一样。
5.1 1.7 的二次快照校验
1.7 的每个 Segment 内部维护一个 count 字段记录这个段里的元素个数。size() 的做法是:先不加锁地遍历所有 Segment,把 count 累加一遍,同时记录每个段的 modCount(修改次数)总和;然后立刻再遍历一遍,如果两次 modCount 总和一致,说明遍历过程中没有写操作发生,这次和就是可信的。
如果两次不一致,说明有并发写,那就把所有 Segment 的锁都锁上,再重新算一遍。RETRIES_BEFORE_LOCK 这个阈值是 2,也就是说最多尝试两次不加锁的快照,还不成功就上全锁。
这个方案的问题在哪?一是极端情况下会把所有 Segment 锁住,相当于瞬间变成全局锁;二是即使加了所有段锁,算出来的值也只是一个瞬间值,因为算完锁一放,外面的写操作又进来了。所以 size() 本质上就是个"近似值",只不过这个近似的可信度比较高。
5.2 1.8 的 baseCount + CounterCell
1.8 把计数逻辑换成了类似 LongAdder 的思路。维护一个 baseCount,以及一个 CounterCell 数组。当并发竞争不激烈时,直接 CAS 更新 baseCount;一旦 CAS 失败,就把计数分摊到 CounterCell 数组的某个 cell 上,每个 cell 各自累加,最后 size() 时把 baseCount 和所有 cell 的值加起来。
这个设计和 1.7 相比最大的进步是:计数不再需要锁,而且高并发下多个线程可以同时更新不同的 cell,不会互相阻塞。代价是 sum 的结果更"近似"——因为在累加的那一刻,其他线程可能还在改 cell。所以 1.8 还提供了 mappingCount(),返回 long 类型,而 size() 返回 int,本质上是一个四舍五入的近似值。
有个容易忽略的点:CounterCell 数组的长度也就是 CPU 核数附近的一个 2 的幂,元素是 volatile long。如果你用它做精确的库存扣减,一定会踩坑,因为它从设计上就没打算提供精确计数。
5.3 弱一致迭代器是什么意思
ConcurrentHashMap 的迭代器是弱一致性的。什么意思?它不会抛 ConcurrentModificationException,写出迭代器时的"快照"之后,其他线程做的修改,迭代器可能看得到、也可能看不到,遍历过程中也不会加锁。这在大多数场景下是好事,比如一个常驻的缓存 Map,你遍历它打印指标时,不希望因为别的线程正好在写就崩溃。
但如果你用迭代器去判断"遍历到的数据是不是最新",就会被坑到。我自己就见过一个线上问题:一个定时任务用 ConcurrentHashMap 的 keySet() 遍历,然后根据 key 是否存在来决定要不要触发某个动作,结果因为弱一致性,刚 put 进去的 key 没有被这一次遍历看到,任务漏跑了一轮。这种问题不是框架的 bug,而是使用方对语义的误解。
6. 容易被忽略的细节差异
数据结构、锁、扩容、计数这四大块讲完,面试的基本盘就有了。下面这几个细节虽然小,但非常能区分一个人到底是背了八股还是真的用过。
6.1 null key 和 null value 都是禁区
1.7 和 1.8 的 ConcurrentHashMap 都禁止 key 或 value 为 null。这一点和 HashMap 不一样,HashMap 允许一个 null key,也允许 null value。ConcurrentHashMap 为什么不行?最经典的解释是:如果 map.get(key) 返回 null,你没法区分是"这个 key 不存在"还是"这个 key 对应的 value 就是 null"。在并发环境下,这种二义性会带来麻烦,所以设计者干脆从源头禁止。
1.8 的 putVal 方法第一行就是if (key == null || value == null) throw new NullPointerException(),可以直接看到这个约束。
6.2 concurrencyLevel 在 1.8 里变成了什么
1.7 的构造参数里,concurrencyLevel 是一个重量级概念,它决定 Segment 数组的大小,会向上取到 2 的幂,默认 16。也就是说,你在 1.7 里写new ConcurrentHashMap(16, 0.75f, 16),得到的是 16 段并发上限。
1.8 里已经没有 Segment 了,concurrencyLevel 参数虽然还在构造方法里,但它只用来作为初始容量的最小提示。具体来说,如果传入的 initialCapacity 比 concurrencyLevel 小,就把 initialCapacity 提到 concurrencyLevel 那么高,然后 table 的初始容量按 tableSizeFor 向上取 2 的幂。这个改动说明一个问题:并发级别不再和内部结构绑定,桶数越多,天然并发能力越强。
6.3 1.8 新增的原子复合方法
1.8 给 ConcurrentHashMap 加了一批函数式方法:compute、computeIfAbsent、computeIfPresent、merge。这些方法把"判断 + 更新"做成了原子操作,在锁内完成整个函数的执行。比如map.computeIfAbsent(key, k -> expensiveLoad(k)),多个线程同时调,最终只会有一个线程真正执行 expensiveLoad,其他人拿到同一个结果。
用起来方便,但有一个坑:回调函数里绝对不能再去操作同一个 ConcurrentHashMap,否则轻则死循环,重则 StackOverflowError,严重时能把机器打挂。这一点在 1.8 的文档里其实有说明,但很多人不看。
7. 高频追问和实战排查速查
这一节整理成表,方便面试前和排查问题时直接翻。
7.1 面试高频追问速查表
| 问题 | 结论 | 补充理由 |
|---|---|---|
| 1.8 用 synchronized 是不是性能回退? | 不是 | JDK 6 后锁升级让 synchronized 优化充分,且锁持有时间短 |
| 为什么不能用 null key/value? | 避免二义性 | 无法区分 key 不存在和 value 为 null |
| size() 是精确的吗? | 不是 | 近似值,建议使用 mappingCount() |
| 迭代器会抛 ConcurrentModificationException 吗? | 不会 | 弱一致性迭代器,不加锁 |
| 扩容时其他线程在干嘛? | 帮忙迁移 | ForwardingNode 标记 + helpTransfer |
| 链表多长才树化? | 8 | 泊松分布概率,约千万分之六 |
| 数组多长才允许树化? | 64 | MIN_TREEIFY_CAPACITY,否则先扩容 |
| 1.7 的并发上限是多少? | 默认 16 个 Segment | concurrencyLevel 可调,但固定上限 |
7.2 实战里最容易踩的坑
第一个坑就是拿 size() 当精确值用。比如有人写if (map.size() >= threshold) { doSomething(); },在并发环境下这个判断可能偏大也可能偏小,如果偏小,该触发的动作没触发;如果偏大,可能触发多余动作。需要精确计数时,应该用 AtomicLong 自己维护,或者用 LongAdder 专门做统计。
第二个坑是 computeIfAbsent 里的回调函数写了复杂的耗时逻辑,比如查数据库、调远程接口。虽然并发场景下只会有一个线程真正执行,但其他线程会阻塞等待这个回调完成。如果回调很慢,调用方全都会被拖住,效果和一个慢锁没有区别。别把"只执行一次"误解成"不阻塞"。
第三个坑是遍历时做删除。虽然弱一致迭代器不会抛异常,但如果你想边遍历边删,最好用iterator.remove()或者在遍历收集好 key 之后再统一删,不要边 iter 边直接 map.remove(key),否则某些元素可能被漏删或者重复处理。
第四个坑是不要拿 ConcurrentHashMap 去解决所有并发问题。它只能保证单次操作的原子性,像"先判断再 put"这种复合操作,如果不用 compute 系列方法,依然是不安全的。多线程环境下做"如果不存在就写入"这种操作,优先考虑 computeIfAbsent 而不是 get + put 两步走。
8. 选型建议和个人实践体会
用了几年的实际感受是:1.8 之后,ConcurrentHashMap 在绝大多数并发 Map 场景都值得直接用,不需要自己再去封装分段锁之类的轮子。读多写少的缓存场景,无锁读带来的收益很明显;读少写多的高并发计数场景,CounterCell 的分散计数也比 1.7 的单段计数稳得多。
我自己的习惯是:能用 ConcurrentHashMap 的地方就别用 HashTable,也别图省事用 Collections.synchronizedMap 包一层。那个包装类的锁粒度是整个 Map,并发量一上去就是瓶颈。需要做 Key 维度的原子更新,优先看 compute/merge;需要遍历快照,用 entrySet 配合弱一致迭代器,不要在遍历里做不可重入的操作;需要精确计数,单独维护 LongAdder。
最后分享一个小经验:接手旧项目从 Java 7 迁移到 Java 8 时,除了看有没有用旧的构造参数,重点检查有没有依赖 size() 精确值、有没有在 compute 回调里嵌套操作同一个 Map、有没有把 ConcurrentHashMap 序列化后跨进程用。这三个点是我见过的迁移事故高发区。1.8 的 ConcurrentHashMap 整体更聪明,但只有理解了它的取舍,才能真的用对地方。