【并发编程】为什么Hashtable和ConcurrentHashMap 是不允许键或值为 null 的,HashMap 的键值则都可以为 null?

发布时间 2023-06-23 14:11:02作者: 哩个啷个波

原文链接:https://blog.csdn.net/cy973071263/article/details/126354336

目录

一、从源码的角度分析原因

1.1 Hashtable

1.2 ConcurrentHashMap

1.3 HashMap

二、从架构设计的角度分析原因

2.1 为何不支持 null 值?

2.1.1 ConcurrentHashMap

2.1.2 Hashtable

2.1.3 HashMap

2.2 为何不支持 null 键?

三、替代方案

四、总结


HashMap是允许key和value为null,它允许一个 null键,多个 null值。而 Hashtable和ConcurrentHashMap是不允许键和值为null的。

一、从源码的角度分析原因

首先,我们从源码中,找到HashMap允许key和value为null,Hashtable和ConcurrentHashMap不允许key和value为null的直接原因。

1.1 Hashtable

public synchronized V put(K key, V value) {
    // Make sure the value is not null
    // 如果value为空,直接怕抛出异常
    if (value == null) {
        throw new NullPointerException();
    }
    // Makes sure the key is not already in the hashtable.
    Entry<?,?> tab[] = table;
    // Hashtable是直接用Object类提供的hashCode()方法计算出来的哈希值,与数组长度直接取模来得到对应下表值的
    // 如果key为null,则会造成null.hashCode()这样的调用,直接导致空指针异常报错
    int hash = key.hashCode();
    int index = (hash & 0x7FFFFFFF) % tab.length;
    @SuppressWarnings("unchecked")
    Entry<K,V> entry = (Entry<K,V>)tab[index];
    for(; entry != null ; entry = entry.next) {
        if ((entry.hash == hash) && entry.key.equals(key)) {
            V old = entry.value;
            entry.value = value;
            return old;
        }
    }
    addEntry(hash, key, value, index);
    return null;
}

通过源码我们能清楚的看到,Hashtable的put操作,如果传入的value是null,就会直接抛出异常;如果传入的key是null,因为Hashtable是直接用Object的hashCode()方法计算出来哈希值,然后用哈希值直接与数组长度取模求出对应的数组下标的,所以如果key是null,就会出现null.hashCode()的情况,直接导致出现空指针异常。

1.2 ConcurrentHashMap

final V putVal(K key, V value, boolean onlyIfAbsent) {
    // key和value都不能为null
    if (key == null || value == null) throw new NullPointerException();
    ...
}

ConcurrentHashMap的put操作源码,也是直接就不允许传入的key和value为null,如果有null就会直接抛出异常。

1.3 HashMap

/**
 * 该方法的作用:将传入的子Map中的全部元素逐个添加到HashMap中
 * @param evict 最初构造此Map时为false,否则为true(中继到afterNodeInsertion方法)。
 */
final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
    ...
    putVal(hash(key), key, value, false, evict);
    ...
}
 
/**
 * JDK 1.8实现:将 键key 转换成 哈希码(hash值)操作 = 使用hashCode() + 1次位运算 + 1次异或运算(2次扰动)
 * 1. 取hashCode值: h = key.hashCode() 
 * 2. 高位参与低位的运算:h ^ (h >>> 16)
 */
static final int hash(Object key) {
    int h;
    // key.hashCode():返回散列值也就是hashcode
    // ^ :按位异或
    // >>>:无符号右移,忽略符号位,空位都以0补齐
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    // a. 当key = null时,hash值 = 0,所以HashMap的key 可为null      
    // 注:对比HashTable,HashTable对key直接hashCode(),若key为null时,会抛出异常,所以HashTable的key不可为null
    // b. 当key ≠ null时,则通过先计算出 key的 hashCode()(记为h),然后 对哈希码进行扰动处理: 按位 异或(^) 哈希码自身右移16位后的二进制
}

HashMap的源码中,求哈希值不是直接通过Object的hashCode()方法计算的,而是还要做一些额外的扰动处理,为了避免哈希碰撞概率。并且也没有对put操作的入参key和value做判空抛异常操作。所以HashMap是允许key和value为空的。

之所以HashMap只允许一个null键,我们可以看hash()方法源码,如果key为null,它就会将key的哈希值直接赋值为0,这样在后续计算对应数组下标时一定是0,这样所有的key值为null的数据都会被北方数组0下标上,并且新插入的数据是会直接在数组桶上覆盖掉旧数据的,也就保证了数组中只会有一个key为null的键值对。

