Java集合:HashMap线程不安全?有哪些表现?

版权声明:本博客为记录本人学习过程而开,内容大多从网上学习与整理所得,若侵权请告知! https://blog.csdn.net/Fly_as_tadpole/article/details/87862298

HashMap是线程不安全的!主要表现在多线程情况下:

1)hash冲突时,put方法不是同步的,先存的值会被后存的值覆盖

2)在resize的时候,可能会导致死循环(环形链表)


让我们先来了解一下HashMap的底层存储结构,HashMap底层是一个Entry数组,一旦发生Hash冲突的的时候,HashMap采用拉链法解决碰撞冲突,Entry内部的变量:

[java] view plain copy

  1. final Object key;  
  2. Object value;  
  3. Entry next;  
  4. int hash;  


        通过Entry内部的next变量可以知道使用的是链表,这时候我们可以知道,如果多个线程,在某一时刻同时操作HashMap并执行put操作,而有大于两个key的hash值相同,如图中a1、a2,这个时候需要解决碰撞冲突,而解决冲突的办法上面已经说过,对于链表的结构在这里不再赘述,暂且不讨论是从链表头部插入还是从尾部初入,这个时候两个线程如果恰好都取到了对应位置的头结点e1,而最终的结果可想而知,a1、a2两个数据中势必会有一个会丢失,如图所示:

再来看下put方法

[java] view plain copy

  1. public Object put(Object obj, Object obj1)  
  2.     {  
  3.         if(table == EMPTY_TABLE)  
  4.             inflateTable(threshold);  
  5.         if(obj == null)  
  6.             return putForNullKey(obj1);  
  7.         int i = hash(obj);  
  8.         int j = indexFor(i, table.length);  
  9.         for(Entry entry = table[j]; entry != null; entry = entry.next)  
  10.         {  
  11.             Object obj2;  
  12.             //搜索同一个桶的链表上是否有相同key的entry,有则直接替换value
  13.             if(entry.hash == i && ((obj2 = entry.key) == obj || obj.equals(obj2)))  
  14.             {  
  15.                 Object obj3 = entry.value;  
  16.                 entry.value = obj1;  
  17.                 entry.recordAccess(this);  
  18.                 return obj3;  
  19.             }  
  20.         }  
  21.   
  22.         modCount++;  
  23.         //判断是否需要扩容,是则扩容,否,则创建entry
  24.         addEntry(i, obj, obj1, j);  
  25.         return null;  
  26.     }  


put方法不是同步的,同时调用了addEntry方法:

[java] view plain copy

  1. void addEntry(int i, Object obj, Object obj1, int j)  
  2.     {  
  3.         if(size >= threshold && null != table[j])  
  4.         {  
  5.             resize(2 * table.length);  
  6.             i = null == obj ? 0 : hash(obj);  
  7.             j = indexFor(i, table.length);  
  8.         }  
  9.         createEntry(i, obj, obj1, j);  
  10.     }  

addEntry方法依然不是同步的,所以导致了线程不安全出现伤处问题,其他类似操作不再说明,源码一看便知,


 resize死循环

重新调整 HashMap 大小的时候,存在条件竞争。

因为如果两个线程都发现 HashMap 需要重新调整大小了,它们会同时试着调整大小。在调整大小的过程中,存储在链表中的元素的次序会反过来。因为移动到新的 bucket 位置的时候,HashMap 并不会将元素放在链表的尾部,而是放在头部。这是为了避免尾部遍历(tail traversing)。如果条件竞争发生了,那么就死循环了。多线程的环境下不使用 HashMap。

HashMap 的容量是有限的。当经过多次元素插入,使得 HashMap 达到一定饱和度时,Key 映射位置发生冲突的几率会逐渐提高。这时候, HashMap 需要扩展它的长度,也就是进行Resize。

  1. 扩容:创建一个新的 Entry 空数组,长度是原数组的2倍

  2. rehash:遍历原 Entry 数组,把所有的 Entry 重新 Hash 到新数组

为什么多线程会导致死循环,它是怎么发生的?

我们都知道HashMap初始容量大小为16,一般来说,当有数据要插入时,都会检查容量有没有超过设定的thredhold,如果超过,需要增大Hash表的尺寸,但是这样一来,整个Hash表里的元素都需要被重算一遍。这叫rehash,这个成本相当的大。

1

2

3

4

5

6

7

8

9

10

11

12

13

