一、背景
最近设计某个类库时使用了 ConcurrentHashMap
最后遇到了 value 为 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 章 数据结构与集合
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;
}
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 接口有很多子类,那么他们各自的适用场景是怎样的呢?
使用场景主要取决于以下几个方面:
- 是否需要线程安全:如果需要在多线程环境下操作
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
实现类各有千秋,使用场景各有不同,我们需要根据具体需求和场景进行权衡一样,我们在设计方案时也会遇到类似的场景,我们能做的是根据场景选择最适合的方案。
我们遇到的任何问题,都是彻底掌握某个知识的绝佳机会。当我们遇到问题时,应该主动掌握相关知识,希望大家不仅能够知其然,还要知其所以然。
创作不易,如果本文对你有帮助,欢迎点赞、收藏加关注,你的支持和鼓励,是我创作的最大动力。