但是HashMap允许多个null值,因为key可以是任意值,所以键值对可以放在数组的任意位置,有多少个value为null的键值对都是不会有限制的。

二、****从架构设计的角度分析原因

我们从源码的角度了解到了Hashtable和ConcurrentHashMap不允许key和value为null的直接原因,但是当初为什么要这样设计呢,为什么不能和HashMap一样,允许key和value为null呢?我们将问题拆分两个小问题来分别分析,即为何不支持 null 键,以及为何不支持 null 值。

2.1 为何不支持 null 值?

2.1.1 ConcurrentHashMap

我们先以ConcurrentHashMap为例进行讲解,给ConcurrentHashMap中插入 null (空)值会存在歧义。我们可以假设ConcurrentHashMap允许插入 null(空) 值,那么,我们取值的时候会出现两种结果:

  • 值没有在集合中,所以返回的结果就是 null (空);
  • 值就是 null(空),所以返回的结果就是它原本的 null(空) 值。

这就产生了歧义,出现了二义性问题。

ConcurrentHashMap 的设计本意就是为了在对线程场景下使用的,而在多线程场景下,出现了这样歧义的情况,会导致一些错误的结果。

我们用下面的示例代码举个例子,假设此时ConcurrentHashMap是可以插入空值的

ConcurrentHashMap map;
if (map.containsKey(key)) {
    return map.get(key);
} else {
    throw new KeyNotFoundException;
}

我们先看一下containsKey()的源码:

// 查找ConcurrentHashMap中是否存在键为key的键值对
public boolean containsKey(Object key) {
    // 这里直接使用的get()方法,如果通过这个key去查找相应的value,返回的是null的话,就认为不存在
    return get(key) != null;
}

判断当前ConcurrentHashMap中是否存在以key为键的键值对,其实就是调用的get()方法,如果返回的是null,就返回false,不是null就返回true。ConcurrentHashMap的get()方法没有加锁。

假设此时有线程T1调用ConcurrentHashMap 的 containsKey(key) 方法,想要判断一下容器中有没有以key为键的键值对,我们假设存在这个键值对,并且这个键值对的value不为null。那么containsKey()方法就会返回true,进入到If分支中。

但是进入之后在线程T1执行return map.get(key)代码之前,另一个线程T2将这个键为key的键值对删除了。然后T1执行get(key)方法的时候,它就无法在ConcurrentHashMap容器中找到对应的键值对了,进而返回null。

这样,就出现了一个很严重的问题,上面这段代码认为如果要查询的key存在,就会返回其对应的value,如果不存在,在调用containsKey()方法的时候就会返回fasle,进而进入到了另一个else分支,抛出key不存在的异常。但是上面我们将的这种情况,明明此时容器中已经不存在以key为键的键值对了,但是最后并没有抛出异常,而是返回了一个null。我们这里假设的是ConcurrentHashMap可以插入空值,那么用户就会认为此时容器中存在以key为键的键值对,并且其对应的value为null,这样就与真实情况不符了,出现了并发错误。归结其原因,是因为存在二义性问题,返回的是null即有可能是不存在该键值对,也有可能是存在该键值对,只是value为null。

通过上面的假设分析,我们就发现如果允许ConcurrentHashMap为空的话,会出现并发错误。回到现实,现实是它并不允许key和value为空,在这种前体现,我们去套用上面的这个案例,在T2线程将容器中key对应的键值对删除后,T1线程再去执行get(key)操作,因为容器中没有这个key,就会返回null。但是ConcurrentHashMap并不允许存在value为null的情况,所以说用户在得到了返回值null的时候,唯一的原因是键不存在而不是因为值可能为null,这样就会知道此时容器中并没有键为key的键值对,这也符合事实情况,就不会出现并发错误了。不允许存在key和value为null,就不会出现二义性问题,只要是返回的null,就认为是容器中不存在该键值对,不会出现其他含义。

2.1.2 Hashtable

Hashtable不允许key和value为null的原因,和ConcurrentHashMap是一样的。在并发环境下使用时,会造成歧义问题。如果允许值为null,有可能出现线程以为存在该键值对,但实际这个键值对已经被删除的情况,造成并发错误。

继续使用之前的测试案例:

