ConcurrentHashMap 算得上是 Java 并发编程里绕不开的一座山。很多同学在准备面试时把它背得滚瓜烂熟,什么 CAS + synchronized、分段锁演进、红黑树转换,张口就来。但真到排查线上问题或者要做技术选型时,又发现自己对它的理解只停留在"八股文"层面,稍微追问一个细节就露怯了。
这篇文章我打算换个讲法,不按源码逐行翻译,而是从"设计者的视角"出发,把 ConcurrentHashMap 拆开揉碎,讲清楚它到底是怎么在并发环境下做到又安全又快的。我会结合自己多年踩坑和阅读源码的体会,把那些常规文档里不会写的细节、容易误解的地方一并拿出来聊聊。无论你是准备面试,还是想在项目里更合理地使用并发容器,这篇内容应该都能给你一些实在的收获。
1. 为什么我们需要一个"并发版"的 HashMap
在聊 ConcurrentHashMap 之前,先回到最基础的问题:HashMap 不是挺好用的吗,为什么非要在并发场景下另起炉灶?
1.1 HashMap 在并发环境下的三个致命伤
HashMap 在单线程环境下确实非常优秀,平均 O(1) 的查找性能,灵活的扩容机制,让它在绝大多数业务场景里都是首选。但它天生就不是为并发设计的。
第一个问题是数据覆盖。两个线程同时执行 put 操作时,如果哈希槽位相同,后写入的线程会把前一个线程已经放入的数据直接覆盖掉。这不是理论上的风险,而是真实会发生的问题。我见过不少线上事故,最后排查下来就是业务代码在高并发下用 HashMap 存共享状态,导致偶发性的数据丢失。
第二个问题是扩容死循环。JDK 7 时代,HashMap 扩容采用的是头插法,多线程并发触发扩容时,有概率让链表形成环。一旦链表成环,下一次 get 这个 key 的时候,就会陷入无限循环,CPU 直接飙到 100%。这个经典问题所有老 Java 开发应该都听过,它直接推动了 ConcurrentHashMap 的诞生和普及。
第三个问题是快速失败机制。HashMap 的迭代器是 fail-fast 的,也就是"快速失败"——在迭代过程中如果检测到结构被修改(比如别的线程往里加了元素),会立刻抛出 ConcurrentModificationException。这个异常在并发读写场景下几乎是必然出现的,因为 put 操作修改 modCount 的速度远超你迭代的速度。
1.2 从"锁住一切"到"锁住局部":并发容器的设计哲学
那么问题来了,如果要解决 HashMap 的并发问题,最直接的办法是什么?加锁。给整个 Map 加一把大锁,所有读写操作串行化,这样绝对安全。
Hashtable 和 Collections.synchronizedMap 就是这么干的。它们的实现极其简单粗暴:所有方法都加上 synchronized 关键字,或者用一个互斥锁包裹所有操作。但这种做法的代价是性能急剧下降。
想象一个场景:某个 Map 里有 1000 个 key,分散在不同的哈希桶里。理论上两个线程要是操作两个不同的 key,完全可以互不干扰地并行执行。但全局锁不允许你这么干,它强制所有线程排队进入临界区,即使它们操作的资源根本不冲突。
这就引出了并发容器设计的核心思想:锁的粒度决定并发度。如果你能把一把全局大锁拆分成若干把局部小锁,让不同线程操作不同区域时各自持有自己的锁,那么并发度就能成倍提升。Hashtable 的并发度是 1,而 ConcurrentHashMap 的目标就是把并发度提到一个更高的水平。
1.3 从 JDK 7 到 JDK 8:一次彻底的重构
JDK 7 的 ConcurrentHashMap 采用"分段锁"(Segment)的设计。它内部维护了一个 Segment 数组,每个 Segment 继承自 ReentrantLock,相当于一个小的 Hashtable。理论并发度等于 Segment 数组的长度,默认是 16,也就是说最多支持 16 个线程同时写入而互不阻塞。
这个设计在 JDK 7 时代是很先进的,但放到今天来看有几个明显问题:一是锁粒度仍然不夠细,一个 Segment 内部包含多个哈希桶,这些桶仍然共享同一把锁;二是查询操作也需要先定位到 Segment,再经过一次哈希定位才能找到真正的桶,多了一层跳转;三是它为了兼容 JDK 5 的老接口,代码结构略显臃肿。
JDK 8 对 ConcurrentHashMap 进行了彻底重写。分段锁被摒弃,取而代之的是 CAS + synchronized 的组合方案。每个哈希桶独立加锁,锁粒度细化到单个槽位,理论上并发度和哈希桶数量成正比。同时,底层存储结构也与 HashMap 对齐,引入了红黑树来优化极端哈希冲突场景。这可以说是一次脱胎换骨的升级,也是我这次要重点讲解的版本。
2. 核心数据结构:一张表,两种节点,三个关键字段
要说清楚 JDK 8 的 ConcurrentHashMap 是怎么工作的,必须先搞清楚它的"骨架"。我把它的核心结构总结为"一张表、两种节点、三个关键字段"。
2.1 Node 数组与哈希桶的物理组织
ConcurrentHashMap 的底层主存储是一个 Node<K,V> 数组,我们可以把它理解成一个大仓库里的货架,数组的下标就是货架的位置。每个数组位置(也就是一个哈希桶)可能指向:
- null:空桶位,表示这个位置还没有数据。
- 一个 Node:单个节点,表示这个桶位只有一条数据。
- 一条 Node 链表:多个节点通过 next 指针串联,表示这个桶位发生了哈希冲突,多个 key 映射到了同一个位置。
- 一颗红黑树(通过 TreeNode 组织):当链表长度超过阈值(默认是 8),链表会转换成红黑树来降低查询复杂度。
Node 是这个数据结构的基础单元,它承载了真正的键值对信息。我贴一段关键源码:
static class Node<K,V> implements Map.Entry<K,V> { final int hash; final K key; volatile V val; volatile Node<K,V> next; }注意看 val 和 next 都声明了 volatile。这可不是随便写上去的,它保证了在多线程环境下,一个线程对 val 的修改能立刻被其他线程看到,无需额外加锁就能实现可见性。这一点在使用上有个重要影响:ConcurrentHashMap 并不支持存储 null 值,因为如果 val 为 null,在多线程环境下就无法区分"这个 key 对应的值本来就是 null"还是"这个值还没被写入"。
2.2 ForwardingNode:扩容期间的关键信标
在 2.1 节提到的两种节点里,除了普通的 Node,还有一种特殊的节点叫 ForwardingNode,简称 ForwardingNode 或者"迁移节点"。
这种节点只在扩容期间出现。它的 hash 值被固定为 MOVED(-1),内部包含一个指向新表的引用 nextTable。当你去 get 一个 key,发现目标桶位是一个 ForwardingNode 时,就说明这个桶的数据已经被迁移到新表了,你需要去新表里继续查找。
ForwardingNode 的引入解决了扩容期间读写并发的一致性问题。它像一个路标:数据搬走了,凡是找过来的线程,都被指引到新地址去,不会出现在新旧表之间"迷路"的情况。
2.3 sizeCtl、binCount 等关键字段的底层作用
除了存储结构和节点类型,还有几个控制字段起着决定性作用,我把它们统一称为"三个关键字段"。
第一个是 sizeCtl。这是个极其核心的字段,它的值在不同场景下代表完全不同的含义:
- 负数:表正在初始化或扩容。-1 表示有线程正在进行初始化;-(1+n) 表示有 n 个线程正在协助扩容。
- 0 或正数:表示下一次扩容的触发阈值。0 代表尚未初始化,正数通常是当前容量乘以负载因子(0.75)的结果。
sizeCtl 是整个并发控制的"中枢神经系统",它通过 CAS 操作不断更新,确保只有一个线程能主导初始化,多个线程能协作参与扩容。
第二个是 transferIndex。在扩容时,它表示迁移任务的"进度条"。扩容操作会把原数组分成若干个区间,每个参与扩容的线程通过 CAS 修改 transferIndex 来"认领"自己的迁移区间。这样多个线程可以并行地迁移不同区间的数据,互不干扰。
第三个是 baseCount 和 CounterCell[]。它们配合用于统计 ConcurrentHashMap 中的元素数量。因为高并发下用一个单一变量计数必然产生竞争,所以设计者借鉴了 LongAdder 的思路,引入了一个 CounterCell 数组来分散计数压力。我后面会专门讲它的实现细节。
3. put 操作全流程:从定位到写入的每一步都算数
put 方法是 ConcurrentHashMap 最核心的入口。我把它的完整流程拆成几个阶段来解析,每一个分支、每一个判断都值得仔细琢磨,因为它们在并发环境下都有明确的意图。
3.1 put 方法的完整执行路径
当线程执行 put 操作时,它会走这样一条路径:
final V putVal(K key, V value, boolean onlyIfAbsent) { // 禁止 null 键和 null 值 if (key == null || value == null) throw new NullPointerException(); // 扰动函数:高位参与低位计算,降低哈希冲突概率 int hash = spread(key.hashCode()); int binCount = 0; // 注意这个 for 循环,整个 put 逻辑都包在循环里 // 目的是配合 CAS 实现无锁重试 for (Node<K,V>[] tab = table;;) { Node<K,V> f; int n, i, fh; K k; V v; if (tab == null || (n = tab.length) == 0) // 表还没初始化,先初始化 tab = initTable(); else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) { // 桶位为空,用 CAS 直接放入新节点 if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value))) break; } else if ((fh = f.hash) == MOVED) // 发现 ForwardingNode,说明正在扩容,当前线程协助迁移 tab = helpTransfer(tab, f); else { // 走到这里说明桶位有数据,需要加锁处理 V oldVal = null; synchronized (f) { // 加锁后重新检查桶位是否被修改 if (tabAt(tab, i) == f) { if (fh >= 0) { // 普通链表节点,走链表插入逻辑 binCount = 1; // ... 遍历链表,找到则替换,找不到则追加 } else if (f instanceof TreeBin) { // TreeBin 是红黑树的头节点,走树插入逻辑 // ... } } } // 插入完成后,检查是否满足树化条件 if (binCount != 0) { if (binCount >= TREEIFY_THRESHOLD) treeifyBin(tab, i); // ... } } } // 计数增加,可能触发扩容 addCount(1L, binCount); return null; }这段伪代码基本还原了 put 的完整骨架。我建议你把它当成一张流程图来看,重点关注几个分叉路口的选择逻辑。
3.2 无锁快路径:为什么大多数 put 不需要加锁
你可能会注意到一个细节:在上述流程中,绝大多数的 put 操作其实根本不会触发加锁,或者说,锁的获取被推迟到了最后一刻。这是 ConcurrentHashMap 性能优秀的关键所在。
当目标桶位是空的时候,线程直接用 CAS(Compare And Swap)原子地把新节点放进去。CAS 是硬件级别的原子指令,它比 synchronized 轻量得多。这里的设计哲学是:如果能用无锁操作解决,就绝不引入锁的代价。
CAS 在这里的具体含义是:比较当前桶位是否仍然为 null,如果确实为 null,就把它替换成新节点;如果不是 null,说明有其他线程抢先写入了,这次 CAS 失败,循环会重新执行,回到桶位判断的逻辑。
这个机制用生活化的比喻:就像你在停车场找个空车位,看到空位直接开进去,而不是先打电话问管理员"这个车位没人吧、我可以停吗"。只有当车位已经被占时,才需要用更复杂的手段协调。
正是这种"先尝试,失败再协商"的思路,让 ConcurrentHashMap 在低竞争场景下几乎达到了 HashMap 的性能水平。
3.3 锁住单个哈希桶:加锁路径的设计意图
当目标桶位已经有数据时,CAS 方案行不通了,因为它没法保证链表的并发修改安全。此时 ConcurrentHashMap 会退回到操作系统级别的乐观锁机制——synchronized 加锁。
但这个锁和 Hashtable 的全局锁有天壤之别:它锁的是桶位上的头节点 f,而不是整个 Map。这意味着,如果两个线程操作的是不同的哈希桶,它们各自的锁互不干扰,完全可以并行执行。只有当两个线程恰好操作同一个哈希桶时,才会有竞争等待。
这里有一个细节值得关注:为什么加锁之后还要再做一次tabAt(tab, i) == f的检查?因为从"发现桶位有数据"到"真正获取到锁"之间有一个时间窗口,另一个线程可能已经修改了这个桶位(比如触发了扩容、链表结构已经变化)。如果不重新检查,就会基于过期的信息做修改,导致数据错乱。这就是典型的"双重检查锁定"(Double-Checked Locking)思想。
在锁内执行链表插入时,代码会区分两种情况:如果 f.hash >= 0,说明这是一个普通的链表节点,就按传统方式遍历链表,找到相同 key 就替换值,找不到就在尾部追加。如果 f instanceof TreeBin,说明这个桶位是一棵红黑树,就按树节点的规则进行插入或更新。这两种路径的体积都不小,但核心逻辑和 HashMap 基本一致,区别只在于多了并发安全的边界保障。
4. 扩容机制:多线程如何协作把一张大表搬进另一张大表
扩容是 ConcurrentHashMap 里最复杂的部分,也是面试官最爱深挖的部分。它要解决的问题很尖锐:在扩容期间,既有老线程在读旧表,又有新线程在写新表,怎样才能保证数据不丢、不错、不重?
4.1 扩容触发条件与扩容目标计算
扩容的触发条件很直白:当 Map 中的元素数量超过 sizeCtl 所代表的阈值时,就会触发 resize。sizeCtl 通常是当前容量乘以 0.75 的负载因子。比如容量为 64 的 Map,sizeCtl 就是 48,当元素数超过 48 时触发扩容。
扩容的目标容量计算也不复杂,就是翻倍——从 n 扩大到 2n。这个设计继承了 HashMap 的传统,因为容量始终保持 2 的幂次,可以继续用位运算(n - 1) & hash快速定位桶位,同时扩容后元素只需判断新增的二进制位是 0 还是 1,就能确定留在原位置还是移到"原位置 + 旧容量"的新位置。
有意思的是,扩容这个动作本身不是由一个线程包办的。它被设计成了"可协作"的任务:一个线程发起扩容后,其他线程在后续操作中发现有扩容正在进行,可以选择停下手中的活,一起帮忙迁移数据。全员参与,共同把大表搬完。这种"全民皆兵"的设计,是 ConcurrentHashMap 并发性能的又一个体现。
4.2 扩容线程如何认领迁移区间
迁移任务的分工是通过 transferIndex 字段实现的。它的初始值是旧表的长度,表示从头到尾都需要迁移。每个参与迁移的线程通过 CAS 操作将 transferIndex 减去一段固定长度,从而"认领"属于自己的迁移区间。
做个类比,就像一大片农田需要收割,transferIndex 是尚未收割的面积计数。每个农民(线程)一次认领一垄地(stride),通过 CAS 把计数器往下减,谁抢到算谁的。这样既避免了重复收割,又保证了公平分配。
当 transferIndex 被减到 0 时,说明所有区间都有人认领,但并不意味着所有数据都迁移完了。认领只是分任务,真正干活还需要逐个桶位地搬。每个线程在自己认领的区间内,从后往前逐个桶位处理:如果桶位为空,直接放置一个 ForwardingNode 占位;如果桶位是链表或树,就拆分成高位链和低位链,分别迁移到新表的对应位置。
4.3 协助扩容:helpTransfer 与扩容期间的读写并发
当某个线程执行 put 或 get 时,如果发现目标桶位是 ForwardingNode(即 hash 值为 MOVED),它不会傻等着,而是会调用 helpTransfer 方法加入到扩容队伍中去。
helpTransfer 的逻辑其实不复杂:检查 nextTable 是否为空,如果非空,就用 CAS 去更新 sizeCtl,表示"又有一个线程加入扩容了",然后调用 transfer 方法开始干活。扩容结束后,所有线程会通过 sizeCtl 的值感知到这个状态,重新调整自己的工作节奏。
那扩容期间的 get 操作怎么保证正确性呢?答案还是 ForwardingNode。get 的时候如果遇到 ForwardingNode,就顺着它指向的 nextTable 到新表里继续找。因为数据是"从旧表搬到新表",不是一个整体拷贝,所以整个过程中新旧表并存,get 操作要么在旧表找到(数据还没搬走),要么去新表找(数据已经搬走),不可能找不到。这是一个非常优雅的"边搬迁边服务"方案。
有些细节你可能在实际使用中才会体会到:扩容期间整个 Map 的 get 性能会有所下降,因为多了判断 ForwardingNode 的环节,还有可能要去新表再查一次。但这个性能损耗在绝大部分业务场景下是感知不到的,因为这个时间窗口通常极短。
5. 读操作与计数统计:无锁化方案的取舍之道
put 和扩容是 ConcurrentHashMap 的"重武器",而 get 和 size 则是日常使用最频繁的"轻骑兵"。这两个操作也有很巧妙的设计,我来逐一解析。
5.1 get 全过程:无锁读凭什么不会出错
get 操作的设计可以用一句话概括:全程无锁。它不做 CAS,不加 synchronized,纯粹依靠 volatile 的可见性和巧妙的结构设计来保证正确性。
get 的步骤是:
- 计算 key 的哈希值,用
(n - 1) & hash定位到具体的桶位。 - 用
tabAt(tab, i)读取桶位的头节点。这个操作本质上是Unsafe.getObjectVolatile,保证了读取到的一定是最新值。 - 如果头节点 hash 小于 0,说明它可能是一个 ForwardingNode 或 TreeBin。ForwardingNode 就顺着 nextTable 去新表查;TreeBin 就按红黑树的方式查找。
- 如果头节点是普通 Node,就遍历链表,逐个比较 key 是否相同。
读操作不锁,是因为它依赖了三个前提:Node 的 val 和 next 是 volatile;数组的索引读取通过 Unsafe 的 volatile 语义保证;结构变更时(比如链表修改)一定是在持有 synchronized 锁的保护下完成的。这意味着,get 要么读到完全一致的新数据,要么读到还没来得及修改的旧数据——但绝不可能是中间状态、也不会读到 null(因为非 null 才会写入)。
这里我要特别提醒一个容易踩的认知坑:ConcurrentHashMap 的 get 是"弱一致性"的。它不是线性一致的。理论上,你调用 get 返回了一个值,紧接着另一个线程 put 了一个新值,然后你再 get 一次,不保证立刻能看到新值。这听起来有点反直觉,但在绝大多数业务场景下,这个折中完全值得,因为它换来了极高的读性能。
5.2 size 统计的精密仪器:baseCount 与 CounterCell
要统计 ConcurrentHashMap 的元素数量,如果用一个共享的 long 类型变量,在高并发写入下必然产生严重的 CAS 竞争。设计者的解法是引入 CounterCell 数组。
当多个线程同时 addCount 时,每个线程会先尝试 CAS 更新 baseCount。如果 CAS 失败,说明竞争激烈,就改为在自己线程对应的一个 CounterCell 里做累加。每个 CounterCell 是独立的存储单元,线程之间各写各的格子,互不竞争。这个思路和 LongAdder 如出一辙——把单一计数器的竞争压力分散到多个子计数器上。
size() 的结果怎么算?就是 baseCount 加上所有 CounterCell 的值的总和。这个结果并不是实时精确的,但对于"统计 Map 里大概有多少元素"这个需求来说,已经足够准确。需要精确计数时,ConcurrentHashMap 还提供了 mappingCount 方法,同样基于 CounterCell 机制,返回一个 long 型更精确的估计值。
这里有个使用层面的提示:如果业务强依赖 Map 的 size 做容量判断或条件触发,建议在使用前先评估一下"稍有不精确是否可接受"。如果完全不能容忍误差,可能需要额外加锁来保证精确计数,但这会牺牲并发性能,是一个需要权衡的设计决策。
5.3 Java 8 中为何弃用分段锁方案
既然聊到了 JDK 8 的实现,很多人会问:分段锁方案在 JDK 7 里不是挺好的吗,为什么 JDK 8 要完全抛弃?
我的理解是:分段锁的粒度受限于 Segment 数组的长度。如果 Segment 数组只有 16 个元素,那无论底层哈希桶有多少个,理论并发度最高就是 16。虽然可以通过扩大 Segment 数组来提升并发度,但这会带来更多内存占用和管理复杂度。更关键的是,JDK 8 的 synchronized 经过版本迭代后性能已经得到了大幅优化(引入偏向锁、轻量级锁等机制),在锁竞争不激烈时,synchronized 的开销已经非常低。用 CAS + synchronized 锁单个桶头的方案,锁粒度更细,逻辑也更简洁。
还有一个技术细节:JDK 8 把链表升级为红黑树后,如果用分段锁,Segment 内部的树形结构移动依然需要锁住整个 Segment。但 JDK 8 的锁粒度细化到桶位后,树和链表在各自桶内维护互不干扰,并发度自然更高。
6. 实战经验:ConcurrentHashMap 使用的坑与排查思路
技术原理讲了不少,最后这部分我想从实战角度聊聊我在项目里使用 ConcurrentHashMap 的真实体验,包括它有什么"坑",以及怎么排查相关问题。这部分内容通常不会出现在源码注释里,但对实际工作特别有帮助。
6.1 不要用它代替一切 Map:理解适用边界
ConcurrentHashMap 性能优秀,但绝不是所有场景的"银弹"。我在一些项目里见过团队把所有的 HashMap 都替换成 ConcurrentHashMap,理由是"以后万一有并发需求呢"。这种一刀切的做法我是不太推荐的。
首先,ConcurrentHashMap 的内存占用比 HashMap 更高,因为它需要额外的 ForwardingNode、TreeBin 等节点结构支持并发。在数据量巨大、并发度不高的场景下,内存成本不容忽视。
其次,ConcurrentHashMap 的迭代器是弱一致的,不支持"迭代期间若被修改则报错"这种语义。如果你有一个"遍历并同时修改"的业务逻辑,比如传统 HashMap 在迭代时遇到 ConcurrentModificationException 反而能提醒你代码有 bug,ConcurrentHashMap 则会"安静地"完成遍历——它不会报错,但结果可能不是你期望的"快照语义"。
我的建议是:在"读多写少、真并发"的场景下用 ConcurrentHashMap;在"单线程、无需考虑并发"的场景下继续用 HashMap;在"需要严格快照迭代"的场景下考虑用 CopyOnWriteArrayList 搭配,或者自己加锁。
6.2 键值不允许为 null 的深意与应对方式
前面提到过,ConcurrentHashMap 的 key 和 value 均不允许为 null。这个限制不是随意定的,它有很深的并发考虑。
如果 value 允许为 null,那么在 get 的时候返回 null 就无法区分两种情况:这个 key 对应的值确实是 null,还是这个 key 根本不存在。在单线程的 HashMap 中,我们可以用 containsKey 再次确认。但在多线程环境下,即使你用 containsKey 确认过,下一次 get 之前该 key 可能已经被删除了,仍然存在竞态条件。为了避免这种隐患,设计者干脆一刀切,禁止 null 值。
实际开发中,很多同学会在调用 map.get(key) 后直接判断是否为 null 来决定后续逻辑。如果 key 不存在,返回 null 是合理的。但如果你确实有"键存在但值为 null"的需求,就需要换一种建模方式,比如用一个特殊的包装对象(如 Optional),或者用一个布尔标志位的内部状态来标记"存在但值为空"。
6.3 为什么使用自定义对象作 key 时务必重写 hashCode 和 equals
这是一个单线程 HashMap 与 ConcurrentHashMap 都适用的基础规范,但在并发场景下更容易引发莫名其妙的问题。
ConcurrentHashMap 定位桶位依赖 key 的 hashCode。如果 hashCode 没有正确重写(比如直接继承 Object 的默认实现),那么两个"逻辑上相等"的对象会计算出不同的哈希值,被放入不同的桶位。结果就是:你想 get 一个 key,却因为哈希不一致而查不到,即使两个 key 的 equals 是相等的。
在并发环境中,这种问题更难排查,因为问题可能是偶发的:不同的调用线程用了不同的对象实例,有的走到了正确桶位,有的没走到。看起来像是有并发 bug,其实根源就是 hashCode 没写好。
所以我的建议是:作为 key 的类,务必保证 hashCode 和 equals 的一致性。如果你使用了 Lombok,可以直接用 @EqualsAndHashCode 注解生成,省心且不容易出错。
6.4 常见问题速查表
为了让你在实际使用中快速定位问题,我整理了一张速查表,把常见的状况、可能的原因和排查方向串起来:
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| get 偶尔返回 null,但 containsKey 为 true | value 本身为 null | 检查所有 put 是否允许 null 值,改用占位对象 |
| 迭代遍历时结果错乱,但不报异常 | 迭代弱一致,未加锁处理 | 需要快照语义时加锁或改用其他容器 |
| 并发写入时性能骤降 | 多个线程操作同一个哈希桶 | 检查 key 的 hashCode 分布,优化散列质量 |
| size() 统计值偶尔不准 | CounterCell 机制是近似精确 | 对精确度要求高的场景自行加锁 |
| 内存占用偏高,GC 压力大 | TreeBin、CounterCell 等额外结构所致 | 评估是否真的需要并发容器 |
| 偶发 ConcurrentModificationException | 可能是 Stream 遍历时并发修改 | 使用迭代器加锁,或改用 forEach 并注意写操作方式 |
6.5 一个真实案例:并发读后写导致的性能抖动
最后分享一个我实际遇到过的问题。某个服务在峰值时段出现明显的性能抖动,排查了数据库、网络都没找到原因。后来通过 jstack 抓线程堆栈,发现大量线程阻塞在 ConcurrentHashMap 的某个桶位的 synchronized 锁上。
进一步分析后发现,业务代码里 80% 的并发写操作都落在了同一个 key 上(一个全局配置项,所有请求都会更新它)。这意味着所有写线程都在竞争同一个哈希桶的锁,Map 的并发能力完全没发挥出来。
解决方案也很直接:对这个高竞争 key 的读写做降级处理——不再每次都更新 Map,而是采用本地缓存 + 定时刷新的方式,让更新的频率从"每个请求"降为"每 30 秒一次"。
这件事给我的教训是:真正的并发性能瓶颈往往不在容器本身,而在业务访问模式的分布上。使用 ConcurrentHashMap 时,留意"热点 key"的存在,有时候比优化容器参数有效得多。
7. 面试高频考点与易混淆点深度拆解
框架层面的知识聊得差不多了,但既然 ConcurrentHashMap 是面试常客,我还是单开一章,把那些容易混淆的点做个集中梳理。这不仅对面试有用,也能帮你更透彻地理解它的内部逻辑。
7.1 链表到红黑树:阈值计算与转换条件
在 JDK 8 中,当一个哈希桶内的链表长度达到 8 时,链表会被转换成一棵红黑树。这个转换的阈值条件有两条:链表长度达到 8,同时当前数组容量至少为 64。如果容量不足 64,即便链表已经很长,也只会触发一次扩容操作,而不是直接树化。
为什么是 8 这个数字?源码注释里给了很详细的概率依据:在理想的随机哈希码下,桶内元素个数遵循泊松分布。当负载因子为 0.75 时,一个桶出现 8 个元素的概率大约是千万分之六——几乎不可能发生。换句话说,如果链表长度真的到了 8,说明数据分布极其不均匀,此时需要红黑树来兜底,把查询复杂度从 O(n) 降到 O(log n)。
第二个条件为什么是容量至少 64?因为如果数组本身很小,说明问题可能出在扩容不及时上。此时优先扩容数组,让数据分布重新散列到更多的桶位里,往往比僵化地去建树更合理。当数组容量大了之后仍然出现某个桶链表过长,这才说明确实需要树化的手段。
树化之后也有一个退化机制:当红黑树中的节点数降到小于 6 时,树会重新退化成链表。树和链表的切换有 8 和 6 两个阈值,中间留了一个缓冲区,避免在某个临界点上来回震动、反复转换造成性能损耗。
7.2 compute 等复合操作:不强保证原子性
很多人误以为 ConcurrentHashMap 的每个方法都是线程安全的,而"线程安全"意味着"所有操作都具备原子性"。这个理解需要打个折。
单个方法如 put、get、remove 确实是线程安全的,但像 compute、computeIfAbsent、merge 这类复合操作,ConcurrentHashMap 虽然做了不少努力去保证内部过程的原子性(比如在 computeIfAbsent 中会用 synchronized 锁住目标桶),但它在某些边界情况下并不能提供让人放心的强一致性保证。
举个例子:两个线程同时调用 computeIfAbsent(key, mappingFunction),其中 mappingFunction 是一个耗时的计算任务。虽然最终结果只有一个线程的计算值会被写入,但理论上另一个线程的映射函数可能仍然会被执行(取决于具体 JDK 版本和实现细节)。如果你的映射函数有副作用(比如发送邮件、扣减库存、写入日志),这就可能造成意外的重复执行。
因此我建议:在需要保证"整个计算+写入过程完全原子"的商业逻辑里,不要完全依赖 ConcurrentHashMap 的复合方法,而是应该在外层自行加锁,或者将映射函数设计成无副作用的幂等函数。这个认知在实际业务里非常重要,我见过不止一次因为 computeIfAbsent 的副作用重复执行导致的数据重复扣减事故。
7.3 弱一致性与强一致性:面试常考的"文字游戏"
面试中最高频的一个问题或许是:"ConcurrentHashMap 是线程安全的,那它能保证强一致性吗?"
答案是不能。准确地说,ConcurrentHashMap 保证的是"单个操作是线程安全的",但整体上它对外提供的是弱一致性视图。什么意思?
弱一致性意味着:如果你在 Thread A 里 put 了一个 key,与此同时 Thread B 正在执行 get 或遍历,Thread B 有可能看不到这个刚刚写入的数据。这在时间上可能只持续极短的时间窗口,但它真实存在。
这个特性对大多数业务来说不是问题,因为大多数业务并不需要一个"所有线程看到的 Map 完全一致"的瞬间快照。但如果你是做分布式锁、配置中心这类需要强一致语义的组件,ConcurrentHashMap 就不适合作为唯一的数据源,你需要借助更重量级的分布式协调组件来完成。
7.4 初始化过程的并发控制:CAS 抢占机制详解
最后聊一下初始化。ConcurrentHashMap 的构造方法并不会直接初始化内部的 Node 数组,而是采用"懒加载"——第一次 put 时才会真正分配数组空间。这与 HashMap 的懒加载策略是一致的。
但是在并发环境下,多个线程可能同时触发初始化,怎么保证只创建一个数组?
答案是通过 sizeCtl 和 CAS 的配合。流程是这样的:第一个线程发现 table 为空,先尝试通过 CAS 把 sizeCtl 从 0 改成 -1。如果 CAS 成功,说明它获得了初始化的"独家授权",可以安心地 new 一个 Node 数组。其他线程随后进来,发现 sizeCtl 为 -1,就知道已经有线程在初始化了,于是通过 Thread.yield 让出 CPU 时间片,等待初始化完成。
如果多个线程同时发动 CAS,最终只有一个能成功,失败的线程会自旋等待。这个机制简单高效,也是"无锁设计"精神的体现——用最轻量的方式解决并发冲突,只有在必要时才引入更重的同步手段。
8. 尾声
ConcurrentHashMap 的内部实现逻辑,说到底是设计者在一张表中平衡了并发安全、性能、内存占用三方诉求的产物。从 JDK 7 的分段锁到 JDK 8 的 CAS + synchronized,从链表到红黑树,从 baseCount 到 CounterCell,每一步演进都有其实际背景和取舍逻辑。理解这些取舍,比背下源码片段重要得多。
在实际项目中,我越来越体会到:一个并发容器是否适合你的业务场景,不能只看它的名气,而要结合访问模式、数据量、竞争程度、一致性需求等维度综合判断。ConcurrentHashMap 很强大,但它不是万能的。工具终究是工具,怎么用、何时用、什么时候该换一个方案,靠的还是开发者对底层原理的把握。
如果你正在准备面试,我建议你在理解源码逻辑的基础上,尝试自己画出 put 和扩容的完整流程图,反复推演几个并发线程同时操作的场景。画图的过程会逼你把每个分支、每个字段的含义都消化到位,比单纯看十篇分析文章都有效。如果你是在实际项目中遇到问题,也欢迎把具体场景拿出来讨论,这类问题往往比理论推演更有意思。