为什么 ConcurrentHashMap 对 null 说“不”?
原因详细说明重要性并发二义性get 返回 null 无法区分"key 不存在"和"value 为 null"⭐⭐⭐⭐⭐原子性缺失get + containsKey 不是原子操作,状态会在两次调用间变化⭐⭐⭐⭐⭐内部机制冲突内部使用特殊节点(ForwardingNode 等)其 key/value 为 null⭐⭐⭐⭐设计一致性与 Hashtable 保持一致,避免混淆⭐⭐⭐性能考量避免额外的 nu
为什么 ConcurrentHashMap 对 null 说“不”?
|
🌺The Begin🌺点点关注,收藏不迷路🌺
|
1. 引言:一个让无数开发者困惑的设计
HashMap<String, String> hashMap = new HashMap<>();
hashMap.put(null, "nullKey"); // ✅ 允许
hashMap.put("nullValue", null); // ✅ 允许
ConcurrentHashMap<String, String> concurrentHashMap = new ConcurrentHashMap<>();
concurrentHashMap.put(null, "nullKey"); // ❌ NullPointerException
concurrentHashMap.put("nullValue", null); // ❌ NullPointerException
同样是 Map 接口的实现,为什么 HashMap 欣然接受 null,而 ConcurrentHashMap 却坚决拒绝?
这个设计背后,隐藏着并发编程中关于二义性和安全性的深刻考量。本文将深入剖析这个设计决策背后的原因。
2. 一图看懂:问题的核心
3. 二义性问题:null 返回值意味着什么?
3.1 HashMap 中的二义性
HashMap<String, String> map = new HashMap<>();
// 场景1:key不存在
map.get("notExist"); // 返回 null
// 场景2:key存在,但value为null
map.put("exist", null);
map.get("exist"); // 返回 null
问题:调用者无法区分返回的 null 究竟代表什么。
String value = map.get("someKey");
if (value == null) {
// 这是 key 不存在,还是 value 就是 null?
// 无法判断!
}
为了解决这个问题,HashMap 提供了 containsKey() 方法:
if (map.containsKey("someKey")) {
// key存在,value可能为null
} else {
// key不存在
}
3.2 并发场景下的致命问题
在单线程环境中,containsKey() 可以解决二义性问题。但在并发环境中,问题变得复杂:
时间线分析:
| 时间 | 线程1 | 线程2 | Map状态 |
|---|---|---|---|
| T1 | get(key) → null | key不存在 | |
| T2 | put(key, “value”) | key已存在,value=“value” | |
| T3 | containsKey(key) → true | 误判! |
结果:线程1 无法确定自己拿到的 null 究竟是"key 不存在"还是"value 为 null",因为状态在两次调用之间发生了变化。
4. 为什么 HashMap 允许 null?
HashMap 的设计者 Doug Lea 和 Josh Bloch 认为,在单线程环境中,开发者可以自己控制调用顺序,二义性问题是可以规避的。允许 null 提供了更大的灵活性。
5. 更深层的原因:并发安全的核心考量
5.1 避免 containsKey 与 get 的竞态条件
如果 ConcurrentHashMap 允许 null value,那么以下代码就会出现问题:
// 这样的代码在允许null的ConcurrentHashMap中会有问题
public String getValueSafely(ConcurrentHashMap<String, String> map, String key) {
String value = map.get(key);
if (value == null) {
// 这里有竞态条件!
if (!map.containsKey(key)) {
return "KEY_NOT_EXISTS";
}
}
return value == null ? "VALUE_IS_NULL" : value;
}
5.2 设计者的明确态度
查看 ConcurrentHashMap 源码中的注释:
/**
* @throws NullPointerException if the specified key or value is null
*/
public V put(K key, V value) {
if (key == null || value == null) throw new NullPointerException();
// ...
}
Doug Lea 在设计文档中明确指出:禁止 null 是为了避免在并发环境下出现无法解释的二义性。
6. 另一种视角:JVM 层面的考量
6.1 内存可见性问题
6.2 与 ConcurrentHashMap 内部机制的冲突
ConcurrentHashMap 内部使用了 ForwardingNode 和 ReservationNode 等特殊节点:
// 特殊节点类
static final class ForwardingNode<K,V> extends Node<K,V> {
ForwardingNode(Node<K,V>[] tab) {
super(MOVED, null, null, null); // key和value都是null
}
}
static final class ReservationNode<K,V> extends Node<K,V> {
ReservationNode() {
super(RESERVED, null, null, null); // key和value都是null
}
}
如果允许用户存入 null key 或 null value,就会与这些内部使用的特殊节点产生冲突,无法区分。
7. 对比其他并发容器
| 容器类 | 允许 null key | 允许 null value | 原因 |
|---|---|---|---|
| HashMap | ✅ | ✅ | 单线程环境,灵活优先 |
| Hashtable | ❌ | ❌ | 古老设计,保守策略 |
| ConcurrentHashMap | ❌ | ❌ | 避免并发二义性 |
| ConcurrentSkipListMap | ❌ | ❌ | 有序性要求,无法比较null |
| CopyOnWriteArrayList | ✅ | ✅ | 允许,但有明确文档说明 |
| LinkedBlockingQueue | ❌ | ❌ | 内部设计依赖非空元素 |
8. 代码演示:二义性问题
import java.util.concurrent.*;
public class NullAmbiguityDemo {
public static void main(String[] args) throws InterruptedException {
// 假设有一个允许null的ConcurrentHashMap(实际不存在)
// 我们用普通Map + 手动同步来模拟问题
ConcurrentHashMap<String, String> map = new ConcurrentHashMap<>();
// 先put一个正常的值
map.put("exists", "value");
// 演示:如果有null value会怎样
// map.put("nullValue", null); // 实际会抛异常
System.out.println("=== 二义性演示 ===");
// 场景:获取一个不存在的key
String result = map.get("notExist");
System.out.println("get notExist: " + result);
// 使用containsKey消除歧义(单线程没问题)
if (result == null && !map.containsKey("notExist")) {
System.out.println("可以确认:key确实不存在");
}
System.out.println("\n=== 并发场景模拟 ===");
simulateConcurrentAmbiguity();
}
static void simulateConcurrentAmbiguity() {
// 使用一个普通Map来模拟允许null的场景
java.util.Map<String, String> map = new java.util.HashMap<>();
map.put("key", null); // value为null
Runnable checker = () -> {
String value = map.get("key");
if (value == null) {
// 在并发环境下,这里无法知道是value为null还是key不存在
System.out.println(Thread.currentThread().getName() +
": 得到null,但无法确定原因");
}
};
Runnable modifier = () -> {
map.remove("key"); // 删除key
System.out.println(Thread.currentThread().getName() + ": 删除了key");
};
// 并发执行,产生歧义
ExecutorService executor = Executors.newFixedThreadPool(2);
executor.submit(checker);
executor.submit(modifier);
executor.shutdown();
}
}
9. 实际开发中的替代方案
既然 ConcurrentHashMap 不允许 null,那如果确实需要表达"空值"的概念怎么办?
9.1 方案对比
9.2 方案一:使用 Optional
ConcurrentHashMap<String, Optional<String>> map = new ConcurrentHashMap<>();
// 存储空值
map.put("key", Optional.empty());
// 存储非空值
map.put("name", Optional.of("张三"));
// 读取
Optional<String> result = map.get("key");
if (result == null) {
System.out.println("key不存在");
} else if (result.isEmpty()) {
System.out.println("key存在,value为空");
} else {
System.out.println("value: " + result.get());
}
9.3 方案二:使用专用占位符对象
public class NullPlaceholder {
private static final NullPlaceholder INSTANCE = new NullPlaceholder();
private NullPlaceholder() {}
public static NullPlaceholder getInstance() { return INSTANCE; }
}
ConcurrentHashMap<String, Object> map = new ConcurrentHashMap<>();
// 存储空值
map.put("key", NullPlaceholder.getInstance());
// 读取
Object value = map.get("key");
if (value == null) {
System.out.println("key不存在");
} else if (value == NullPlaceholder.getInstance()) {
System.out.println("key存在,value为空");
} else {
System.out.println("value: " + value);
}
9.4 方案三:使用 containsKey 前置检查
ConcurrentHashMap<String, String> map = new ConcurrentHashMap<>();
// 封装一个安全的获取方法
public String safeGet(String key) {
// 注意:这不是原子操作,但至少能区分null value的情况
if (!map.containsKey(key)) {
return null; // 表示key不存在
}
return map.get(key); // 这里的null表示value为null
}
10. 经典面试追问
10.1 既然 get 可能返回 null 代表 key 不存在,那为什么不能把 value 设为 null 呢?
因为 map.put(key, null) 和 map.containsKey(key) == false 在并发环境下无法区分。如果允许 value 为 null,那么:
// 线程A执行
if (map.containsKey(key)) {
// 此时另一个线程可能已经删除了这个key
String value = map.get(key); // 可能得到null
}
10.2 Hashtable 也不允许 null,原因一样吗?
不完全一样。Hashtable 是早期设计,更多是出于简单保守的原则。Hashtable 的所有 public 方法都是 synchronized 的,即使允许 null,二义性问题也存在,但没那么严重。
10.3 有没有允许 null 的并发 Map?
有,ConcurrentSkipListMap 也不允许 null(因为需要比较排序)。Collections.synchronizedMap(new HashMap<>()) 允许 null,但它只是简单包装,性能差。
11. 总结
| 原因 | 详细说明 | 重要性 |
|---|---|---|
| 并发二义性 | get 返回 null 无法区分"key 不存在"和"value 为 null" | ⭐⭐⭐⭐⭐ |
| 原子性缺失 | get + containsKey 不是原子操作,状态会在两次调用间变化 | ⭐⭐⭐⭐⭐ |
| 内部机制冲突 | 内部使用特殊节点(ForwardingNode 等)其 key/value 为 null | ⭐⭐⭐⭐ |
| 设计一致性 | 与 Hashtable 保持一致,避免混淆 | ⭐⭐⭐ |
| 性能考量 | 避免额外的 null 检查和处理逻辑 | ⭐⭐ |
一句话总结:
ConcurrentHashMap 禁止 null key 和 null value,是为了避免在并发环境下 get 操作返回 null 时产生二义性,因为无法区分是 key 不存在还是 value 本为 null,而这种二义性在多线程场景下无法通过 containsKey 安全地解决。
📌 小贴士:如果你确实需要在 ConcurrentHashMap 中存储"空值"的概念,推荐使用
Optional或自定义占位符对象,这样既能保持代码清晰,又能避免并发问题。
如果觉得本文对你有帮助,欢迎点赞、收藏、转发~

|
🌺The End🌺点点关注,收藏不迷路🌺
|
更多推荐




所有评论(0)