Hashtable map;
if (map.containsKey(key)) {
    return map.get(key);
} else {
    throw new KeyNotFoundException;
}
 
 
// 判断Hashtable是否包含key
public synchronized boolean containsKey(Object key) {
    Entry tab[] = table;
    int hash = key.hashCode();
    // 计算索引值,
    // % tab.length 的目的是防止数据越界
    int index = (hash & 0x7FFFFFFF) % tab.length;
    // 找到“key对应的Entry(链表)”,然后在链表中找出“哈希值”和“键值”与key都相等的元素
    for (Entry<K,V> e = tab[index] ; e != null ; e = e.next) {
        if ((e.hash == hash) && e.key.equals(key)) {
            return true;
        }
    }
    return false;
}

整个分析过程和ConcurrentHashMap的分析过程是一样的,出现的并发问题也是一样的。Hashtable的所有操作方法都直接上了synchronized锁,会直接将整个类都锁上,所以会保证并发安全。但是只有在方法代码块中会加锁,执行完方法之后就会将锁释放。所以当多个线程同时对Hashtable进行操作的时候,假设如果允许存在null值,线程T1先去执行map.containsKey(key),发现存在该键值对,执行完containsKey方法后,线程T1就将锁释放了,其他线程就又可以操作该Hashtable了,在T1下去执行map.get(key)之前,线程T2将该键值对成功删除,然后T1继续执行map.get(key)取出来的value为null,此时并没有抛出KeyNotFoundException异常,所以线程T1就会认为该键值对存在,但实际上是不存在的,这就出现了并发错误。

如果不允许value为null,那么线程只要是通过get(key)方法获取到null,就知道容器中根本就不存在key的键值对,因为此时Hashtable不存在二义性问题。

2.1.3 HashMap

那么HashMap允许插入 null(空) 值,难道它就不担心出现歧义吗?这是因为HashMap的设计本意就是给单线程使用的,HashMap就完全是按照认为用户只会在单线程环境下使用它而设计的,它只需要保证在单线程环境下使用不会出现按问题就可以了。

分析之前也是先看一下HashMap的源码,

get()是根据传入的key,在容器找找到对应的键值对并返回value。如果不存在这个键值对或者存在这个键值对但是value为null,这个方法都会返回null。

public V get(Object key) {

    Node<K,V> e;

    return (e = getNode(hash(key), key)) == null ? null : e.value;
}

get()方法本质是调用的这个方法,这个方法是传入key和hash值,然后在容器中找到对应的键值对并返回其Node对象,注意这里不是返回的value,而是返回的键值对的Node对象,所以如果HashMap中并不存在key对应的键值对,这个方法就会返回null,如果存在key对应的键值对,这个方法就会返回其对应的Node对象,就算是value为null,也是能返回Node对象,只不过对象中的value属性为Null。

final Node<K,V> getNode(int hash, Object key) {
    // tab:当前map的数组,first:当前hash对应的索引位置上的节点,e:遍历过程中临时存储的节点,
    // n:tab数组的长度,k:first节点的key/遍历链表时遍历到的节点e的key值
    Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
    // 1.对table进行校验:table不为空 && table长度大于0 && 
    // hash对应的索引位置上的节点不为空
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {
        // 判断第一个节点是不是要找的元素,比较hash值和key是否和入参的一样,如果一样,直接返回第一个节点
        if (first.hash == hash && // always check first node
            ((k = first.key) == key || (key != null && key.equals(k)))){
            return first;
        }
        // 第一个节点不是要找的元素,
        // 取出来第二个节点,并且第二个节点不为null,说明还没走到该节点链的最后
        if ((e = first.next) != null) {
            // 如果第一个节点是红黑树类型
            if (first instanceof TreeNode){
                // 调用红黑树的查找目标节点方法getTreeNode
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            }
            // 前提条件:第一个节点不为null,并且也不是红黑树,而且还有下一个节点,那么该索引位置的元素类型就是链表,从第二个节点开始遍历该链表,
            do {
                // hash值和key值与入参一致,说明找到要查询的节点,返回节点
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);// e指针后移,并且下一个节点不为null则继续遍历,不为null表示没到链表最后
        }
    }
    // 没找到返回null
    return null;
}

判断HashMap中是否存在以key为键的键值对,存在就返回true,不存在就返回false,这个方法本质时调用的getNode()方法,只要是能找到对应的键值对Node对象,就证明容器中存在这个键值对。就算一个键值对的value为null,因为这里是判断键值对的Node对象是否为空,所以仍然会认为存在该键值对的。

