ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

ConcurrentHashMap源码拆解:并发锁、扩容机制与null值陷阱

ConcurrentHashMap源码拆解:并发锁、扩容机制与null值陷阱 做Java开发的朋友应该没人不认识ConcurrentHashMap。日常拿它做缓存、做并发统计、做状态记录看起来就是个“线程安全的HashMap”。但一旦上了线上环境你就发现它远比表面复杂。最近有同行在群里问“ConcurrentHashMap同时读写报null”我第一反应就是这哥们八成踩了compute、computeIfAbsent或者null值语义的坑。这篇文章不打算复述官方文档我会从源码层面把它的散列算法、锁设计、扩容机制、并发读写原理逐个拆开讲再集中复盘那几个把无数人坑过的“报null”场景。适合想把并发容器彻底吃透的中高级Java开发者也适合准备面试或想在并发编程上再精进一步的工程师。1. 从一次线上事故说起HashMap到底是怎么被玩坏的1.1 扩容死循环JDK 7头插法的锅很多新人对“HashMap线程不安全”没概念觉得顶多是丢数据嘛重启就好了。但如果在JDK 7及更早版本里让HashMap在并发put触发扩容最严重的情况是直接导致CPU飙到100%服务彻底卡死。原因是JDK 7的HashMap在扩容时采用头插法迁移链表。单线程下这没问题但多线程同时put触发resize时两个线程会对同一条链表进行transfer操作互相覆盖next引用最终在数组桶中形成环形链表。一旦后续get或put走到这个桶就会在遍历链表时陷入无限循环。我早年在线下压测时就复现过这种场景CPU直接被打满线程栈永久停在HashMap.get的调用链上连jstack都看得清清楚楚。这个坑在JDK 8的HashMap里已经被修掉了它改成尾插法避免成环但多线程put仍然会互相覆盖数据导致元素丢失、size不准确。所以“HashMap线程不安全”从来不是过去式它只是症状从死循环变成了更隐蔽的丢失。1.2 数据覆盖与丢失并发put的连锁反应即便没有死循环HashMap并发写入也一样会出问题。我举一个最常见的场景两个线程同时put两个不同的key碰巧它们的hash值落到同一个桶并且这个桶的链表已经有一定长度。线程A和线程B都拿到桶头节点然后各自在链头插入自己的Entry最后落地时后写的那个线程会把先写的覆盖掉。表面看没有异常但其中一个key就凭空消失了。更隐蔽的是size统计问题。HashMap的modCount在并发加1时没有原子性保障两个线程同时修改modCount可能只累加一次导致迭代器认为没有结构变化但实际数据已经变了。这种问题在缓存场景里最致命——你以为缓存里没有某个key数据库被反复穿透实际上数据早就在Map里躺着只是你永远get不到。1.3 Hashtable为什么被时代抛弃在ConcurrentHashMap之前Java官方给出的线程安全Map方案是Hashtable和Collections.synchronizedMap。它们的设计极其简单粗暴给整张表加一把大锁所有读操作和写操作都串行执行。这种方案的缺点不用多说并发一上来所有线程都在锁上排队吞吐量直线下降。拿它做高并发缓存基本属于自杀式设计。更重要的是它在读多写少的场景下也毫无优势因为读操作本身不修改任何数据但依然要被锁挡在门外。这与后来并发容器“读无锁”的设计理念背道而驰。于是Doug Lea大神在Java 5时代带来了ConcurrentHashMap第一次在Map实现里引入分段锁的思想到今天已经迭代了三个大版本成了并发编程教科书级别的样本。2. 两代ConcurrentHashMap的设计哲学2.1 JDK 7Segment锁分段思想JDK 7的ConcurrentHashMap核心结构是一个Segment数组。每个Segment继承ReentrantLock本身相当于一把独立的锁内部维护一个HashEntry数组。默认创建16个Segment这样理论上同一时刻能支持16个线程分别对不同Segment并发写线程间互不干扰。它的操作逻辑是put一个key时先通过hash确认落到哪个Segment然后只锁住这个Segment其他Segment上的读写在本次put期间完全不受影响。get操作则不同因为HashEntry里的key和hash字段都被声明为finalvalue是volatile的所以读的时候无需加锁直接取volatile值就能保证可见性。这种“写锁分段、读不加锁”的设计在当时非常超前。但Segment方案也有固有短板一是Segment数组容量在构造时固定理论上Map容量上限是Segment数量乘以每个Segment的容量扩容时也只是对Segment内部的HashEntry数组扩容整体扩展能力有限二是锁粒度还是“桶段”级别一段里面可能包含多个桶竞争稍微激烈就会出现同一Segment内部互斥。到了JDK 8这套设计被彻底推倒重来。2.2 JDK 8CAS synchronized的精细并发JDK 8的ConcurrentHashMap放弃了Segment回归到与HashMap一致的Node数组加链表/红黑树结构。并发控制也从“分段锁”变成了更精细的“桶锁”每个桶里面的头节点就是一把锁配合CAS做无锁更新。这个演变非常关键。锁粒度从“多个桶共用一个锁”细化为“每个桶独立一把锁”并发竞争面被大幅缩小。它实际上做了一道加法一道减法减法是把原来的分段结构简化成普通数组和HashMap的数据布局完全对齐加法是引入了synchronized锁Node头节点而不是依赖ReentrantLock。有人在网上争论synchronized是不是比ReentrantLock差其实JDK在synchronized上做过大量偏向锁、轻量级锁优化锁竞争稀疏时开销比ReentrantLock更小这里选synchronized属于非常务实的工程决策。与此同时JDK 8版本把红黑树引入Node数组的结构中当单个桶的链表长度超过8且数组容量不小于64时链表会转为红黑树把最坏情况下的查询复杂度从O(n)降到O(log n)。这一手是直接从HashMap抄过来的目的是防hash碰撞攻击。2.3 重写的背后锁粒度与性能的权衡为什么说JDK 8的重写工程价值极高我拿一个具体数字说明。JDK 7里如果有两个线程分别写两个key但这两个key恰好落在同一个Segment那么依然会互斥。而JDK 8中只要它们落在不同的桶位上就能真正并发执行。就算落在同一个桶锁竞争也只需要在这一条链路上解决不会波及全Map。我实测过一个内部网关项目从JDK 7的ConcurrentHashMap迁移到JDK 8的ConcurrentHashMap后在16线程并发写入的场景下吞吐量提升了近一倍。原因主要就是锁粒度细化。当然这只是一个粗略的观测毕竟不同服务的内存模型和CPU架构也有影响但方向很明确。从实现哲学上说JDK 8版ConcurrentHashMap已经不再是“一堆小Hashtable”而是一张真正意义上的并发散列表。它通过CAS完成无锁插入、通过volatile保证读可见性、通过synchronized实现安全性每一项都是并发工具包里的经典案例。3. 核心源码机制逐段拆解3.1 spread()扰动函数让散列更均匀ConcurrentHashMap在定位桶位之前会对key的hashCode做一次扰动运算核心代码如下static final int spread(int h) { return (h ^ (h 16)) HASH_BITS; }这里做的事情是把key的原始hash值的高16位与低16位做异或再将结果与HASH_BITS0x7fffffff按位与保证结果为正数因为负数hash已经被约定为特殊节点的标识比如ForwardingNode的hash是MOVED-1TreeBin的hash是-2。为什么要做高位与低位的异或因为桶位下标是用(n - 1) hash计算出来的而HashMap的容量永远是2的幂次方。如果只有低位参与寻址那么当n较小时高位的散列特性会完全丢失。比如n16时参与下标计算的只有hash的低4位哪怕key的高位五花八门只要低4位相同就全部挤进同一个桶。扰动函数通过异或把高位信息混入低位让散列分布更均匀从源头上降低碰撞概率。3.2 初始化与sizeCtl并发控制的指挥棒sizeCtl是ConcurrentHashMap里最核心的控制字段它的取值有多种含义取值含义0默认状态数组尚未初始化正数下一次扩容的阈值capacity * loadFactor-1正在初始化负数且非-1正在扩容-(1 参与扩容的线程数)初始化过程发生在第一次put时属于懒加载。多个线程可能同时发现table为null但只有一个线程能成功通过CAS把sizeCtl从0改成-1然后执行数组创建。其他线程看到sizeCtl已经是-1就主动让出CPU等待初始化完成。这样避免了多线程重复创建数组导致的内存浪费和数据错乱。我之前见过一些团队在初始化ConcurrentHashMap时手动指定超大容量结果发现内存占用远超预期这是因为并发Map的容量值会被调整为2的幂次方并不是说你传1000就只占1000个槽位。这里也提醒大家预估容量时尽量用预计元素数 / 0.75f 1去换算具体我在后面选型章节再展开。3.3 put流程全程回放从CAS到锁put方法是整个ConcurrentHashMap最精华的部分完整流程大致如下先说空值检查。putVal的第一步就是判断key或value是否为null是则直接抛出NullPointerException。这是ConcurrentHashMap刻意设置的约束不是疏忽后面我会单独解释。第二步是懒初始化。如果table为空调用initTable。初始化用CAS抢占保证只有一个线程真正执行数组分配。第三步是核心的桶遍历逻辑。通过(n - 1) spread(key.hashCode())定位到目标桶如果桶位为null说明当前还没有任何节点用Unsafe的compareAndSwapObject尝试直接把新Node放进去。这一步全程无锁是并发性能的关键。CAS失败说明有其他线程抢先写入了就进入下一轮循环重新处理这件事。如果桶位头节点的hash等于MOVED-1说明这个桶正在被扩容迁移。当前线程不会傻等而是调用helpTransfer加入扩容队伍一起帮忙搬数据。如果桶位不为空则用synchronized锁住头节点然后遍历链表或红黑树。找到相同key就更新value找不到就插入新节点。插入完成后如果链表长度超过树化阈值8并且数组容量不低于64则转换为红黑树。最后通过addCount更新Map的元素数量而这已经是另一个复杂的并发统计问题了。这里我补充一个实操细节put流程里用了循环加CAS的乐观重试模式这在并发代码里非常常见。简单说就是“先试一把再说失败了再重新读最新状态”。相比上来就加锁这种方式在竞争不激烈时开销极小。从代码审美角度来说JDK 8的put逻辑刻意把无锁路径和锁路径分开绝大多数put其实走的是“桶空直接CAS”的捷径只有碰撞时才会升级到锁。这也解释了为什么高并发写场景里只要扩容不频繁ConcurrentHashMap的写入吞吐非常可观。4. 扩容迁移多线程分桶搬运的工程艺术4.1 扩容触发的三个条件ConcurrentHashMap的扩容不像HashMap那样“一个线程干完全部”而是设计成多线程协同。触发扩容的条件主要有三个第一新增元素后元素总数超过sizeCtl阈值也就是容量乘以负载因子。第二某个桶链表长度达到8但数组容量小于64此时不会树化而是触发扩容。第三有人调用treeifyBin时发现容量不够转而走扩容逻辑。扩容一旦被触发会创建原数组两倍大小的nextTable并进入transfer方法。整个扩容过程对调用线程来说不是阻塞等待而是“自己主动参与干活”干不完还会有后来的线程继续接力。4.2 ForwardingNode与桶转移标记扩容过程中原table中已经被搬走的桶位会被放上一个特殊的节点——ForwardingNode它的hash固定为MOVED-1内部持有nextTable的引用。这个节点有两个作用。第一个作用是标记任何线程看到这个节点就知道这个桶已经搬完了数据在new数组中。此时put操作会直接调用helpTransfer加入扩容get操作会顺着ForwardingNode里的nextTable引用去新数组中查找读操作完全透明。第二个作用是推进迁移进度。transfer方法中定义了一个全局的transferIndex表示尚未迁移的桶区间。每个线程过来先领取一段连续的桶位默认按stride分批从后往前搬运。每搬完一个桶就把它替换成ForwardingNode并CAS更新transferIndex。这样大量线程可以并行领取不同区段的任务互相之间只用CAS竞争一个游标不会锁死。4.3 多线程如何分摊迁移工作我简单描述一下迁移的实际过程。线程进入transfer之后会通过CAS把transferIndex减去一个stride拿到属于自己的一段区间。比如数组长度是1024stride是16那么一个线程领取到的就是下标1008到1023这一段的16个桶。迁移单个桶时如果桶内是链表结构就遍历链表按高低位分成两部分。这里有个非常巧妙的设计因为数组容量翻倍节点在新数组中的索引要么保持原下标i要么变成ioldCap。具体走哪个分支只看节点hash新增的那个bit是0还是1。所以迁移过程中只需要把链表拆成low链和high链两条然后分别放到原位置和原位置oldCap的位置不需要重新计算每个节点的hash。这个“原地拆链”的技巧在JDK 8中代码表现得很清晰效率极高。迁移完再把桶位替换成ForwardingNode原数组的这个桶就对外宣告完成。我在看源码时最大的感触是扩容本身是一个极其复杂的状态机但代码里几乎没有用锁去保护全局状态全靠CAS加volatile的配合。这也是为什么很多并发专家建议源码学习者把ConcurrentHashMap的transfer当成经典案例反复读。5. 高并发读写特性与“同时读写报null”排查实录5.1 get为什么可以无锁ConcurrentHashMap的get方法全程不加锁但依然能拿到正确的值。它依靠的是三重保障Node的value和next字段是volatile的保证读到的不是过期数据table桶位通过Unsafe的getObjectVolatile加volatile语义读取防止读到的是被CPU缓存污染的旧引用整体数组引用table本身也是volatile确保扩容时能看到最新的nextTable。这里有个容易误会的点volatile只能保证可见性和有序性不能保证复合操作的原子性。get期间如果正好有另一个线程在put或扩容get可能读到旧值也可能读到新值但这符合ConcurrentHashMap的弱一致性设计。它不是强一致容器它追求的是吞吐和最终正确的平衡。那种“读和写必须一刻不差看到同一个值”的场景本质上不该选它应该考虑ConcurrentMap之外的方案。5.2 不允许null key/value的真正原因ConcurrentHashMap不允许null key和null value这一点和HashMap完全不同。原话是Doug Lea解释过的如果在非并发Map里返回null你可以理解为“这个key不存在”但如果在并发Map里返回null你无法分辨是“key不存在”还是“key对应的value本来就是null”。这种二义性在并发环境中会放大成不可预估的业务逻辑错误。而且get方法不加锁如果允许value为null那么线程A写入map.put(k, null)的同时线程B执行map.get(k)返回nullB无法判断A到底有没有写入成功。为了语义清晰干脆在源头上禁止null。这个约束在put和compute系列方法里体现得完全不同。put直接抛NullPointerException但computeIfAbsent如果回调函数返回null它什么都不记录直接返回null不会抛异常。这就给后面要讲的“报null”埋下了伏笔。5.3 同时读写报null的典型案例与解决思路结合“concurrenthashmap 同时读写 报null”这个热搜点我整理了实际工作中最常见的三个报null场景每个都是我见过或踩过的。场景一业务代码里先get判断再put典型写法是if (map.get(key) null) { map.put(key, buildValue()); }这个写法的问题是判断和put不是原子操作。两个线程同时get到null然后各自put后写的覆盖前写的但业务上你以为是“不存在才写入”结果数据被覆盖后逻辑就紊乱了。如果buildValue内部依赖某些前置状态还可能对外表现为空值异常或字段为null。正确的做法是用computeIfAbsent让ConcurrentHashMap保证一个key的创建操作只执行一次。场景二用compute或computeIfAbsent时在回调函数里往同一个Map做了嵌套更新。比如这样map.computeIfAbsent(key1, k - { return map.computeIfAbsent(key2, kk - value); });JDK 8的ConcurrentHashMap对嵌套更新同一个Map会检测到并抛出IllegalStateException或递归更新异常。这个异常往往在线上表现为“某个计算逻辑直接失败后续拿到结果时是null”。解决办法是避免在compute系列回调里操作同一个并发Map改成先算好再放入或者把并发Map拆成两级缓存。场景三用compute(key, remappingFunction)更新已有键但remappingFunction返回了null。此时ConcurrentHashMap会认为你要删除这个键于是直接把条目移除。如果你的业务语义是“计算不出来就留空”代码就会在后续读这个key时得到null如果你又没处理null就会一路把异常带出去。所以使用compute前一定要想清楚回调返回null等价于删除条目不是“设成null”。5.4 compute/computeIfAbsent的副作用与坑我再单独说一下compute系列的正确用法。它的初衷是替代“先判断后操作”的非原子组合但副作用也不少。第一个坑是锁的时间跨度。compute在回调函数执行期间会持有该桶的锁如果回调里做了耗时操作比如调用远程接口、查数据库、执行复杂计算那么其他访问同一个桶的线程都会阻塞。我见过有人拿computeIfAbsent做缓存加载结果每个key首次加载都要查数据库所有访问同一桶的线程全部排队。这种情况应该用putIfAbsent(key, computedValue)先把值算好再放。第二个坑是回调抛异常时的状态。如果compute的回调抛出了运行时异常当前映射不会被修改异常会原样抛出。很多人以为Map会保持初始状态结果就是业务方法直接异常退出连兜底的机会都没有。第三个坑是死锁。如果两个线程分别在compute回调里互相等待对方持有的锁就可能出现死锁。比如线程A持有key1的锁等待key2线程B持有key2的锁等待key1。虽然ConcurrentHashMap内部有递归更新检测但跨Map的情况它管不了。所以我的建议是compute系列只适合轻量、快速的逻辑比如“读取当前值加1再放回去”这种不适合重量级回调。重度逻辑请自己控制锁或者预先计算。6. 项目实战从选型到调优的落地建议6.1 三类并发Map的适用场景对比选型时很多人只盯着“线程安全”这一个维度其实并发等级不同方案完全不同场景推荐方案理由单线程读写无并发HashMap性能最好实现简单低并发读写读多写少Collections.synchronizedMap代码简单锁竞争可接受多线程高并发读写ConcurrentHashMap桶级锁无锁读吞吐最优高并发只追加不删除ConcurrentHashMap或CopyOnWriteArrayList看具体数据结构需求我特别想强调一点不要在高并发场景里为了图省事用Collections.synchronizedMap。它的锁是Map对象级别的写操作锁整个Map读操作也要锁。并发量一上去锁竞争会成为整个服务的瓶颈而且你还不太好定位因为它不会直接抛异常只是TPS上不去。反过来如果并发量极低比如只有2-3个线程偶尔读一下配置那用synchronizedMap完全没问题。用ConcurrentHashMap反而因为内存占用更大、代码语义更严格不能存null显得笨重。6.2 容量预估与参数调优ConcurrentHashMap的构造参数有三个initialCapacity、loadFactor、concurrencyLevel。JDK 8里concurrencyLevel已经被重新解释成“用于计算初始容量的提示”不再是分段数量。容量预估有一条比较实用的经验公式初始容量至少要大于预估元素数 / 0.75。因为默认负载因子是0.75如果不预估或者设太小Map会在写入过程中频繁扩容。扩容虽然多线程协作但仍有不小的性能开销高并发下能不扩就不扩。举例来说如果你预估要放10000个元素可以设置initialCapacity为10000 / 0.75 1约13334取整后实际容量会调整到16384。这样基本全程无需扩容。还有一个容易踩的细节即使你设置了initialCapacity数组初始化的真正时间点仍然是第一次put而不是new对象那一刻。也就是说构造ConcurrentHashMap本身几乎不耗内存真正的内存占用从第一次写入才开始。如果你初始化后就丢在内存里不写入它不会占用一个巨大的连续数组空间。6.3 高并发计数场景LongAdder替代方案很多人拿ConcurrentHashMap做计数器比如统计接口调用次数map.put(key, map.getOrDefault(key, 0) 1)。这种写法在高并发下其实有性能问题因为get和put之间有竞态需要循环重试或使用mergemap.merge(key, 1L, Long::sum);merge方法利用ConcurrentHashMap内部的原子更新机制比getput可靠得多。但当并发线程数非常高时就算是ConcurrentHashMap也要在桶级别做CAS竞争这时更适合使用LongAdder。LongAdder的原理是把一个计数变量拆成多个Cell每个线程更新时只操作自己命中的Cell最后求和时把各Cell相加。这样冲突率远远小于单个计数器。我在一个日志上报组件里把ConcurrentHashMap计数切换到LongAdder之后写入耗时直接降了一个数量级。不过LongAdder不是Map如果需要按key维度统计可以考虑ConcurrentHashMapKey, LongAdder的组合。6.4 最后的几点实践经验文章最后分享几个我长期实战中沉淀下来的操作习惯。第一能不用compute就不用compute优先考虑putIfAbsent和merge。这两个方法的语义更清晰出现问题时更容易推理。第二千万不要在compute的回调函数里做远程调用、数据库操作和循环等待。那会是整个并发Map的隐形灾难。第三如果确实需要Map的value允许null可以考虑用独立的sentinel对象替代或者用Optional.empty()包装而不是把null直接塞进ConcurrentHashMap。第四排查“同时读写报null”问题时第一步不是去翻了ConcurrentHashMap的源码而是先确认是不是业务代码把“返回null”当成了“写入null”。这两个概念很容易混淆尤其是在使用computeIfAbsent和compute函数时。第五源码阅读要有耐心ConcurrentHashMap的代码量虽然不大但每一行都值得反复读。建议从put方法入手然后是transfer和addCount最后再看reservation、CounterCell这些辅助机制。我个人在实际排查并发问题时的体会是ConcurrentHashMap的“报null”大多不是它本身的问题而是使用方没有理解它的语义边界。文档里说得清楚明白地写着“不能存null”compute的null返回值意味着删除键值computeIfAbsent的null意味着不写入。这些规则单独看都很明确但组合在复杂的并发业务逻辑里就容易乱。把它的设计初衷和使用边界真正想明白比记住任何源码细节都重要。
返回列表