做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 7:Segment锁分段思想
JDK 7的ConcurrentHashMap核心结构是一个Segment数组。每个Segment继承ReentrantLock,本身相当于一把独立的锁,内部维护一个HashEntry数组。默认创建16个Segment,这样理论上同一时刻能支持16个线程分别对不同Segment并发写,线程间互不干扰。
它的操作逻辑是:put一个key时,先通过hash确认落到哪个Segment,然后只锁住这个Segment,其他Segment上的读写在本次put期间完全不受影响。get操作则不同,因为HashEntry里的key和hash字段都被声明为final,value是volatile的,所以读的时候无需加锁,直接取volatile值就能保证可见性。这种“写锁分段、读不加锁”的设计在当时非常超前。
但Segment方案也有固有短板:一是Segment数组容量在构造时固定,理论上Map容量上限是Segment数量乘以每个Segment的容量,扩容时也只是对Segment内部的HashEntry数组扩容,整体扩展能力有限;二是锁粒度还是“桶段”级别,一段里面可能包含多个桶,竞争稍微激烈就会出现同一Segment内部互斥。到了JDK 8,这套设计被彻底推倒重来。
2.2 JDK 8:CAS + 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_BITS(0x7fffffff)按位与,保证结果为正数,因为负数hash已经被约定为特殊节点的标识(比如ForwardingNode的hash是MOVED=-1,TreeBin的hash是-2)。
为什么要做高位与低位的异或?因为桶位下标是用(n - 1) & hash计算出来的,而HashMap的容量永远是2的幂次方。如果只有低位参与寻址,那么当n较小时,高位的散列特性会完全丢失。比如n=16时,参与下标计算的只有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,拿到属于自己的一段区间。比如数组长度是1024,stride是16,那么一个线程领取到的就是下标1008到1023这一段的16个桶。
迁移单个桶时,如果桶内是链表结构,就遍历链表,按高低位分成两部分。这里有个非常巧妙的设计:因为数组容量翻倍,节点在新数组中的索引要么保持原下标i,要么变成i+oldCap。具体走哪个分支,只看节点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)返回null,B无法判断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之间有竞态,需要循环重试或使用merge:
map.merge(key, 1L, Long::sum);merge方法利用ConcurrentHashMap内部的原子更新机制,比get+put可靠得多。但当并发线程数非常高时,就算是ConcurrentHashMap也要在桶级别做CAS竞争,这时更适合使用LongAdder。
LongAdder的原理是把一个计数变量拆成多个Cell,每个线程更新时只操作自己命中的Cell,最后求和时把各Cell相加。这样冲突率远远小于单个计数器。我在一个日志上报组件里把ConcurrentHashMap计数切换到LongAdder之后,写入耗时直接降了一个数量级。不过LongAdder不是Map,如果需要按key维度统计,可以考虑ConcurrentHashMap<Key, 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意味着不写入。这些规则单独看都很明确,但组合在复杂的并发业务逻辑里就容易乱。把它的设计初衷和使用边界真正想明白,比记住任何源码细节都重要。