☰
ConcurrentHashMap为什么不允许null?源码与并发语义深度解析
2026/10/14 4:24:10 网站建设 项目流程

先交代一下背景。之前排查一个线上问题,看到同事在代码里往ConcurrentHashMap里写入了一个 null 值作为"占位符",结果应用启动几分钟就抛了NullPointerException。他当时很困惑,说"HashMap 不是可以放 null 吗,为什么换成ConcurrentHashMap就报错?"

这个问题其实很有代表性。ConcurrentHashMap的 key 和 value 都不能为 null,这在 JDK 文档里写得清清楚楚,但很多开发者只知道"不能用",却说不清"为什么不能用"。这次我就把这个问题彻底拆一遍,从使用现象、源码实现、并发语义三个维度讲透,顺带把computeIfAbsent、merge这些容易被 null 坑到的方法也一起梳理清楚。不管你是面试准备、源码阅读还是线上排障,这篇应该都能帮上忙。

1. 先看现象:同样叫 Map,HashMap 敢收 null,ConcurrentHashMap 直接翻脸

先说最直观的使用层面差异。HashMap对 null 的容忍度很高,key 和 value 都可以为 null,而且有专门的处理逻辑。比如:

Map<String, String> hashMap = new HashMap<>(); hashMap.put(null, "nullKey"); hashMap.put("nullValue", null); System.out.println(hashMap.get(null)); // 输出 nullKey System.out.println(hashMap.get("nullValue")); // 输出 null

这段代码在 HashMap 里运行得很正常。再看 ConcurrentHashMap:

Map<String, String> chm = new ConcurrentHashMap<>(); chm.put("key", null); // 直接抛 NullPointerException

一执行,NullPointerException立刻抛出来,没有任何商量余地。Hashtable 同样是这个脾气,虽然它的线程安全实现方式很古老(方法级synchronized),但对于 null 的拒绝和 ConcurrentHashMap 是一致的。

这里的关键点在于:为什么同样实现 Map 接口,HashMap 就允许 null,而 ConcurrentHashMap 和 Hashtable 都不允许?

如果去翻 JDK 的类注释,ConcurrentHashMap的文档里有一句话说得比较直接:不允许使用 null 作为 key 或 value,理由是"无法区分一个 null 返回值到底是表示 key 不存在,还是存在但 value 为 null"。这个理由如果能理解透,今天的问题就解决了一大半。

顺带说一句,ConcurrentHashMap的 null 限制是它的核心设计之一,不是实现层面的"漏网之鱼"。你去看它早期的putVal方法,第一行就是if (key == null || value == null) throw new NullPointerException();,这个检查在后续版本里一直保留着,属于有意为之的强制约束。

2. get 返回 null 到底意味着什么:一个语义歧义引发的连锁反应

现在我们把问题往深处推一层。Map 的get方法有一个天然缺陷:当它返回 null 时,你无法知道 null 是"这个 key 不存在"还是"这个 key 存在,但它的 value 就是 null"。

在单线程的 HashMap 场景里,这个缺陷是可以补救的。get返回 null 之后,可以再用containsKey确认一下:

