java并发-HashMap 为什么是线程不安全的

发布时间:2023年12月19日

1.源码分析put()方法

public V put(K key, V value) {
	return putVal(hash(key), key, value, false, true);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,  
               boolean evict) {  
    // 获取哈希表数组  
    Node<K,V>[] tab;   
    // 获取当前节点  
    Node<K,V> p;   
    // 获取哈希表的大小  
    int n, i;  

    // 如果哈希表为空或已满,重新调整哈希表大小  
    if ((tab = table) == null || (n = tab.length) == 0)  
        n = (tab = resize()).length;  

    // 根据键的哈希值和索引,在哈希表数组中查找对应的槽位  
    if ((p = tab[i = (n - 1) & hash]) == null)  
        // 如果槽位为空,则创建一个新的节点并放入该槽位  
        tab[i] = newNode(hash, key, value, null);  
    else {  
        Node<K,V> e; K k;  
        // 如果当前节点就是我们要插入的节点,则直接使用  
        if (p.hash == hash &&  
            ((k = p.key) == key || (key != null && key.equals(k))))  
            e = p;  
            // 如果当前节点是一个红黑树节点,则将其转换为红黑树节点并调用putTreeVal方法  
        else if (p instanceof TreeNode)  
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);  
        else {  
            // 如果当前节点不是红黑树节点,则遍历链表直到找到要插入的位置  
            for (int binCount = 0; ; ++binCount) {  
                if ((e = p.next) == null) {  
                    p.next = newNode(hash, key, value, null);  
                    // 如果链表长度超过阈值,则将其转换为红黑树  
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st  
                        treeifyBin(tab, hash);  
                    break;  
                }  
                if (e.hash == hash &&  
                    ((k = e.key) == key || (key != null && key.equals(k))))  
                    break;  
                p = e;  
            }  
        }  
        if (e != null) { // 如果键已经存在,则更新对应的值  
            V oldValue = e.value;  
            if (!onlyIfAbsent || oldValue == null)  
                e.value = value;  
            // 访问节点后执行的操作,可能用于更新节点的访问计数等  
            afterNodeAccess(e);  
            return oldValue; // 返回旧值  
        }  
    }  
    ++modCount; // 记录修改次数  
    if (++size > threshold) // 如果哈希表大小超过阈值,则重新调整哈希表大小  
        resize();  
    afterNodeInsertion(evict); // 插入节点后执行的操作,可能用于触发清除操作等  
    return null; // 返回null,表示插入成功且没有返回旧值  
}

HashMap putVal()方法中,可以看出里面进行了很多操作,那么在这里,我们把目光聚焦到++modCount 这一行代码中,它不是一个原子操作,可以理解为典型的i++操作。

  • 第一个步骤是读取;
  • 第二个步骤是增加;
  • 第三个步骤是保存。

在这里插入图片描述

我门可以根据箭头依次看,假设线程1 首先拿到 i=1 的结果,然后进行i+1操作,但此时i+1 的结果并没有保存下来,线程1就被切换走了,于是 CPU 开始执行线程2,它所做的事情和线程1是一样的i++操作,线程1 拿到的i 的结果一样都是1,为什么呢?因为线程1 虽然对i进行了+1操作,但结果没有保存,所以线程2看不到修改后的结果。
然后假设等线程2i进行 +1操作后,又切换到线程1,让线程1 完成未完成的操作,即将i+1的结果 2 保存下来,然后又切换到线程2 完成 i=2的保存操作,虽然两个线程都执行了对 i 进行 +1 的操
作,但结果却最终保存了i=2 的结果,而不是我们期望的i=3,这样就发生了线程安全问题,导致了
数据结果错误,这也是最典型的线程安全问题。
如此也可以证明HashMap是线程不安全的。(上述代码是java8的版本)

2.hashMap扩容期间取出的值不准确

如下面的例子:

package com.example;

import java.util.HashMap;
import java.util.Map;
import java.util.stream.IntStream;