void resize(int newCapacity) {

        Entry[] oldTable = table;

        int oldCapacity = oldTable.length;

        if (oldCapacity == MAXIMUM_CAPACITY) {

            threshold = Integer.MAX_VALUE;

            return;

        }

        Entry[] newTable = new Entry[newCapacity];

        transfer(newTable, initHashSeedAsNeeded(newCapacity));

        table = newTable;

        threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);

}

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

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;

            }

        }

}

大概看下transfer:

  1. 对索引数组中的元素遍历
  2. 对链表上的每一个节点遍历:用 next 取得要转移那个元素的下一个,将 e 转移到新 Hash 表的头部,使用头插法插入节点。
  3. 循环2,直到链表节点全部转移
  4. 循环1,直到所有索引数组全部转移

经过这几步,我们会发现转移的时候是逆序的。假如转移前链表顺序是1->2->3,那么转移后就会变成3->2->1。这时候就有点头绪了,死锁问题不就是因为1->2的同时2->1造成的吗?所以,HashMap 的死锁问题就出在这个transfer()函数上。

1.1 单线程 rehash 详细演示

单线程情况下,rehash 不会出现任何问题:

  • 假设hash算法就是最简单的 key mod table.length(也就是数组的长度)。
  • 最上面的是old hash 表,其中的Hash表的 size = 2, 所以 key = 3, 7, 5,在 mod 2以后碰撞发生在 table[1]
  • 接下来的三个步骤是 Hash表 resize 到4,并将所有的 <key,value> 重新rehash到新 Hash 表的过程

如图所示:

1.2 多线程 rehash 详细演示

为了思路更清晰,我们只将关键代码展示出来

1

2

3

4

5

6

while(null != e) {

    Entry<K,V> next = e.next;

    e.next = newTable[i];

    newTable[i] = e;

    e = next;

}

  1. Entry<K,V> next = e.next;——因为是单链表,如果要转移头指针,一定要保存下一个结点,不然转移后链表就丢了
  2. e.next = newTable[i];——e 要插入到链表的头部,所以要先用 e.next 指向新的 Hash 表第一个元素(为什么不加到新链表最后?因为复杂度是 O(N))
  3. newTable[i] = e;——现在新 Hash 表的头指针仍然指向 e 没转移前的第一个元素,所以需要将新 Hash 表的头指针指向 e
  4. e = next——转移 e 的下一个结点

假设这里有两个线程同时执行了put()操作,并进入了transfer()环节

1

2

3

4

5

6

while(null != e) {

    Entry<K,V> next = e.next; //线程1执行到这里被调度挂起了

    e.next = newTable[i];

    newTable[i] = e;

    e = next;

}

那么现在的状态为:

从上面的图我们可以看到,因为线程1的 e 指向了 key(3),而 next 指向了 key(7),在线程2 rehash 后,就指向了线程2 rehash 后的链表。

然后线程1被唤醒了:

  1. 执行e.next = newTable[i],于是 key(3)的 next 指向了线程1的新 Hash 表,因为新 Hash 表为空,所以e.next = null
  2. 执行newTable[i] = e,所以线程1的新 Hash 表第一个元素指向了线程2新 Hash 表的 key(3)。好了,e 处理完毕。
  3. 执行e = next,将 e 指向 next,所以新的 e 是 key(7)

然后该执行 key(3)的 next 节点 key(7)了:

  1. 现在的 e 节点是 key(7),首先执行Entry<K,V> next = e.next,那么 next 就是 key(3)了
  2. 执行e.next = newTable[i],于是key(7) 的 next 就成了 key(3)
  3. 执行newTable[i] = e,那么线程1的新 Hash 表第一个元素变成了 key(7)
  4. 执行e = next,将 e 指向 next,所以新的 e 是 key(3)

这时候的状态图为:

然后又该执行 key(7)的 next 节点 key(3)了:

  1. 现在的 e 节点是 key(3),首先执行Entry<K,V> next = e.next,那么 next 就是 null
  2. 执行e.next = newTable[i],于是key(3) 的 next 就成了 key(7)
  3. 执行newTable[i] = e,那么线程1的新 Hash 表第一个元素变成了 key(3)
  4. 执行e = next,将 e 指向 next,所以新的 e 是 key(7)

这时候的状态如图所示:

很明显,环形链表出现了!!当然,现在还没有事情,因为下一个节点是 null,所以transfer()就完成了,等put()的其余过程搞定后,HashMap 的底层实现就是线程1的新 Hash 表了。

猜你喜欢

转载自blog.csdn.net/Fly_as_tadpole/article/details/87862298