做Java开发这么多年,面试时被问ConcurrentHashMap几乎是躲不开的环节,而Java 8版本的实现与Java 7相比变动极大,很多人停留在“分段锁”的认知里,甚至背了八股文却不知道底层数组、链表、红黑树是怎么配合工作的。这篇文章就把Java 8中ConcurrentHashMap的底层数据结构、成员变量设计、put/get/扩容等核心方法执行流程从头到尾拆一遍,并结合实际开发中常见的坑给出排查思路。不论你是准备面试还是想真正理解并发容器,这都是一份可以直接对照源码阅读的手册。
先说明一点:本文所有源码分析基于JDK 8的官方实现(java.util.concurrent包),分析的侧重点是结构和流程,而不是逐行注释。读的时候建议打开IDE里的ConcurrentHashMap源码,边看边对照,效果最好。
1. 底层数据结构全解析:从分段锁到CAS+synchronized的演进
1.1 核心成员变量与内存布局
Java 8的ConcurrentHashMap抛弃了Java 7的Segment分段锁结构,转而使用“数组+链表+红黑树”的存储结构,配合CAS和synchronized来保证并发安全。先看几个最关键的成员变量,它们在内存布局中承担不同职责。
table是核心存储数组,类型是Node<K,V>[],默认初始容量为16。每个数组槽位可能是null、一个Node节点、一棵红黑树(TreeBin)或一个ForwardingNode。nextTable只有在扩容时才会非空,指向扩容后的新数组,它的长度通常是旧数组的两倍。sizeCtl是控制标识符,它承载了多种状态,后面扩容部分会详细分析。transferIndex是扩容时线程任务分配的索引,记录还没迁移的槽位范围,多个线程通过CAS修改它来各自认领区间。baseCount用于记录元素个数的基础值,更新时优先用CAS,CAS竞争激烈时使用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锁住链表头节点或红黑树根节点,并发度从固定的16(Segment数量)提升到了数组长度级别,理论上扩容后并发度更高。
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_BITS(0x7fffffff)做与运算,确保结果为正数,从而与特殊节点的负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尚未初始化。
- -1:table正在初始化,某个线程通过CAS把sizeCtl从0改为-1,其他线程发现-1就让出CPU。
- -(1+n):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(低位)和i+n(高位,n是旧数组长度)。判断依据是节点的hash值在旧容量对应bit位上是0还是1,这一点与HashMap的resize完全一致。拆分成两条链后,旧数组的i位置放置ForwardingNode,新数组的i位置和i+n位置分别放入两条链,既保证了迁移期间其他线程读写的一致可见,又避免了逐个节点插入带来的性能损失。
所有区间迁移完成后,会做最终检查,确认旧数组中所有槽位都是ForwardingNode,然后把table指向nextTable,sizeCtl设置为新容量的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上做耗时的computeIfAbsent,QPS下降极其明显。解决思路是计算函数里只做内存操作,远程调用放到外面,或者用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飙升的问题,最终排查发现是配置了错误的初始容量导致频繁扩容,线程全部卡在扩容迁移,调整容量后问题立刻消失。那时才真正意识到读源码不是面试刷题,而是生产环境的救命工具。