
做Java开发这么多年面试时被问ConcurrentHashMap几乎是躲不开的环节而Java 8版本的实现与Java 7相比变动极大很多人停留在“分段锁”的认知里甚至背了八股文却不知道底层数组、链表、红黑树是怎么配合工作的。这篇文章就把Java 8中ConcurrentHashMap的底层数据结构、成员变量设计、put/get/扩容等核心方法执行流程从头到尾拆一遍并结合实际开发中常见的坑给出排查思路。不论你是准备面试还是想真正理解并发容器这都是一份可以直接对照源码阅读的手册。先说明一点本文所有源码分析基于JDK 8的官方实现java.util.concurrent包分析的侧重点是结构和流程而不是逐行注释。读的时候建议打开IDE里的ConcurrentHashMap源码边看边对照效果最好。1. 底层数据结构全解析从分段锁到CASsynchronized的演进1.1 核心成员变量与内存布局Java 8的ConcurrentHashMap抛弃了Java 7的Segment分段锁结构转而使用“数组链表红黑树”的存储结构配合CAS和synchronized来保证并发安全。先看几个最关键的成员变量它们在内存布局中承担不同职责。table是核心存储数组类型是NodeK,V[]默认初始容量为16。每个数组槽位可能是null、一个Node节点、一棵红黑树TreeBin或一个ForwardingNode。nextTable只有在扩容时才会非空指向扩容后的新数组它的长度通常是旧数组的两倍。sizeCtl是控制标识符它承载了多种状态后面扩容部分会详细分析。transferIndex是扩容时线程任务分配的索引记录还没迁移的槽位范围多个线程通过CAS修改它来各自认领区间。baseCount用于记录元素个数的基础值更新时优先用CASCAS竞争激烈时使用CounterCell数组分散计数。Node节点是链表的基本单元它的val和next字段都被volatile修饰保证可见性。这里有个容易忽略的设计Node的hash值在正常情况下是经过spread方法扰动后的散列值但特殊节点ForwardingNode、TreeBin、ReservationNode的hash被取为负值常量例如MOVED-1、TREEBIN-2、RESERVED-3这是后续判断节点类型的关键标志。与Java 7对比最大的优化是锁粒度从Segment级别降到了单个哈希桶级别。Java 7中Segment继承ReentrantLock每个Segment管理一段桶锁冲突仍然可能跨多个桶存在。Java 8用synchronized锁住链表头节点或红黑树根节点并发度从固定的16Segment数量提升到了数组长度级别理论上扩容后并发度更高。1.2 链表转红黑树的阈值为什么是8和64树化触发条件有两个链表长度达到8且数组长度达到64。很多人只记得阈值8却忽略了64的限制。链表长度达到8时并不会立刻树化而是先检查table.length是否小于64。如果小于64优先执行扩容而不是树化。原因是数组容量较小时哈希冲突多是因为容量不足扩容能够分散冲突而数组容量达到64后仍然频繁冲突说明hash分布确实糟糕此时链表查询效率已经明显下降必须树化来保证最坏情况下的查询复杂度从O(n)降到O(log n)。树化阈值的选取并非随意而是基于泊松分布的统计结论。在负载因子0.75、随机hash的理想情况下同一个桶内链表长度达到8的概率极低约千万分之六意味着链表长度到8时已经是异常冲突场景适合树化。反过来如果树中节点数降到6以下红黑树会退化为链表避免红黑树在节点少时维护平衡带来的额外开销。为什么退化的阈值是6而不是7因为6和8之间留了一个缓冲防止链表和树在阈值附近反复切换这种滞后设计在并发场景下尤其必要。2. putVal方法执行流程一条数据是怎么写入的2.1 散列值的扰动计算ConcurrentHashMap不会直接使用key.hashCode()作为桶索引而是先经过spread方法处理static final int spread(int h) { return (h ^ (h 16)) HASH_BITS; }这里做了两件事第一将hash值的高16位与低16位异或把高位信息扩散到低位让其在数组长度较小时也能参与寻址第二与HASH_BITS0x7fffffff做与运算确保结果为正数从而与特殊节点的负hash区分开。之所以要这样做是因为在计算桶索引时用的公式是hash (n-1)当数组长度n较小时实际参与运算的是hash的低位如果key的hash值低位重复度高冲突就会非常严重。2.2 插入流程的完整拆解put方法内部调用putVal(key, value, onlyIfAbsent)整个流程可以用下面几个关键分支来理解第一步计算key的spread hash值然后进入一个死循环。循环的目的是处理并发冲突如果某一次尝试因为竞争失败就重新读取最新的table状态再次尝试。第二步检查table是否为null或长度为0如果是则调用initTable初始化。初始化通过CAS将sizeCtl从0改为-1来抢占锁成功的线程执行数组创建失败的线程通过Thread.yield让出CPU。第三步用tabAt方法通过Unsafe.getObjectVolatile读取指定槽位的节点保证读取的是主存中的最新值。如果槽位为空就用casTabAt尝试直接放入新节点CAS成功则跳出循环失败说明被其他线程抢先重新循环。如果槽位不为空说明存在哈希冲突此时判断节点的hash是否等于MOVED。等于MOVED说明数组正在扩容当前线程需要调用helpTransfer协助迁移数据而不是直接插入否则可能把节点写到已经迁移过的旧数组里。如果不等于MOVED则用synchronized锁住该槽位的头节点进入临界区后再检查头节点是否被其他线程修改过通过tabAt重新读取确认确认无误后遍历链表或树进行插入。如果是链表遍历查找key相同的节点找到就按onlyIfAbsent决定是否覆盖没找到就在链表末尾追加节点。如果是TreeBin红黑树的包装节点调用putTreeVal插入同时TreeBin本身实现了读写锁机制来保证并发遍历安全。插入完成后链表场景会检查binCount是否达到TREEIFY_THRESHOLD-1即链表长度为8达到则调用treeifyBin尝试树化。最后addCount方法会更新元素计数可能触发扩容。2.3 为什么锁冲突只在哈希桶级别从上述流程可以看出synchronized只在槽位非空时锁住该槽位的头节点这意味着不同线程插入不同槽位时完全无锁竞争只有落在同一个槽位的线程才会串行执行。与全局锁或分段锁相比这已经把锁冲突范围压缩到了最小。同时CAS的使用避开了锁的获取开销。空槽位插入是并发容器最常见的操作之一Java 8直接用Unsafe的compareAndSwapObject完成不需要创建锁对象省去了线程阻塞和唤醒的代价。在低冲突场景下这种“无锁优先有锁兜底”的设计非常高效。需要特别注意一个实现细节在synchronized临界区内插入逻辑会重新读取头节点确认它没有被修改。因为头节点可能在当前线程进入临界区前被另一个线程替换比如扩容迁移或删除操作如果不做二次校验就会基于过期的头节点操作导致数据错乱。3. 扩容机制深度拆解多线程如何协同迁移3.1 sizeCtl各阶段值的变化含义sizeCtl是理解扩容机制的一把钥匙它在不同阶段有不同的含义0默认值表示table尚未初始化。-1table正在初始化某个线程通过CAS把sizeCtl从0改为-1其他线程发现-1就让出CPU。-(1n)table正在扩容高16位存储扩容标识戳resizeStamp低16位表示参与扩容的线程数加1。比如-2145715711这类负数拆开来看就是扩容正在进行。正数表示下一次触发扩容的阈值等于数组长度乘以负载因子。初始化完成后sizeCtl被设置为0.75n。这种一个变量承载多种状态的设计很巧妙但也增加了阅读源码的难度。面试里经常让人解释“sizeCtl为什么是负数”本质上就是在考这一点。扩容触发条件有两个一个是addCount在更新计数后判断元素数量是否超过sizeCtl阈值另一个是treeifyBin在链表长度达到8但数组长度小于64时通过扩容来缩减链表长度。3.2 transfer方法的实现细节transfer是真正的迁移方法实现了多线程分块迁移。它的核心思路是把整个数组的槽位范围划分为若干连续区间每个线程通过CAS修改transferIndex来认领一段区间进行处理处理完后继续认领下一段直到所有槽位迁移完毕。迁移的最小单位是一个哈希桶区间而不是单个桶这样可以减少CAS竞争。每个线程将transferIndex往前推进一个stride步长步长与CPU核数相关最小为16然后迁移该区间内的所有槽位。迁移单个槽位时如果槽位是null直接放置ForwardingNode标记如果槽位是链表则将链表拆分为high和low两条链分别放入新数组的同索引位置和“原索引旧容量”的位置如果槽位是TreeBin则调用split方法将红黑树拆分为low和high两棵如果拆分后的节点数小于等于6则退化为链表。链表拆分是整个迁移过程中最精妙的部分。对于旧数组位置i的链表新数组中的目标位置只有两个i低位和in高位n是旧数组长度。判断依据是节点的hash值在旧容量对应bit位上是0还是1这一点与HashMap的resize完全一致。拆分成两条链后旧数组的i位置放置ForwardingNode新数组的i位置和in位置分别放入两条链既保证了迁移期间其他线程读写的一致可见又避免了逐个节点插入带来的性能损失。所有区间迁移完成后会做最终检查确认旧数组中所有槽位都是ForwardingNode然后把table指向nextTablesizeCtl设置为新容量的0.75倍。3.3 协助扩容在get/put中的体现Java 8的扩容不是由一个线程单打独斗完成的任何线程在执行put、remove等写操作时发现槽位上的节点hash为MOVED都会调用helpTransfer加入扩容队伍。这种协作机制充分利用了多核CPU的能力让扩容这个最重的操作尽可能快地完成。put操作遇到MOVED节点时的处理路径当前线程通过helpTransfer加入扩容而不是直接去新数组执行插入。因为旧数组中的槽位已经被标记为ForwardingNode真正的数据已经不在旧数组中如果直接在旧数组插入会造成数据丢失。get操作遇到MOVED节点时的处理路径get方法直接调用ForwardingNode的find方法在新数组对应位置继续查找。这里有一个细节get方法本身不需要帮助扩容只负责把查询路由到新数组因为读操作不修改数据等待扩容完成也不会有正确性问题。这种“帮写不帮读”的设计是出于效率考虑。4. 读操作与统计计数的无锁设计4.1 get方法为何不需要加锁get方法全程无锁却能保证拿到的是正确数据核心依赖是volatile读和不变性设计。Node的val和next都是volatile的tabAt读取槽位时也使用Unsafe.getObjectVolatile保证在读的瞬间能拿到最新的引用。当槽位上是普通链表节点时遍历链表的过程中虽然可能有其他线程在尾部追加节点或修改已有节点的val但val被volatile修饰遍历时总能读到最新值。链表的next指针一旦确定就不会改变除了删除时的断链操作删除操作时会把节点的val置为null并借助volatile保证可见性get遍历时发现val为null就跳过不会读到“幽灵节点”。当槽位是TreeBin时情况稍微复杂。红黑树的指针不是volatile的TreeBin内部维护了一个volatile的root和读写锁状态。读线程通过CAS将TreeBin的lockState改为READER在无写操作时多个读线程可以并发遍历如果读线程发现正在写操作就退化到遍历链表TreeBin内部保留了原始链表避免阻塞。这种设计保证了读操作在绝大多数场景下不会被写操作阻塞。4.2 size与mappingCount的计数原理ConcurrentHashMap的元素计数不是简单的volatile int而是由baseCount和CounterCell[]协同完成的。每次put/remove操作后调用addCount方法更新计数先尝试用CAS将baseCount加上增量如果CAS失败说明线程竞争激烈就在当前线程的随机CounterCell上执行CAS。CounterCell数组的长度是2的幂初始为2与CPU核数相关。这种“先集中后分散”的策略减少了多线程同时更新计数器时的竞争。但是要注意size方法返回的只是一个近似值。它累加baseCount和所有CounterCell的值整个过程没有加锁累加的同时可能有其他线程在修改计数因此结果是偏小的或偏大的不能保证完全准确。源码注释也明确说这是弱一致性的视图。所以官方推荐使用mappingCount方法它返回long类型而不是int。为什么因为size返回int当元素数量超过Integer.MAX_VALUE时会溢出为负数而mappingCount用long容纳更大范围。虽然两者在统计逻辑上没有区别但在大数据量场景下必须用mappingCount。5. 实战中的常见问题与排查技巧5.1 遍历弱一致性与并发修改很多开发者用ConcurrentHashMap替代HashMap后以为所有操作都强一致这是常见的误解。迭代器通过entrySet().iterator()虽然是弱一致的不会抛出ConcurrentModificationException但不代表遍历期间一定能看到所有最新数据。迭代器创建后如果其他线程新增了元素迭代器可能看不到删除元素后迭代器可能已经读到了旧值。在实际开发中如果某个业务功能要求遍历时看到的是某一瞬间的完整快照且数据量不大建议先把entrySet转成List或另一个临时Map再遍历。如果数据量大可以考虑用compute系列方法做原子更新而不是先读后写。曾经遇到过缓存刷新场景用迭代器逐条更新结果新数据覆盖后又被旧数据的异步回调覆盖回去排查了很久才发现是弱一致性导致读到了过期数据最终改成对每个key单独使用computeIfPresent。5.2 频繁扩容引发的性能问题并发场景下扩容虽然多线程协作但仍然是一个昂贵的操作。如果Map的初始容量设置过小元素快速增加会触发多次扩容每次扩容都要迁移所有节点期间写操作的性能显著下降。比如默认容量16存放1万个元素数组容量要翻倍到16384中间经历了多次扩容每次扩容时大量线程参与迁移CPU使用率飙升。解决办法是预估容量并提前设置。可以用expectedSize / 0.75f 1作为初始容量让Map在预期数据量下不触发扩容。实际项目中一个存放设备状态的Map预计有5000条记录初始容量设为7000左右整体性能比默认容量配置提升明显。如果无法精确预估宁可设置大一点也不要用默认容量硬扛。5.3 与HashMap混用时的隐藏坑点ConcurrentHashMap不允许key或value为null而HashMap允许。这个差异在特定场景下会变成隐蔽的bug。例如从数据库查询结果放入Map时如果某个字段为null用HashMap没问题切换到ConcurrentHashMap就会抛出NullPointerException。代码里如果同时维护两个Map一个支持null一个不支持很容易在迁移数据时踩坑。另一个容易忽略的点是ConcurrentHashMap的computeIfAbsent方法在计算函数执行期间如果该key对应的槽位被锁住其他线程对同一key的读操作会阻塞。高并发下如果计算函数本身耗时较长比如远程调用会导致大量线程堆积在锁上。JDK 8的这个实现是已知的性能陷阱AWS工程师曾专门发文章吐槽过实测在热点key上做耗时的computeIfAbsentQPS下降极其明显。解决思路是计算函数里只做内存操作远程调用放到外面或者用putIfAbsent配合手动判断。5.4 容量初始化与并发预热的实操建议ConcurrentHashMap的初始化是惰性的也就是首次put才创建table。在高并发流量到达时第一个触发初始化的线程要做完整数组创建其他线程让出CPU后再次自旋检查这个瞬间有一定的耗时。对于延迟敏感的系统可以在启动阶段主动调用一下put或使用构造函数指定initialCapacity让初始化提前完成避免请求高峰时抢占初始化资源。另外对外提供服务时不要把ConcurrentHashMap直接暴露给上层调用者因为size和mappingCount的弱一致性可能会让监控数据短时间抖动。业务上需要精确统计时可以自定义包装类在put/remove时用AtomicLong额外维护一份计数牺牲一点写入性能换取统计准确性这种事情我在监控系统里已经做过好多次了。6. 面试与源码阅读的高频考点梳理6.1 常见面试问题背后的设计意图面试官问ConcurrentHashMap表面上是考API实际是考并发编程功底。比如“为什么get不加锁也不会读到脏数据”答案要落到volatile语义和Node.val/next的声明上“锁的是什么”答案要落到链表头节点或TreeBin上“红黑树查询复杂度是多少为什么最坏情况不会退化”答案要落到树化的两个阈值上“扩容时其他线程能继续读吗”答案要落到ForwardingNode的find路由上。把这些设计意图串成一个体系比死记硬背源码结论更有说服力。还有一道高频题为什么Java 8弃用Segment而用synchronized很多人回复说synchronized性能更好这个答案并不准确。关键在于synchronized在现代JVM中引入了锁升级机制偏向锁、轻量级锁、重量级锁在低竞争场景下开销极低而Segment本身是ReentrantLock每次操作都要走AQS并且锁粒度是整个Segment。从架构上看用单个哈希桶作为锁粒度是本质提升synchronized只是恰好配合实现了这个粒度。可以配合测试数据说明在JDK 8的环境下低竞争时两者差距不大高竞争时细粒度锁优势明显。6.2 源码阅读的顺序建议直接从头到尾读ConcurrentHashMap容易劝退因为方法之间互相调用变量含义随状态变化。建议按这个顺序来先读构造方法和initTable理解sizeCtl的初始状态再读putVal的完整流程结合tabAt/casTabAt理解无锁和加锁的交界然后重点读treeifyBin和treeify把树化的两个条件背下来接着读addCount和transfer这部分最复杂建议画一张状态流转图辅助理解最后读get和size/mappingCount理解弱一致性的具体来源。每读完一个部分就回答三道相关面试题记忆效果比单纯看视频好得多。另外推荐对比阅读HashMap的resize和TreeNode拆分逻辑因为ConcurrentHashMap的迁移算法大量借鉴了HashMap的实现。两边的差异点在于并发控制但链表拆分为高位链和低位链的思路完全一致。理解了HashMap的resize再回头看transfer中的链表拆分就好懂了。6.3 测试验证与性能观察的手段读源码的同时建议写一点测试代码验证结论。可以用多线程并发put同一批key然后观察size返回值的波动范围直观体会弱一致性。也可以用JFR或VisualVM观察锁竞争情况在并发量逐渐升高时看看哪些线程停留在synchronized。实测中会发现当线程数超过16、key分布均匀时大多数put都是在空槽上CAS真正的锁竞争很少这也是ConcurrentHashMap在Java 8中表现优秀的原因。性能对比也很直观在相同数据量下分别用Hashtable、Collections.synchronizedMap和ConcurrentHashMap做并发读写后者的吞吐优势在高并发场景下非常明显。改用自己的业务key做压测时要注意如果业务key分布不均匀比如很多key的hashCode低位相同ConcurrentHashMap容易退化成长链表性能骤降必要时需要自行优化key的hash计算。我在实际项目中遇到过redis key映射Map在并发写时CPU飙升的问题最终排查发现是配置了错误的初始容量导致频繁扩容线程全部卡在扩容迁移调整容量后问题立刻消失。那时才真正意识到读源码不是面试刷题而是生产环境的救命工具。