最近设计某个类库时使用了 ConcurrentHashMap
最后遇到了 value 为 null 时报了空指针异常的坑。
本文想探讨下以下几个问题:
(1) Map
接口的常见子类的 kv 对 null 的支持情况。
(2)为什么 ConcurrentHashMap
不支持 key 和 value 为 null?
(3)如果 value 可能为 null ,该如何处理?
(4)有哪些线程安全的 Java Map 类?
(5) 常见的 Map
接口的子类,如 HashMap
、TreeMap
、ConcurrentHashMap
、ConcurrentSkipListMap
的使用场景。
Map
接口的常见子类的 kv 对 null 的支持情况下图来源于孤尽老师 《码出高效》 第 6 章 数据结构与集合
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.** 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 操作的一致性。
建议封装 put 方法,统一使用该方法对 ConcurrentHashMap
的 put 操作进行封装,当 value 为 null 时,直接 return 即可。
Map map = new ConcurrentHashMap<>();// 封装 put 操作,为 null 时返回
private void putPerson(String key, Person value){if(value == null){return;}map.put(key, value);
}
使用 Optional
// 创建一个 ConcurrentHashMap>
Map> 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 name = map.get("name"); // 返回 Optional.of("Alice")
Optional age = map.get("age"); // 返回 Optional.empty()
Optional gender = map.get("gender"); // 返回 null
自定义表示 null 的类, 然后对 put 和 get 操作进行二次封装,参考代码如下:
// 定义一个表示 null 的类
public class NullValue extends Person{}// 创建一个 ConcurrentHashMap
private Map 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;
}
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
。
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
实现类各有千秋,使用场景各有不同,我们需要根据具体需求和场景进行权衡一样,我们在设计方案时也会遇到类似的场景,我们能做的是根据场景选择最适合的方案。
我们遇到的任何问题,都是彻底掌握某个知识的绝佳机会。当我们遇到问题时,应该主动掌握相关知识,希望大家不仅能够知其然,还要知其所以然。
创作不易,如果本文对你有帮助,欢迎点赞、收藏加关注,你的支持和鼓励,是我创作的最大动力。