写点什么

HashMap 为什么线程不安全?

  • 2022 年 8 月 17 日
    湖南
  • 本文字数:2539 字

    阅读完需:约 8 分钟

HashMap为什么线程不安全?

文章目录线程不安全 HashMap 的线程不安全体现在会造成死循环、数据丢失、数据覆盖等问题。其中死循环和数据丢失是在 JDK1.7 中出现的问题,在 JDK1.8 中已经得到解决,但是 1.8 中仍会有数据覆盖这样的问题。HashMap 是线程不安全的,线程安全场景应该使用 ConcurrentHashMap。


JDK1.7 中的死循环与数据丢失 HashMap 的线程不安全主要是发生在扩容方法中,JDK1.7 中 HashMap 的 transfer 函数如下:


void transfer(Entry[] newTable, boolean rehash) {


    int newCapacity = newTable.length;    for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next; if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key); } int i = indexFor(e.hash, newCapacity); e.next = newTable[i]; newTable[i] = e; e = next; } }}
复制代码


}HashMap 的扩容操作(先扩容在头插法插入)会重新定位每个桶的下标,并采用头插法将元素迁移到新数组中。头插法会将链表的顺序翻转,这也是造成死循环和数据丢失的关键。


举例说明比如现在有两个线程 A、B 同时对下面这个 HashMap 进行扩容操作:


正常扩容后的结果如下(7%4=3 3%4=3):


jdk1.7 是头插法,所以说 7 再来就插到了 3 前面


但是当线程 A 执行到上面 transfer 函数的第 11 行代码时,CPU 时间片耗尽,线程 A 被挂起,即


newTable[i] = e; // 此处挂起,此时还没有执行此时线程 A 中:


e=3;next=7;e.next=null;


此时线程 B 开始执行,并且线程 B 成功的完成了数据迁移,如下:


这个是问题出现关键时间段,根据 Java JMM,线程 B 执行完数据迁移后,此时主内存中 newTable 和 table 都是最新的,也就是说:


7.next=3;3.next=null;此时线程 A 获得 CPU 时间片继续执行 newTable[i] = e ,将 3 放入新数组对应的位置,执行完此轮循环后线程 A 的情况如下


接着继续执行下一轮循环,此时 e=7 ,从主内存中读取 e.next 时发现主内存中 7.next=3, 即 next=3 ,并将 7 采用头插法的方式放入新数组中,并继续执行完此轮循环,结果如下:


继续执行下一次循环可以发现, e.next=null ,所以此轮循环将会是最后一轮循环。


接下来当执行完 e.next=newTable[i] 即 3.next=7 后,3 和 7 之间就相互连接了,当执行完 newTable[i]=e 后,3 被头插法重新插入到链表中,执行结果如下图所示:


到此线程 A、B 的扩容操作完成。


显然当线程 A 执行完后,HashMap 中出现了环形结构,当在以后对该 HashMap 进行操作时会出现死循环。


并且从上图可以发现,元素 5 在扩容期间被莫名的丢失了,这就发生了数据丢失的问题。


JDK1.8 中的数据覆盖 JDK1.7 出现的问题,在 JDK1.8 中已经得到了很好的解决,JDK1.8 直接在 resize 函数中完成了数据迁移。在进行元素插入时使用的是尾插法然后在扩容。


虽然在 JDK1.8 中已经解决死循环和数据丢失问题,但是 1.8 中仍会有数据覆盖这样的问题


但是在 1.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;    if ((tab = table) == null || (n = tab.length) == 0)        n = (tab = resize()).length;    if ((p = tab[i = (n - 1) & hash]) == null) //判断是否出现hash碰撞,如果没有hash碰撞则直接插入元素,此处线程不安全        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; 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) {
复制代码


// existing mapping for keyV oldValue = e.value;if (!onlyIfAbsent || oldValue == null)e.value = value;afterNodeAccess(e);return oldValue;}}++modCount;if (++size > threshold) //++size 此处线程不安全 resize();afterNodeInsertion(evict);return null;}其中代码


if ((p = tab[i = (n - 1) & hash]) == null)是判断是否出现 hash 碰撞:


举例说明比如两个线程 A、B 都在进行 put 操作,并且 hash 函数计算出的插入下标是相同的,当线程 A 执行完第六行代码后由于时间片耗尽导致被挂起,而线程 B 得到时间片后在该下标处插入了元素,完成了正常的插入,然后线程 A 获得时间片,由于之前已经进行了 hash 碰撞的判断,所有此时不会再进行判断,而是直接进行插入,这就导致了线程 B 插入的数据被线程 A 覆盖了,从而线程不安全。


还有一种情况就是代码 if (++size > threshold)中的++size:


同样还是线程 A、B,这两个线程同时进行 put 操作时,假设当前 HashMap 的 zise 大小为 10,当线程 A 执行到此行代码时,从主内存中获得 size 的值为 10 后准备进行+1 操作,但是由于时间片耗尽只好让出 CPU,线程 B 拿到 CPU 还是从主内存中拿到 size 的值 10 进行+1 操作,完成了 put 操作并将 size=11 写回主内存,然后线程 A 再次拿到 CPU 并继续执行(此时 size 的值仍为 10),当执行完 put 操作后,还是将 size=11 写回内存,此时线程 A、B 都执行了一次 put 操作,但是 size 的值只增加了 1,所有说还是由于数据覆盖又导致了线程不安全。


HashMap 的线程不安全主要体现在两个方面:


在 JDK1.7 中,当并发执行扩容操作时会造成环形链和数据丢失的情况。在 JDK1.8 中,在并发执行 put 操作时会发生数据覆盖的情况。解决线程不安全下篇文章详细说明,这里不再赘述


直接使用 Hashtable 类;直接使用 ConcurrentHashMap;使用 Collections 将 HashMap 包装成线程安全的 Map。

用户头像

专注分享Java面试技巧 2022.08.16 加入

专注Java领域干货分享,不限于面试技巧、JVM、多线程、Mysql、Spring、数据库 、微服务、算法 、JDK、分布式等相关知识,期待与您一同进步。

评论

发布
暂无评论
HashMap为什么线程不安全?_Java_Java面试那些事儿_InfoQ写作社区