- HashMap的线程不安全体现在会造成死循环、数据丢失、数据覆盖这些问题。其中死循环和数据丢失是在JDK1.7中出现的问题,在JDK1.8中已经得到解决,然而1.8中仍会有数据覆盖这样的问题。
1 扩容时出现的问题
-
多线程同时扩容时,出现环形链表和数据丢失(JDK1.7)。扩容原理见:HashMap底层实现–扩容
-
原因如下:
JDK1.7中,在HashMap扩容时,会改变链表中的元素的顺序,将元素从链表头部插入。说是为了避免尾部遍历。
-
JDK1.7中扩容的transfer方法:
JDK1.8 中扩容自己重写了扩容方法,resize()。
1.7 是在 while 循环里面,单个计算好数组索引位置后,单个的插入数组中,在多线程情况下,会有成环问题。
1.8 是等链表整个 while 循环结束后,才给数组赋值,所以多线程情况下,也不会成环。void transfer(Entry[] newTable, boolean rehash) { int newCapacity = newTable.length; for (Entry<K,V> e : table) { //e为空时循环结束 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; } } }
分析成环过程:
- 假设原来在数组 1 的下标位置有个链表,链表元素是 a->b->null,现在有两个线程同时执行这个方法,我们先来根据线程 1 的执行情况来分别分析下这三行代码。
-
e.next = newTable[i];
newTable 表示新的数组,newTable[i] 表示新数组下标为 i 的值,第一次循环的时候为null,e 表示原来链表位置的头一个元素,是 a,e.next 是 b。
e.next = newTable[i] 的意思就是拿出 a 来,并且使 a 的后一个节点是 null,如下图 1 的位置:
-
newTable[i] = e;
就是把 a 赋值给新数组下标为 1 的地方,如下图 2 的位置:
-
e = next;
next 的值在 while 循环一开始就有了,为:Entry<K,V> next = e.next; 在此处 next 的值就是 b,把 b 赋值给 e,接着下一轮循环。 -
从 b 开始下一轮循环,重复 1、2、3,注意此时 e 是 b 了,而 newTable[i] 的值已经不是空了,已经是 a 了,所以 1,2,3 行代码执行下来,b 就会插入到 a 的前面,如下图 3 的位置:
这个就是线程 1 的插入节奏。 -
重点来了,假设线程 1 执行到现在的时候,线程 2 也开始执行。此时数组上面链表已经形成了 b->a->null。线程一的执行结果不仅仅是给newTable赋值,还改变了a、b两个对象的next的实际指向。使得b.next = a。所以此时线程二栈中的链表指向已经被修改成了:
注意newTable 是在扩容方法中新建的局部变量,newTable 不是共享的,线程 2 无法在线程 1 newTable 的基础上再进行迁移数据。
-
总结:
插入的时候和平时追加到尾部的思路是不一致的,是链表的头结点开始循环插入,导致插入的顺序和原来链表的顺序相反的。
table 是共享的,table 里面的元素也是共享的,while 循环都直接修改 table 里面的元素的 next 指向,导致指向混乱。
2 值覆盖
-
JDK1.8源码put方法中:
//如果没有hash碰撞则直接插入元素 if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null);
假设两个线程A、B都在进行put操作,并且hash函数计算出的插入下标是相同的,当线程A执行完第二行代码后由于时间片耗尽导致被挂起,而线程B得到时间片后在该下标处插入了元素,完成了正常的插入,然后线程A获得时间片,由于之前已经进行了hash碰撞的判断,所有此时不会再进行判断,而是直接进行插入,这就导致了线程B插入的数据被线程A覆盖了,从而线程不安全。
-
JDK1.8源码put方法中:
if (++size > threshold) resize();
还是线程A、B,这两个线程同时进行put操作时,假设当前HashMap的zise大小为10,当线程A执行到
++size
时,从主内存中获得size的值为10后准备进行+1操作,但是由于时间片耗尽只好让出CPU,线程B快乐的拿到CPU还是从主内存中拿到size的值10进行+1操作,完成了put操作并将size=11写回主内存,然后线程A再次拿到CPU并继续执行(此时size的值仍为10),当执行完put操作后,还是将size=11写回内存,此时,线程A、B都执行了一次put操作,但是size的值只增加了1,所有说还是由于数据覆盖又导致了线程不安全。 -
JDK1.7中也有类似的值覆盖问题。
【参考文档】
大多数人不知道的:HashMap链表成环的原因和解决方案
HashMap中是如何形成环形链表的?
JDK1.7和JDK1.8中HashMap为什么是线程不安全的?