🚫 ConcurrentHashMap为啥不让放null?设计哲学大揭秘!

35 阅读7分钟

一、现象: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) returns null, you can't detect whether the key explicitly maps to null vs the key isn't mapped. In a non-concurrent map, you can check this via map.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,有两种可能:

  1. key不存在
  2. key存在,但value是null

在HashMap中可以用containsKey()区分,但在ConcurrentHashMap中不行,因为两次调用之间,其他线程可能修改了Map,导致判断结果不准确。

为了避免这种歧义,Doug Lea设计时采用了Fail-Fast原则,直接禁止null,让问题在开发阶段就暴露出来。

Q2:HashMap为什么可以允许null?

标准答案:

  1. 单线程环境:HashMap设计用于单线程,get()containsKey()之间不会被打断
  2. 历史原因:HashMap从JDK 1.2就存在,当时就允许null,为了向后兼容不能改
  3. 设计哲学: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允许nullvalue允许null原因
HashMap单线程,可用containsKey区分
Hashtable避免并发二义性
ConcurrentHashMap避免并发二义性
TreeMapkey需要比较(Comparable)
ConcurrentSkipListMap避免并发二义性
LinkedHashMap继承HashMap
WeakHashMap单线程环境

十、记忆技巧 🧠

口诀

并发容器不要null,
单线程的随便来,
HashMap历史包袱重,
TreeMap的key要能比!

记住Doug Lea的话:
二义性问题要避免,
Fail-Fast是好设计,
问题早点暴露好!

思维导图

为什么ConcurrentHashMap不允许null?
    │
    ├─ 核心原因:二义性问题
    │   ├─ get(key) = null
    │   │   ├─ key不存在?
    │   │   └─ valuenull?
    │   └─ 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!"
(与其神秘地失败,不如快速地失败!)💡