一、背景

最近设计某个类库时使用了 ​​ConcurrentHashMap​​ 最后遇到了 value 为 null 时报了空指针异常的坑。

什么?你不知道 ConcurrentHashMap 的 kv 不能为 null?_线程安全


本文想探讨下以下几个问题:

(1) ​​Map​​接口的常见子类的 kv 对 null 的支持情况。

(2)为什么 ​​ConcurrentHashMap​​ 不支持 key 和 value 为 null?

(3)如果 value 可能为 null ,该如何处理?

(4)有哪些线程安全的 Java Map 类?

(5) 常见的 ​​Map​​​ 接口的子类,如 ​​HashMap​​​、​​TreeMap​​​ 、​​ConcurrentHashMap​​​ 、​​ConcurrentSkipListMap​​ 的使用场景。

二、探究

2.1 Map接口的常见子类的 kv 对 null 的支持情况

下图来源于孤尽老师 《码出高效》 第 6 章 数据结构与集合

什么?你不知道 ConcurrentHashMap 的 kv 不能为 null?_线程安全_02

2.2 为什么 ConcurrentHashMap 不支持 key 和 value 为 null?

从 ​​java.util.concurrent.ConcurrentHashMap#put​​ 方法的注释和源码中可以非常容易得看出,不支持 key 和 value null。

/**
* Maps the specified key to the specified value in this table.
* Neither the key nor the value can be null.
*
* <p>The value can be retrieved by calling the {@code get} method
* with a key that is equal to the original key.
*
* @param key key with which the specified value is to be associated
* @param value value to be associated with the specified key
* @return the previous value associated with {@code key}, or
* {@code null} if there was no mapping for {@code key}
* @throws NullPointerException if the specified key or value is null
*/
public V put(K key, V value) {
return putVal(key, value, false);
}

/** Implementation for put and putIfAbsent */
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException();
int hash = spread(key.hashCode());
// 省略其他
}

那么,为什么不支持 key 和 value 为 null 呢?
据查阅资料,​​​ConcurrentHashMap​​ 的作者 Doug Lea 自己的描述:

The main reason that nulls aren’t allowed in ConcurrentMaps (ConcurrentHashMaps, ConcurrentSkipListMaps) is that ambiguities that may be just 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.

可知 ​​ConcurrentHashMap​​​ 是线程安全的容器,如果 ​​ConcurrentHashMap​​​ 允许存放 null 值,那么当一个线程调用 ​​get(key)​​​ 方法时,返回 null 可能有两种情况:
(1) 一种是这个 key 不存在于 map 中
(2) 另一种是这个 key 存在于 map 中,但是它的值为 null。
这样就会导致线程无法判断这个 null 是什么意思。
在非并发的场景下,可以通过 ​​​map.contains(key)​​检查是否包括该 key,从而断定是不存在 key 还是存在key 但值为 null,但是在并发场景下,判断后调用其他 api 之间 map 的数据已经发生了变化,无法保证对同一个 key 操作的一致性。

2.3 怎么解决?

2.3.1 封装 put 方法,使用前判断

建议封装 put 方法,统一使用该方法对 ​​ConcurrentHashMap​​ 的 put 操作进行封装,当 value 为 null 时,直接 return 即可。

Map<String, Person> map = new ConcurrentHashMap<>();


// 封装 put 操作,为 null 时返回
private void putPerson(String key, Person value){
if(value == null){
return;
}
map.put(key, value);
}

2.3.2 使用 Optional 类型

使用 Optional

// 创建一个 ConcurrentHashMap<String, Optional<String>>
Map<String, Optional<String>> map = new ConcurrentHashMap<>();

// 插入或更新 key-value 对
map.computeIfAbsent("name", k -> Optional.ofNullable("Alice")); // 如果 name 不存在,则插入 ("name", Optional.of("Alice"))
map.computeIfAbsent("age", k -> Optional.ofNullable(null)); // 如果 age 不存在,则插入 ("age", Optional.empty())

// 获取 value
Optional<String> name = map.get("name"); // 返回 Optional.of("Alice")
Optional<String> age = map.get("age"); // 返回 Optional.empty()
Optional<String> gender = map.get("gender"); // 返回 null

2.3.3 自定义一个表示 null 的类

自定义表示 null 的类, 然后对 put 和 get 操作进行二次封装,参考代码如下:

// 定义一个表示 null 的类
public class NullValue extends Person{

}


// 创建一个 ConcurrentHashMap<String, Object>
private Map<String, Person> map = new ConcurrentHashMap<>();