if (map.get(key) == null) { if (!map.containsKey(key)) { // key 确实不存在 } else { // key 存在,value 本来就是 null } }

注意这个补救逻辑有一个前提:在你调get和调containsKey之间,map 的状态不能发生变化。单线程下这个前提天然成立,但到了并发环境里,问题就来了。

并发环境下,get和containsKey是两个独立的操作,中间完全可能插入其他线程的put或remove。比如线程 A 执行完map.get(key)发现返回 null,正打算执行containsKey做二次判断,线程 B 已经把这个 key 删掉了。等线程 A 再调containsKey,得到的结果对应的已经是线程 B 操作之后的 map 状态,而非刚才get对应的状态。

如果 map 允许 value 为 null,get返回 null 带来的不确定性会更严重。你以为get(key) == null表示要初始化一个值,实际上那个 key 早就被别人塞了一个 null 进去,你的初始化操作就会覆盖掉已有的"空值占位",业务逻辑直接出错。

ConcurrentHashMap的设计者选择从源头掐灭这个问题:value 不允许为 null,意味着 get 返回 null 就一定有唯一解释——key 不存在(或者曾经存在但已经被删了)。这个语义在很多并发场景里非常宝贵,它让你可以放心地把 null 当作"没有数据"的信号。

同样的逻辑也适用于 key。ConcurrentHashMap的get方法内部第一步就要调用key.hashCode()去计算哈希,如果 key 为 null,连 hashCode 都调不了,直接 NPE。所以 key 的限制可以说是被迫的,value 的限制则是主动设计。

3. 拜托,别再用"先 get 再 put"的模式写并发代码

语义歧义是一方面,更实际的问题是并发编程中最经典的一个坏味道:先检查后操作(check-then-act)。

如果你从 HashMap 时代带过来的习惯是这种写法:

if (map.get(key) == null) { map.put(key, value); }

在单线程下这只是性能问题(多做了一次查询),但在并发环境下这就是教科书级别的竞态条件。两个线程可能同时发现get返回 null,然后同时执行put,后写入的覆盖先写入的,业务数据悄悄丢了,而且很难排查。

ConcurrentHashMap提供了putIfAbsent、computeIfAbsent、compute、merge这些原子方法,就是为了替代这种"先检查再操作"的模式。以putIfAbsent为例:

map.putIfAbsent(key, value);

这个操作要么成功写入,要么发现 key 已存在且不覆盖,整个过程是原子的,不需要你去手动加锁。

但这里出现了一个新的问题:如果 ConcurrentHashMap 允许 value 为 null,putIfAbsent(key, null)这个操作应该怎么定义?你说它"不存在就放进去",那放进去的是一个 null;你说"null 表示不操作",那这个方法的语义又跟 put 的 null 校验自相矛盾。不管怎么定义,都会让方法的语义变得模糊。

最干净的做法就是:不允许 null。这样一来,所有带默认值语义的原子方法都可以放心地以"null 就代表数据不存在"为基准去实现,用户也不用担心自己传入的 null 会不会被当作有效值存进去。

在 JDK 8 之后的版本里,computeIfAbsent是更常用的初始化方式:

map.computeIfAbsent(key, k -> new ValueObject());

它只在 key 不存在时才执行映射函数,并且在映射函数执行期间对该 key 的并发操作会被适当阻塞(涉及 bin 级别的锁)。但它有一个非常容易踩的坑:如果映射函数返回 null,computeIfAbsent不会向 map 里写入任何内容。这个返回值 null 和 ConcurrentHashMap 不允许 value 为 null 是两回事——前者是"计算结果为空,所以不记录",后者是"禁止把 null 作为 value 存储"。很多人把这两个搞混,后面第 5 节我会详细展开。

4. 翻源码:一个 null 检查,撑起了多少并发复杂度

从使用层面和并发语义层面说完,我们再落到源码实现上看。ConcurrentHashMap内部的存储结构是 Node 数组加链表/红黑树,节点定义大致如下:

static class Node<K,V> implements Map.Entry<K,V> { final int hash; final K key; volatile V val; volatile Node<K,V> next; }

key 是 final 的,value 是 volatile 的。每次写入节点的 value 都要通过 CAS 或者 bin 锁来保证可见性。

putVal方法的入口有这样一个硬校验:

if (key == null || value == null) throw new NullPointerException();

这个检查除了防止语义歧义,还有一个很实际的作用:它让val字段在整棵链表/树的维护过程中,永远不需要处理"null 是否合法性"的分支。

你可能会觉得,不就是多一个if (value != null)的判断吗,能增加多少复杂度?但放到整个 ConcurrentHashMap 的实现里看,情况完全不同。

第一,ConcurrentHashMap使用 CAS 做无锁操作,比如casTabAt和tabAt这类操作直接操作内存槽位。如果 value 允许为 null,那么 CAS 时就要额外判断"当前值是 null 还是在的",这种判断会污染所有无锁路径的实现逻辑。

第二,扩容过程中会生成ForwardingNode这样的特殊节点作为占位标记。这些特殊节点的存在本来就要求代码在遍历时"看到 null 就可能是遇到空桶或正在迁移的桶",如果再混入合法的 null value,遍历逻辑的分支会变得极其复杂。

第三,序列化和反序列化也是一个麻烦。在writeObject里遍历所有节点写入 key 和 value 时,如果 value 可能为 null,读取端需要额外的逻辑来区分"读到 null 表示这个 entry 是 null value"以及"读到 null 表示遍历结束"。虽然传参时做了限制,但凡是涉及外部数据和反射的场景,少一个 null 约束就多一类边界问题。

有人可能会说:HashMap 里 value 允许 null,也没见实现复杂到哪里去。那是因为 HashMap 不是线程安全的,它不需要考虑"另一个线程正在同时读写 map"时 null 被当作特殊信号处理的问题。CHM 的代码里 null 既要承担"空槽位""迁移标记""删除标记"等多重重任,如果再让它成为一个合法的业务值,整个实现的复杂度会翻好几倍。

所以源码层面拒绝 null,与其说是一个安全策略,不如说是在不允许 null 的前提下,很多并发算法才能保持简洁和高效。

5. 别小看这几个返回 null 的方法:computeIfAbsent、computeIfPresent、merge 的坑

围绕 null 还有一个高频踩雷区,就是 JDK 8 加入的compute系列方法。很多人在这些方法上栽过跟头,因为它们的语义和普通put不一样,尤其跟 null 打交道的方式五花八门。

第一个方法是computeIfAbsent。刚才说过,如果映射函数返回 null,表示"这个 key 不应该有任何映射",它不会被写入 map。看个例子:

ConcurrentHashMap<String, List<String>> chm = new ConcurrentHashMap<>(); chm.computeIfAbsent("k1", key -> null); System.out.println(chm.containsKey("k1")); // false

这个行为有一点反直觉:你调用了computeIfAbsent("k1"),但执行完之后 map 里根本没有 k1。它的设计意图是让你在映射函数内部做条件判断——满足条件才返回对象,不满足就返回 null,表示"这次不记录"。这个特性其实是挺好的,很多缓存场景下可以省掉一层containsKey判断。

但如果你不了解这个语义,很容易写出这样的代码:

chm.computeIfAbsent(key, k -> buildValue(k)); // buildValue 返回 null System.out.println(chm.get(key)); // 还是 null

你以为是初始化好了,实际什么都没存进去,下次get还是 null,你可能会误以为值被并发覆盖了,结果排查半天。

第二个是computeIfPresent。它只对"key 已存在"的情况执行映射函数,并且如果映射函数返回 null,对应的条目会被移除:

ConcurrentHashMap<String, String> chm = new ConcurrentHashMap<>(); chm.put("key", "old"); chm.computeIfPresent("key", (k, v) -> null); System.out.println(chm.containsKey("key")); // true

等等,这里containsKey返回的是 true 吗?不对,它返回的是 false。computeIfPresent的映射函数返回 null 表示删除条目。这张表我整理一下:

方法当 key 不存在时当 key 存在时映射函数返回 null 的后果
computeIfAbsent执行映射函数并写入结果直接返回现有值,不执行函数不写入任何内容,相当于忽略
computeIfPresent不执行映射函数,返回 null执行映射函数,用结果覆盖旧值删除该 key 的条目
compute执行映射函数,正常写入执行映射函数,用结果覆盖旧值删除该 key 的条目(如果存在),不存在则不产生任何映射
merge使用 value 和旧值执行函数同左删除条目或不做任何写入

merge这里值得多说一句。它的重映射函数接收两个参数:旧值和传入的新值。如果旧值不存在,它不会调用映射函数,直接把传入的 value 写进去。但如果映射函数返回 null,同样会触发删除逻辑。

这种"返回 null 即删除"的语义在很多场景下确实方便,比如实现一个并发计数器:

chm.merge(key, 1L, (oldVal, newVal) -> oldVal + newVal);

但如果有人在这个函数里不小心返回了 null,counter 就会被悄悄删掉,下次 merge 又重新从 1 开始计数,数据直接失真。

我在实际项目里遇到过类似的线上事故。当时是某服务用ConcurrentHashMap<String, AtomicInteger>做指标统计,某同事在 merge 的 lambda 里写了一个累加逻辑,但有一类数据被判定为"无效"后直接返回 null,结果这一类的统计值全部被清空,监控面板上的数据忽大忽小,排查了两天才定位到是 merge 返回 null 引发的条目移除。

这里给一个实用的建议:遇到compute系列方法时,先明确回答三个问题——key 是否存在?映射函数接收什么参数?返回 null 意味着什么?这三个问题的答案能帮你避免绝大多数坑。

6. 如果面试官追问"为什么",把答案分成三层

这个问题在面试里出现的频率非常高,而且经常作为连环追问的起点。你光背结论"因为 get 返回 null 会产生歧义"是不够的,最好能按层次组织答案。

第一层,从 API 语义讲。ConcurrentHashMap.get(key)返回 null 应当是"key 不存在"的唯一解释。如果允许 value 为 null,那么 null 就有两种含义,调用方无法直接判断,必须依赖containsKey做二次确认,而二次确认在并发环境下本身就是不可靠的。这是最核心、最容易表达的一点。

第二层,从并发原子性讲。ConcurrentHashMap 提供的putIfAbsent、computeIfAbsent等方法,其语义都建立在"null 代表没有值"这个前提上。允许 null value 会让这些原子方法的语义混沌不清——putIfAbsent(key, null)到底是"放入 null"还是"什么都不做"?设计者选择用禁止 null 来保持原子方法的语义纯洁。

第三层,从内部实现讲。ConcurrentHashMap 大量使用 CAS、ForwardingNode 迁移标记、空槽位判断等机制,null 已经承担了多种"信号"职责。如果它同时可以是业务值,遍历、扩容、序列化的分支复杂度都会成倍上升,而且在无锁路径上引入可空值会让并发正确性更难证明。

这三层由浅入深,既照顾了提问者的意图,也展示了你对并发容器设计哲学的掌握。如果面试官顺势追问"HashMap 为什么又允许 null",你可以反向对比:HashMap 不做线程安全承诺,它的get返回 null 即使产生歧义,也只需要用户自己在单线程环境下通过containsKey消解。这是一个很合理的"设计让步"。

还有一点可以主动提出来:Hashtable 同样不允许 null。这说明"线程安全的 Map 不允许 null"是一个延续了很久的共识,只不过 Hashtable 性能差被 ConcurrentHashMap 替代了,所以很多人才会把目光集中在后者身上。

7. 业务里真的需要"值为空"的语义时,怎么办

最后一个话题,也是很多人实际会遇到的:业务模型里确实有"key 存在,但没有值"的需求,怎么用 ConcurrentHashMap 表达。

第一种方案是使用哨兵对象。定义一个共享的静态空对象:

private static final Object EMPTY_OBJECT = new Object(); chm.put(key, EMPTY_OBJECT);

读取时通过引用相等判断是否是空哨兵。代价是每次读取要做一个引用比较,但语义非常明确。如果 value 的类型是固定的,也可以定义一个该类型的空值常量,效果相同。

第二种方案是单独维护一个 Set 记录"已存在但值为空"的 key。写入时先put(key, defaultValue),同时在 Set 里登记,读取时先查 Set。这个方案的问题在于 Set 和 Map 之间的一致性很难维护,并发下很容易出现一个结构已更新、另一个结构还没来得及更新的窗口期,我不是很推荐,除非你的场景对一致性的要求非常宽松。

第三种方案是用 Optional 作为 value 类型。Optional 本身是一个普通对象,可以用Optional.empty()表示空值:

chm.put(key, Optional.empty());

这样既能区分"key 不存在"和"key 存在但值为空",又不需要自定义哨兵。代价是每次读写多一层 Optional 的包装和拆解,性能敏感的热点路径上需要权衡。

我个人在实际项目中的体会是:如果这个"空值"真的需要被存进 ConcurrentHashMap,绝大多数情况下是设计上出了问题——你可以重新审视一下业务模型,看能不能用默认值、空集合或者延迟初始化来替代。真的要存,优先选哨兵对象,它的可读性和性能是三种方案里最平衡的。把 null 语义藏在 map 里,短期写着舒服,长期排查问题时会相当痛苦。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询