为什么HashMap是线程不安全的?(分析 jdk1.7和1.8)

前言

我们都知道HashMap是线程不安全的,只是简单知道它源码中put/get方法都没有加同步锁,多线程操作会无法保证安全,但是除了这一点,它到底为什么线程不安全??在本章就让我们来深入了解一下HashMap线程不安全的问题。

HashMap是线程不安全的,其主要体现:

  1. 在jdk1.7中,在多线程环境下,扩容时会造成环形链或数据丢失。
  2. 在jdk1.8中,在多线程环境下,会发生数据覆盖的情况。

jdk1.7中的HashMap

在多线程环境下HashMap容易出现死循环,死循环发生在HashMap的扩容函数 transfer 中,jdk1.7中HashMap的transfer函数如下:

 1    void transfer(Entry[] newTable, boolean rehash) {
 2         int newCapacity = newTable.length;
 3         for (Entry<K,V> e : table) {
 4             while(null != e) {
 5                 Entry<K,V> next = e.next;
 6                 if (rehash) {
 7                     e.hash = null == e.key ? 0 : hash(e.key);
 8                 }
 9                 int i = indexFor(e.hash, newCapacity);
10                 e.next = newTable[i];
11                 newTable[i] = e;
12                 e = next;
13             }
14         }
15     }

先简要介绍一下transfer函数的作用:进行扩容时,将原数组重新hash到新数组中,注意上面 10-12 行代码,可以看到jdk1.7中采用的是 头插法(新来的元素会插入到原本元素的位置),也就是链表顺序会翻转,而这就是形成死循环的关键。

1.1扩容造成死循环的分析过程

  1. 我们假设hash算法为 key mod 链表的大小
  2. hash表size为2,key取3,7,5,所以都在table[1]中
  3. 然后扩容,size变为4

resize之前的数据结构如下:
![在这里插入图片描述](https://img-blog.csdnimg.cn/20200812191944964.png#pic_center)**图1**

单线程的环境下,扩容之后的结果如下:
![在这里插入图片描述](https://img-blog.csdnimg.cn/20200812192041480.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3NpbmF0XzQwNDgyOTM5,size_16,color_FFFFFF,t_70#pic_center)**图2**

然后在多线程环境下,假设有两个线程A和B都在进行put操作。线程A在执行到transfer函数中第11行代码处挂起(也就是在插入数据之前挂起)。
在这里插入图片描述
此时线程A中运行上述程序结果如下:

e=3,由 图1 可得 e.next= 7,扩容之后索引 i= 3,
e.next=newTable[3];由于还没有真正进行扩容,所以此时newTable[3]=null;
所以e.next=null;
在这里插入图片描述
线程A挂起后,此时线程B正常执行,并完成resize操作,结果如下:
在这里插入图片描述

注意:由于线程B已经执行完毕,根据Java内存模型,现在newTable和table中的Entry都是主存中最新值:7.next=3,3.next=null。

此时切换到线程A上,线程A会继续执行 newTable[i] = e;代码执行过程如下:

newTable[3]=e ----> newTable[3]=3
e=next ----> e=7

此时结果如下:
在这里插入图片描述
继续执行下一轮循环:

e=7
next=e.next ----> next=3【从主存中取值】
e.next=newTable[3] ----> e.next=3【从主存中取值】
newTable[3]=e ----> newTable[3]=7
e=next ----> e=3

结果如下:
在这里插入图片描述
再次进行循环(总之都是在反复执行上面的代码):

e=3
next=e.next ----> next=null
e.next=newTable[3] ----> e.next=7 即:3.next=7
newTable[3]=e ----> newTable[3]=3
e=next ----> e=null

结果如下:
在这里插入图片描述
注意此次循环:由于e=null,所以循环结束。e.next=7,而在上次循环中7.next=3,3和7之间就相互连接了,从图中也能很明显看到出现了环形链表。在以后只要涉及轮询hashmap的数据结构,就会在这里出现死循环。

1.2 扩容造成数据丢失分析过程

依照上述分析过程,初始时:
在这里插入图片描述

线程A和线程B进行put操作,同样线程A挂起:
在这里插入图片描述
此时线程A的运行结果如下:
e=7,next=5,e.next=null。
在这里插入图片描述
此时线程B已获得CPU时间片,并完成resize操作:
在这里插入图片描述
同样注意由于线程B执行完成,newTable和table都为最新值:5.next=null。

此时切换到线程A,继续执行newtable[i]=e,代码执行过程如下:

newTable[3]=e ----> newTable[3]=7
e=next ----> e=5

此时结果如下:
在这里插入图片描述

接着进行下一次循环:

e=5
next=e.next ----> next=null,从主存中取值
e.next=newTable[1] ----> e.next=5,从主存中取值
newTable[1]=e ----> newTable[1]=5
e=next ----> e=null

将5放置在table[1]位置,此时e=null循环结束,并形成环形链表。并在后续操作hashmap时造成死循环。
在这里插入图片描述
从上图可以发现,元素3在扩容期间丢失了。

jdk1.8中HashMap

在jdk1.8中对HashMap进行了优化,不再采用头插法方式,而是直接插入链表尾部(尾插法),因此不会出现环形链表的情况,但是在多线程的情况下仍然不安全,这是为什么呢?
这里我们看jdk1.8中HashMap的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碰撞则直接插入元素
              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 key
                 V oldValue = e.value;
                 if (!onlyIfAbsent || oldValue == null)
                     e.value = value;
                 afterNodeAccess(e);
                 return oldValue;
             }
         }
         ++modCount;
         if (++size > threshold)
             resize();
         afterNodeInsertion(evict);
         return null;
     }

注意第6行代码:如果没有hash碰撞则会直接插入元素。我们假设线程A和线程B同时进行put操作,刚好这两条不同的数据hash值一样,并且该位置数据为null,所以线程A、B都会进入第6行代码中,(由于没有值,自然检测不到哈希碰撞,所以会直接插入)。假设一种情况,线程A进入后还未进行数据插入时挂起,而线程B也判断此处为空,正常执行,从而正常插入数据,然后线程A获取CPU时间片,由于之前已经进行了hash碰撞的判断,所以此时不会再进行判断,而是直接进行插入,这就导致了线程B插入的数据被线程A覆盖了,从而线程不安全。

  • 2
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值