HashMap 为什么线程不安全?

(3) 2024-04-20 14:12

Hi,大家好,我是编程小6,很荣幸遇见你,我把这些年在开发过程中遇到的问题或想法写出来,今天说一说HashMap 为什么线程不安全?,希望能够帮助你!!!。

HashMap 为什么线程不安全?

文章目录

  • HashMap 为什么线程不安全?
    • 前言
    • 项目环境
    • 1.put 方法中的 ++modCount 问题
    • 2.扩容期间取值不准确
    • 3.同时 put 碰撞导致数据丢失
    • 4.可见性问题
    • 5.扩容头插法可能导致的循环链表问题
    • 6.总结
    • 7.参考

前言

本文从以下几个方面来讨论 HashMap 为什么是线程不安全的

  • put 方法中的 modCount++ 问题
  • 扩容期间取值不准确
  • 同时 put 碰撞导致数据丢失
  • 可见性问题
  • 扩容头插法可能导致的循环链表问题(jdk 1.8 以前版本)
    • jdk 1.8+ 修改为尾插法解决了这个问题

项目环境

  • jdk 1.8
  • github 地址:https://github.com/huajiexiewenfeng/java-concurrent
    • 本章模块:collection

1.put 方法中的 ++modCount 问题

jdk1.8 - put 方法源码如下,省略了部分代码:

    final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) { 
   
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        ...
        ++modCount;
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }

++modCount 是一个复合操作,也是非常经典的线程不安全代码,我们可以自己写了一个例子

public class NotSafeThreadIncrementDemo { 
   

    static int i = 0;
    static int j = 0;

    public static void main(String[] args) { 
   
        for (int k = 0; k < 100; k++) { 
   
            new Thread(() -> { 
   
                i++;
                ++j;
            }).start();
        }
        System.out.printf("变量 i 的值:[%d]\n变量 j 的值:[%d]\n", i, j);
    }
}

执行结果:

变量 i 的值:[97]
变量 j 的值:[98]

预期结果是 i=j=100,但是实际上每次执行的结果都不一样,甚至 i 和 j 的值都不一定相等。

原因如下:

从表面上看 i++ 只是一行代码,但实际上它并不是一个原子操作,它的执行步骤主要分为三步,而且在每步操作之间都有可能被打断。

  • 第一步:读取
  • 第二步:增加
  • 第三步:保存

图解:
HashMap 为什么线程不安全?_https://bianchenghao6.com/blog__第1张
假设 线程1、线程2 两个线程同时进行 i++ 的操作如上图所示,我们按照箭头的方向进行说明

为啥线程会被切换走?

Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方法来实现的,在任何一个确定的时刻,一个处理器(对于多核来说是一个内核)都之会执行一条线程中的指令。

–《深入理解Java虚拟机》周志明 (第二章-运行时数据区中->程序计数器相关的描述)

线程 1 先执行拿到 i=1,第二步进行 i+1 的操作,此时应该进行第三步 i=2 的操作,但是 线程 1 被切换走了,线程 2 抢到了 CPU 的时间片, 开始执行拿到 i=1(因为线程1的 i=2 的值还没有保存,所以线程2拿到的 i 的值还是为 1),进行第四步 i+1 的操作,然后按照箭头继续后面 5,6,7 操作,最终我们得到 i 的值为 2,并不是预期的 3,这就是线程安全问题。

由此可以得出 HashMap 是线程非安全的,如果有多个线程同时调用 put 方法,++modCount 的值就有可能计算错误。

2.扩容期间取值不准确

首先我们来解释一下这个问题,HashMap 在数据不断添加的过程中,在一定条件下会触发扩容的机制,在扩容期间,会新建一个新的空数组,并用旧的元素填充到新数组中。在这个填充的过程中,如果有线程去取值,就有可能取到 null 值。

我们来看下面这个示例代码

  • 循环 1000 次 map 中添加元素(次数不够可以增加,主要是为了触发扩容机制)
  • 预先设置一个值 key=1000,value=“xwf”
  • 不断的去获取我们预先设置的值,看 value 值是否为空,如果为空就表示,在扩容过程中发生了数据不一致问题
public class NotSafeHashMapDemo { 
   

    public static void main(String[] args) throws InterruptedException { 
   
        final Map<Integer, String> map = new HashMap<>();
        final Integer targetKey = 1000;// 如果不出异常,可以增大这个值
        final String targetValue = "xwf";
        map.put(targetKey, targetValue);

        new Thread(() -> { 
   
            IntStream.range(0, targetKey).forEach(key -> map.put(key, "someValue"));
        }).start();

        // 不断的循环取 targetKey 对应的值,预期值为 xwf
        while (true) { 
   
            System.out.println(map.size());// 查看 map 集合大小
            if (null == map.get(targetKey)) { 
   // 如果取到的值为 null 表示在扩容的过程中,原来 targetKey 的值发生了变化
                throw new RuntimeException("HashMap is not thread safe.");
            } else { 
   
                System.out.println(map.get(targetKey));
            }
        }
    }

}

执行结果:

...
集合的大小:637
xwf
集合的大小:661
xwf
集合的大小:703
xwf
集合的大小:738
xwf
集合的大小:768
Exception in thread "main" java.lang.RuntimeException: HashMap is not thread safe.
	at com.csdn.collection.NotSafeHashMapDemo.main(NotSafeHashMapDemo.java:30)

当集合大小到 768 的时候,发生了取值异常的情况,验证了我们的结论。

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

假设 线程1 和 线程2同时 put 添加元素,恰好两个 key 计算的 hash 值相同,预期结果如下图所示:
HashMap 为什么线程不安全?_https://bianchenghao6.com/blog__第2张
预期结果:假设 线程1 先执行成功将元素添加到数组中,而 线程2 因为 hash 碰撞,将元素添加为 Node 的 next 节点。

但是在多线程环境下有可能发生如下情况,两个线程同时判断该位置是空的,可以写入,所以这两个线程的两个不同的 value 便会添加到数组的同一个位置,这样最终就只会保留一个数据,丢失一个数据。

4.可见性问题

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

5.扩容头插法可能导致的循环链表问题

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

6.总结

所以综上所述,HashMap 是线程不安全的,在多线程使用场景中如果需要使用 Map,应该尽量避免使用线程不安全的 HashMap。同时,虽然 Collections.synchronizedMap(new HashMap()) 是线程安全的,但是效率低下,因为内部用了很多的 synchronized,多个线程不能同时操作。推荐使用线程安全同时性能比较好的 ConcurrentHashMap。

博主之前有一篇文章对 ConcurrentHashMap 进行了相关介绍:

Java并发编程|第十篇:ConcurrentHashMap源码分析

友情提醒,源码分析较多,有兴趣可以了解。

7.参考

  • 《深入理解Java虚拟机》- 周志明

今天的分享到此就结束了,感谢您的阅读,如果确实帮到您,您可以动动手指转发给其他人。

上一篇

已是最后文章

下一篇

已是最新文章

发表回复