public boolean containsKey(Object key) {

    return getNode(hash(key), key) != null;

}

通过上面的源码分析,我们发现HashMap的get()方法也是存在二义性问题的。但是HashMap设置之初就是为了在单线程环境下使用,所以只要是在单线程环境下不会出现问题,就是可以的。

我们接着用之前的测试代码,只不过此时是单线程环境,只有线程T1去执行这段代码。

HashMap map;
if (map.containsKey(key)) {
    return map.get(key);
} else {
    throw new KeyNotFoundException;
}

因为是在单线程环境下,就不会存在线程T1执行完map.containsKey(key),发现存在该键值对,进入到If分支中后,别的线程再将这个键值对删掉的情况。所以说只要是map.containsKey(key)判断是存在该键值对的,那么后面执行return map.get(key)的时候也肯定还是存在的,即使是返回的null值,用户也认为是该键值对的value为null。如果HashMap中不存在该键值对,在一开始判断map.containsKey(key)时就会返回false,进而进入到else分支抛出异常。我们发现,在单线程环境下,虽然HashMap也存在二义性问题,但是结合使用containsKey方法可以避免二义性,也就不会出现问题。这也进一步表明了,HashMap是一个线程不安全的对象,只能用在单线程环境下,并且在单线程使用的时候,如果想要获取指定key的键值对,应该先去通过containsKey(key)判断存不存在该键值对,存在的话再去调用get(key),这样才能避免HashMap的二义性问题。

通过上述的分析,我们就可以从设计的角度明白为什么Hashtable和ConcurrentHashMap 是不允许键或值为 null 的,HashMap 的键值则都可以为 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.

Further digressing: I personally think that allowing
nulls in Maps (also Sets) is an open invitation for programs
to contain errors that remain undetected until
they break at just the wrong time. (Whether to allow nulls even
in non-concurrent Maps/Sets is one of the few design issues surrounding
Collections that Josh Bloch and I have long disagreed about.)

It is very difficult to check for null keys and values
in my entire application .

Would it be easier to declare somewhere
static final Object NULL = new Object();
and replace all use of nulls in uses of maps with NULL?

-Doug

以上信件的主要意思是,Doug Lea 认为这样设计最主要的原因是:不容忍在并发场景下出现歧义!

也就是我们上面分析出来的歧义的问题,在并发场景下,是无法判断是键不存在,还是键对应的值为null。

2.2 为何不支持 null 键?

从上面的 Doug Lea 的回复可以看出,他本人和 HashMap 的作者 Josh Bloch 在设计上是存在分歧的,他认为如果在 Maps(及 Sets)中允许出现 null,会导致一些未知的异常在特殊的情况下发生。

个人认为在实现上是可以支持允许key为null的,通过上述的思路去分析,并没有发现如果允许key为null会导致出现错误,因为get操作都是去获取value,所以一般都是因为value的值而导致二义性问题的。但是作者的设计风格是想尽量避免不必要的隐藏异常,进而也就不允许key为null了。

三、替代方案

如果你确实需要在 ConcurrentHashMap 中使用 null 怎么办呢?

可以使用一个特殊的静态空对象来代替 null。

public static final Object NULL = new Object();

四、总结

下面我们就来总结一下。

ConcurrentHashma是p线程安全用来做支持并发的,这样会有一个问题,当你通过get(k)获取对应的value时,如果获取到的是null时,你无法判断,它是put(k,v)的时候value为null,还是这个key从来没有做过映射。

Hashtable同理,它也是线程安全用来做支持并发的,Hashtable 采用了安全失败机制(fail-safe),导致当前得到的数据不一定是集合最新的数据。如果使用null值,就不能判断到底是映射的value是null,还是因为没有找到对应的key而为空,因为在多线程环境下,无法通过调用contain(key)来对key是否存在做判断,在多线程情况下,即便此刻你能通过contains(key)知晓了是否包含null,下一步当你使用这个结果去做一些事情时可能其他并发线程已经改变了这种状态。

而HashMap是非并发的,它是在单线程环境下使用的,不会存在一个线程操作该HashMap时,其他的线程将该HashMap修改的情况,所以可以通过contains(key)来做判断是否存在这个键值对,从而做相应的处理。而支持并发的Map在调用m.contains(key)和m.get(key)时,这两个时间点的m很可能已经不同了。