private static final NullValue nullValue = new NullValue();

//使用示例: 值不为 null 时
putPerson("1002", new Person("张三"));

//使用示例: 值为 null 时
putPerson("1003", null);


// 封装设置操作
private void putPerson(String key,Person person){
if(person == null){
map.put(key, nullValue);
return;
}
map.put(key, person);
}


// 封装获取操作
private Person getPerson(String key){
if(key == null){
return null;
}
Person person = map.get(key);
if(person instanceof NullValue){
return null;
}
return person;
}

2.3.4 使用其他线程安全的 Java Map 类

Java 中也有支持 key 和 value 为 null 的线程安全的集合类,比如 ConcurrentSkipListMap (JDK) 和 CopyOnWriteMap (三方)

  • ​ConcurrentSkipListMap​​​ 是一个基于跳表的线程安全的 map,它使用锁分段的技术来提高并发性能。它允许 key 和 value 为 null,但是它要求 key 必须实现 ​​Comparable​​​ 接口或者提供一个 ​​Comparator​​。
  • ​CopyOnWriteMap​​ 是一个基于数组的线程安全的 map,它使用写时复制的策略来保证并发访问的正确性。它允许 key 和 value 为 null。

注意 JDK 中没有提供 ​​CopyOnWriteMap​​​,很多三方类库提供了对应的工具类。如​​org.apache.kafka.common.utils.CopyOnWriteMap​​。

2.4 常见的 Map 接口的子类的使用场景

Map 接口有很多子类,那么他们各自的适用场景是怎样的呢?

什么?你不知道 ConcurrentHashMap 的 kv 不能为 null?_java_03

使用场景主要取决于以下几个方面:

  • 是否需要线程安全:如果需要在多线程环境下操作 ​​Map​​​,那么应该使用 ​​ConcurrentHashMap​​​、​​ConcurrentSkipListMap​​​,它们都是并发安全的。而 ​​HashMap​​​、​​TreeMap​​​、​​HashTable​​​和​​LinkedHashMap​​​则不是,并且 ​​HashTable​​​已经被 ​​ConcurrentHashMap​​取代。
  • 是否需要保证键的顺序:如果需要按照键的自然顺序或者插入顺序遍历 ​​Map​​​,那么应该使用 ​​TreeMap​​​或者 ​​LinkedHashMap​​​,它们都是有序的。而 ​​ConcurrentSkipListMap​​也是有序的,并且支持范围查询。其他类则是无序的。
  • 是否需要高效地访问和修改:如果需要快速地获取和更新 ​​Map​​​中的元素,那么应该使用 ​​HashMap​​​或者 ​​ConcurrentHashMap​​​,它们都是基于散列函数实现的,具有较高的性能。
    而 ​​​TreeMap​​​和 ​​ConcurrentSkipListMap​​​则是基于平衡树实现的,具有较低的性能。​​CopyOnWriteMap ​​则是基于数组实现的,并发写操作会复制整个数组,因此写操作开销很大。

在选择合适的 ​​Map​​ 接口实现时,需要根据具体需求和场景进行权衡。

三、总结

基本功很重要,有时候基本功不扎实,更容易遇到一些奇奇怪怪的坑。假设你不了解 ​​ConcurrentHashMap​​ 的 kv 不能为 null, 测试的时候没有覆盖这种场景,等上线以后遇到这个问题可能直接导致线上问题,甚至线上故障。

​ConcurrentHashMap​​ 作者在 put 方法注释中给出了 kv 不允许为 null 的提示,并没有在注释中给出设计原因,给众多读者带来了诸多困惑。这也给我们很大的启发,当我们的某些设计容易引起别人的困惑和好奇时,不仅要将注意事项放在注释中,更应该将设计原因放在注释里,避免给使用者带来困扰

“适合自己的才是最好的”。正如不同的 ​​Map​​ 实现类各有千秋,使用场景各有不同,我们需要根据具体需求和场景进行权衡一样,我们在设计方案时也会遇到类似的场景,我们能做的是根据场景选择最适合的方案。

我们遇到的任何问题,都是彻底掌握某个知识的绝佳机会。当我们遇到问题时,应该主动掌握相关知识,希望大家不仅能够知其然,还要知其所以然。


创作不易,如果本文对你有帮助,欢迎点赞、收藏加关注,你的支持和鼓励,是我创作的最大动力。

什么?你不知道 ConcurrentHashMap 的 kv 不能为 null?_spring_04