public class HashMapNotSafe {
    public static void main(String[] args) {
        final Map<Integer, String> map = new HashMap<>();
        final Integer targetKey = 0b1111_1111_1111_1111;
        final String targetValue = "v";
        map.put(targetKey, targetValue);
        new Thread(() -> {
            IntStream.range(0, targetKey).forEach(key -> map.put(key, "someValue"));
        }).start();
        while (true) {
            if (null == map.get(targetKey)) {
                throw new RuntimeException("HashMap is not thread safe.");
            }
        }
    }
}

代码中首先建立了一个 HashMap,并且定义了 keyvaluekey 的值是一个二进1111_1111_1111_1111,对应的十进制是 65535。之所以选取这样的值,就是为了让它在扩容往回填充数据的时候,尽量不要填充得太快,比便于我们能捕捉到错误的发生。而对应的 value 是无所谓的,我们随意选取了一个非 null"v"来表示它,并且把这个值放到了 map 中。
我们就用一个新的线程不停地往我们的 map 中去填入新的数据,我们先来看是怎么填入的。首先它用了一个 IntStream,这个 range 是从 0 到之前所讲过的 65535,这个 range 是一个左闭右开的区间,所以会从 0、1、2、3…… 一直往上加,并且每一次加的时候,这个 0、1、2、3、4 都会作为 key 被放到 map 中去。而它的 value 是统一的,都是 "someValue",因为 value 不是我们所关心的。
然后,我们就会把这个线程启动起来,随后就进入一个 while 循环,这个 while 循环是关键,在 while 循环中我们会不停地检测之前放入的 key 所对应的 value 还是不是我们所期望的字符串 “v”。我们在 while 循环中会不停地从 map 中取 key 对应的值。如果 HashMap 是线程安全的,那么无论怎样它所 取到的值都应该是我们最开始放入的字符串 "v",可是如果取出来是一个 null,就会满足这个 if 条件并且随即抛出一个异常,因为如果取出 null 就证明它所取出来的值和我们一开始放入的值是不一致的,也就证明了它是线程不安全的,所以在此我们要抛出一个 RuntimeException 提示我们。
运行结果如下:

Exception in thread "main" java.lang.RuntimeException: HashMap is not thread safe.
	at com.example.rest.HashMapNotSafe.main(HashMapNotSafe.java:18)

很明显,很快这个程序就抛出了我们所希望看到的 RuntimeException,并且我们把它描述为:HashMap is not thread safe,一旦它能进入到这个 if 语句,就已经证明它所取出来的值是 null,而不是我们期望的字符串 “v”。

3.同时 put 碰撞导致数据丢失

比如,有多个线程同时使用 put 来添加元素,而且恰好两个 putkey 是一样的,它们发生了碰撞,也就是根据 hash 值计算出来的 bucket 位置一样,并且两个线程又同时判断该位置是空的,可以写入,所以这两个线程的两个不同的 value 便会添加到数组的同一个位置,这样最终就只会保留一个数据,丢失一个数据。

4.可见性问题无法保证

可见性也是线程安全的一部分,如果某一个数据结构声称自己是线程安全的,那么它同样需要保证可见性,也就是说,当一个线程操作这个容器的时候,该操作需要对另外的线程都可见,也就是其他线程都能感知到本次操作。可是 HashMap 对此是做不到的,如果线程1给某个 key 放入了一个新值,那么线程2在获取对应的 key 的值的时候,它的可见性是无法保证的,也就是说线程2可能可以看到这一次的更改,但也有可能看不到。所以从可见性的角度出发,HashMap 同样是线程非安全的。

5.死循环造成 CPU 100%

HashMap 有可能会发生死循环并且造成 CPU 100%,这种情况发生最主要的原因就是在扩容的时候,也就是内部新建新的 HashMap 的时候,扩容的逻辑会反转散列桶中的节点顺序,当有多个线程同时进行扩容的时候,由于 HashMap 并非线程安全的,所以如果两个线程同时反转的话,便可能形成一个循环,并且这种循环是链表的循环,相当于 A 节点指向B 节点B 节点又指回到 A 节点,这样一来,在下一次想要获取该 key 所对应的 value 的时候,便会在遍历链表的时候发生永远无法遍历结束的情况,也就发生 CPU 100%的情况。

文章来源:https://blog.csdn.net/qq_36649893/article/details/135096326
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。