一、现象:null引发的惨案 💥
实验1:HashMap允许null
// ✅ HashMap:null随便放
Map<String, String> hashMap = new HashMap<>();
hashMap.put(null, "value"); // ✅ key可以为null
hashMap.put("key", null); // ✅ value可以为null
hashMap.put(null, null); // ✅ 全null也行
System.out.println(hashMap.get(null)); // 输出:null
System.out.println(hashMap.containsKey(null)); // true
实验2:ConcurrentHashMap拒绝null
// ❌ ConcurrentHashMap:null一律不要
Map<String, String> concurrentMap = new ConcurrentHashMap<>();
concurrentMap.put(null, "value"); // 💣 NullPointerException
concurrentMap.put("key", null); // 💣 NullPointerException
concurrentMap.put(null, null); // 💣 NullPointerException
源码验证:
public V put(K key, V value) {
return putVal(key, value, false);
}
final V putVal(K key, V value, boolean onlyIfAbsent) {
// 开门见山:null检查!
if (key == null || value == null)
throw new NullPointerException(); // 🚫 直接抛异常
// ...
}
问题来了:为什么要这么设计?🤔
二、核心原因:二义性问题(Ambiguity)🎭
问题场景:get()返回null的两种含义
ConcurrentHashMap<String, String> map = new ConcurrentHashMap<>();
// 假设允许value为null
String value = map.get("key");
// 问题:value为null有两种可能!
// 1️⃣ key不存在,所以返回null
// 2️⃣ key存在,但value就是null
// 💡 如何区分这两种情况?
HashMap的解决方案:containsKey()
HashMap<String, String> map = new HashMap<>();
String value = map.get("key");
if (value == null) {
// ✅ 可以用containsKey区分
if (map.containsKey("key")) {
System.out.println("key存在,value是null");
} else {
System.out.println("key不存在");
}
}
ConcurrentHashMap的困境:并发环境不适用!
ConcurrentHashMap<String, String> map = new ConcurrentHashMap<>();
// ❌ 多线程环境下的问题:
String value = map.get("key"); // 线程1执行
if (value == null) {
// 此时线程2插入了 map.put("key", "value")
if (map.containsKey("key")) { // 线程1继续
// 💣 判断结果可能不准确!
// get()时key不存在,containsKey()时key存在了
}
}
生活比喻:
你去快递柜取快递📦:
单线程场景(HashMap):
1. 你打开柜子,发现是空的(get返回null)
2. 你再确认一下是不是你的柜子(containsKey)
3. 确认完了还是空的,因为没人动过!✅
多线程场景(ConcurrentHashMap):
1. 你打开柜子,发现是空的(get返回null)
2. 就在这时,快递员放进去一个包裹!📦
3. 你再确认是不是你的柜子(containsKey)
4. 发现有东西了!但你刚才明明看到是空的!😵
5. 到底是没快递还是快递是空的?搞不清了!
三、Doug Lea大神的设计哲学 🎨
原话引用
"The main reason that nulls aren't allowed in ConcurrentMaps (ConcurrentHashMaps, ConcurrentSkipListMaps) is that ambiguities that may be barely tolerable in non-concurrent maps can't be accommodated. The main one is that if
map.get(key)returnsnull, you can't detect whether the key explicitly maps tonullvs the key isn't mapped. In a non-concurrent map, you can check this viamap.contains(key), but in a concurrent one, the map might have changed between calls."—— Doug Lea
翻译:
ConcurrentHashMap不允许null的主要原因是:在并发环境下,二义性问题无法容忍。如果get(key)返回null,你无法判断是key映射到了null,还是key不存在。在非并发Map中,你可以用containsKey()检查,但在并发Map中,两次调用之间Map可能已经改变了。
设计原则:Fail-Fast(快速失败)
// ❌ 不好的设计:隐藏问题
if (value == null) {
// 不知道是什么情况,继续运行
// 可能导致诡异的bug
}
// ✅ 好的设计:提前暴露问题
if (key == null || value == null) {
throw new NullPointerException(); // 立即失败!
// 开发阶段就能发现问题
}
生活比喻:
就像开车🚗,发现刹车有问题:
- ❌ 不好的设计:刹车慢慢失灵,某天突然刹不住(隐藏问题)
- ✅ 好的设计:刹车有问题就报警,不让你开(快速失败)
四、为什么HashMap可以允许null?🤷
原因1:单线程环境,二义性可控
HashMap<String, String> map = new HashMap<>();
// 单线程环境:
String value = map.get("key");
if (value == null) {
// 这之间不会有其他线程修改map
if (map.containsKey("key")) {
// ✅ 判断结果是准确的
}
}
原因2:历史遗留,为了兼容性
// HashMap从JDK 1.2就有了
// 当时就允许null,后来不好改
// 改了会破坏大量老代码
Map<String, String> map = new HashMap<>();
map.put(null, null); // 很多老代码这么写
// 如果禁止null,无数项目升级JDK就挂了!💥
原因3:设计目标不同
HashMap:
- 目标:通用的key-value存储
- 场景:单线程环境
- 哲学:灵活性第一,允许null
ConcurrentHashMap:
- 目标:高并发的key-value存储
- 场景:多线程环境
- 哲学:正确性第一,禁止null
五、其他并发容器的选择 📦
Hashtable:也不允许null
Hashtable<String, String> table = new Hashtable<>();
table.put(null, "value"); // 💣 NullPointerException
table.put("key", null); // 💣 NullPointerException
// 原因:和ConcurrentHashMap一样,避免二义性
ConcurrentSkipListMap:也不允许null
ConcurrentSkipListMap<String, String> map = new ConcurrentSkipListMap<>();
map.put(null, "value"); // 💣 NullPointerException
map.put("key", null); // 💣 NullPointerException
// 原因:同上
TreeMap:key不能为null,value可以
TreeMap<String, String> map = new TreeMap<>();
map.put(null, "value"); // 💣 NullPointerException (需要比较key)
map.put("key", null); // ✅ OK
六、实际开发中的影响 💼
场景1:数据库查询结果
// ❌ 错误:直接放入ConcurrentHashMap
ConcurrentHashMap<Long, User> userCache = new ConcurrentHashMap<>();
User user = userDao.getById(123L); // 可能返回null
userCache.put(123L, user); // 💣 如果user是null,抛异常
// ✅ 正确:检查null
if (user != null) {
userCache.put(123L, user);
} else {
// 不存在的数据不放缓存
// 或者用特殊对象表示不存在
}
场景2:使用Optional
// ✅ 推荐:用Optional包装
ConcurrentHashMap<Long, Optional<User>> userCache = new ConcurrentHashMap<>();
User user = userDao.getById(123L);
userCache.put(123L, Optional.ofNullable(user)); // ✅ OK
// 使用时:
Optional<User> opt = userCache.get(123L);
if (opt != null && opt.isPresent()) {
User u = opt.get();
// ...
}
场景3:使用特殊值表示
// ✅ 使用特殊对象表示"不存在"
private static final User NULL_USER = new User(); // 特殊标记
ConcurrentHashMap<Long, User> userCache = new ConcurrentHashMap<>();
User user = userDao.getById(123L);
userCache.put(123L, user != null ? user : NULL_USER); // ✅ OK
// 使用时:
User u = userCache.get(123L);
if (u != null && u != NULL_USER) {
// 真实用户
}
七、经典面试题解析 🎤
Q1:为什么ConcurrentHashMap不允许null?
标准答案:
主要是为了避免并发环境下的二义性问题。
具体来说,如果get(key)返回null,有两种可能:
- key不存在
- key存在,但value是null
在HashMap中可以用containsKey()区分,但在ConcurrentHashMap中不行,因为两次调用之间,其他线程可能修改了Map,导致判断结果不准确。
为了避免这种歧义,Doug Lea设计时采用了Fail-Fast原则,直接禁止null,让问题在开发阶段就暴露出来。
Q2:HashMap为什么可以允许null?
标准答案:
- 单线程环境:HashMap设计用于单线程,
get()和containsKey()之间不会被打断 - 历史原因:HashMap从JDK 1.2就存在,当时就允许null,为了向后兼容不能改
- 设计哲学:HashMap追求灵活性,允许各种边界情况
Q3:如果一定要在ConcurrentHashMap中表示"null值"怎么办?
解决方案:
1️⃣ 使用Optional
ConcurrentHashMap<String, Optional<String>> map = new ConcurrentHashMap<>();
map.put("key", Optional.empty()); // 表示null值
2️⃣ 使用特殊值
private static final String NULL_VALUE = new String(); // 特殊标记
map.put("key", NULL_VALUE);
3️⃣ 使用Guava的Optional
ConcurrentHashMap<String, com.google.common.base.Optional<String>> map = ...;
八、源码验证 💻
ConcurrentHashMap的null检查
// JDK 8源码
public V put(K key, V value) {
return putVal(key, value, false);
}
final V putVal(K key, V value, boolean onlyIfAbsent) {
// 第一行就检查!
if (key == null || value == null)
throw new NullPointerException();
int hash = spread(key.hashCode()); // 后续代码
// ...
}
// get方法不会返回null(除非key不存在)
public V get(Object key) {
// ...
return (p = e.find(h, key)) != null ? p.val : null;
}
HashMap的null处理
// JDK 8源码
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
static final int hash(Object key) {
int h;
// ✅ 允许key为null,hash值为0
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
final V putVal(int hash, K key, V value, ...) {
// ✅ 没有null检查,直接放入
// ...
}
九、总结对比表 📊
| 容器 | key允许null | value允许null | 原因 |
|---|---|---|---|
| HashMap | ✅ | ✅ | 单线程,可用containsKey区分 |
| Hashtable | ❌ | ❌ | 避免并发二义性 |
| ConcurrentHashMap | ❌ | ❌ | 避免并发二义性 |
| TreeMap | ❌ | ✅ | key需要比较(Comparable) |
| ConcurrentSkipListMap | ❌ | ❌ | 避免并发二义性 |
| LinkedHashMap | ✅ | ✅ | 继承HashMap |
| WeakHashMap | ✅ | ✅ | 单线程环境 |
十、记忆技巧 🧠
口诀
并发容器不要null,
单线程的随便来,
HashMap历史包袱重,
TreeMap的key要能比!
记住Doug Lea的话:
二义性问题要避免,
Fail-Fast是好设计,
问题早点暴露好!
思维导图
为什么ConcurrentHashMap不允许null?
│
├─ 核心原因:二义性问题
│ ├─ get(key) = null
│ │ ├─ key不存在?
│ │ └─ value是null?
│ └─ containsKey()不可靠
│ └─ 两次调用间可能被修改
│
├─ 设计哲学:Fail-Fast
│ ├─ 问题早暴露
│ └─ 运行时错误→编译时错误
│
└─ HashMap为何可以?
├─ 单线程环境
├─ 历史兼容性
└─ 设计目标不同
类比记忆
🏪 单人商店(HashMap):
老板一个人看店,顾客问"这个商品有吗?"
老板可以准确回答,中间不会有变化。
🏬 大型超市(ConcurrentHashMap):
多个收银员同时工作,顾客问"这个商品有吗?"
等你去货架找,可能已经被别人买走或补货了!
所以超市规定:空货架不许摆!(禁止null)
核心要点总结:
- ✅ ConcurrentHashMap禁止null是为了避免二义性
- ✅ 并发环境下get()+containsKey()不可靠
- ✅ Fail-Fast设计哲学:早发现早解决
- ✅ 用Optional或特殊值代替null
- ✅ HashMap允许null是历史原因+单线程场景
Doug Lea的智慧:
"Better to fail fast than fail mysteriously!"
(与其神秘地失败,不如快速地失败!)💡