ConcurrentHashMap底层原理深度剖析:从CAS到扩容协作机制

发布时间:2026/10/14 6:21:37
ConcurrentHashMap底层原理深度剖析:从CAS到扩容协作机制 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 的底层主存储是一个 NodeK,V 数组我们可以把它理解成一个大仓库里的货架数组的下标就是货架的位置。每个数组位置也就是一个哈希桶可能指向null空桶位表示这个位置还没有数据。一个 Node单个节点表示这个桶位只有一条数据。一条 Node 链表多个节点通过 next 指针串联表示这个桶位发生了哈希冲突多个 key 映射到了同一个位置。一颗红黑树通过 TreeNode 组织当链表长度超过阈值默认是 8链表会转换成红黑树来降低查询复杂度。Node 是这个数据结构的基础单元它承载了真正的键值对信息。我贴一段关键源码static class NodeK,V implements Map.EntryK,V { final int hash; final K key; volatile V val; volatile NodeK,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 表示有线程正在进行初始化-(1n) 表示有 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 (NodeK,V[] tab table;;) { NodeK,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 NodeK,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 性能优秀的关键所在。当目标桶位是空的时候线程直接用 CASCompare 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 的 MapsizeCtl 就是 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 反而能提醒你代码有 bugConcurrentHashMap 则会安静地完成遍历——它不会报错但结果可能不是你期望的快照语义。我的建议是在读多写少、真并发的场景下用 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 为 truevalue 本身为 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 和扩容的完整流程图反复推演几个并发线程同时操作的场景。画图的过程会逼你把每个分支、每个字段的含义都消化到位比单纯看十篇分析文章都有效。如果你是在实际项目中遇到问题也欢迎把具体场景拿出来讨论这类问题往往比理论推演更有意思。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询