我们经常在面试中会遇到这个问题:
Q:HashMap是线程安全的吗?
A:不是
Q:为什么?
A:因为Hashmap 在多线程的情况下扩容会造成死循环的问题
上述回答并不准确,因为在jdk1.7 和 jdk1.8 中的 Hashmap是不一样的,只有在 jdk1.7 中才会出现上述问题,而在jdk1.8中是不会出现的。
现在我们通过源码看下为什么 jdk1.7 中Hashmap 在多线程的情况下扩容会造成死循环,我们直接看源码:
我们简单解释下,在Hashmap放入元素的时候会有一个 addEntry 的方法,里面会判断当 Hashmap 中的元素个数达到threshold
阈值的时候,会进入 resize 扩容方法,长度为原来的2倍。在 resize 中我们可以看到首先创建了一个 Entry 数组 newTable
,长度为newCapacity,然后会进入 transfer 方法将原来数组中的元素转换到新数组 newTable中,现在我们着重看下transfer 方法:
方法不长,总共只有15行代码,但却是造成死循环的关键地方。 红框中的代码就是造成HashMap死循环的原因,
现在我们假设原数组中的第一个元素为一个链表 2-->4,如下图所示:
| 2--->4 |
现在我们复现一下它是如何转换元素的,假设rehash 为 false,我们看下while 中的第一次遍历是什么样的:
上图可以看到e 为元素2,e.next 为元素 4,而 newTable 为新数组,所以 newTable[i] 是为null 的,while中第一次遍历结束后,我们看下新数组的元素存储情况:
| 2--->null |
现在我们看下while 中的第二次遍历:
上图中可以看到 e 为元素4 ,而由于元素4和元素2是在原数组的同一个位置中的,所以他们的 e.hash 的值是一样的,这样算出来的下标 i 也是一样的,这样 newTable[i] 就是上一个元素 2。 所以 e.next=2, newTable[i]=4 , 继续往下走,next 为null则循环结束。我们继续看下新数组中的元素存储情况:
| 4--->2 |
可以看到链表中的元素被反转了过来,这是因为jdk1.7 中HashMap 在扩容时转换元素的时候,若同一个位置有多个元素,则会采用头插法方式将元素放入新数组,也就是元素始终会在原来元素的头部插入。
我们假设现在A 线程第一次循环结束后,在执行到592行的时候被挂起了。现在B线程进来执行完了2次循环,将4指向2,B结束,A继续执行,就出现2-->4, 4-->2的死循环情况。
我们继续看下jdk1.8 Hashmap 在多线程的情况下扩容的操作源码:
话不多说,直接看 resize 方法:
代码很长,我们着重看下722-726行 和 736-739行,还是2-->4 ,我们看下它2次的数据转换情况,第一次循环:
第一次循环loTail 为null, 所以 loHead 和 loTail 都等于元素 2 ,第二次循环:
可以看到在第二次循环完成后,新数组中的 newTab[j] 中的元素还是原来的顺序 2-->4, 所以即使是在多线程的情况下扩容也不会发生死循环。但是jdk1.8中的HashMap依然不是线程安全的,是有可能发生数据覆盖的,我们还是看源码:
我们着重看下 630 行 和 631 行,在 630 行 和 631 行 可以看到 hash 与 n-1 按位与(也就是取余) 生成元素下标,然后取出该下标的元素判断器是否为空,若为空则将元素放入tab数组中。
现在我们假设有A,B2个线程往HashMap中放入元素, A 线程带着 i=1,value=4 的元素执行到630行时被挂起了,而 B 线程带着i=1,value=5 的元素执行完成,结束。这时A线程继续执行 tab[1]=4 , 结束。这样value=5 的元素就被覆盖。
总结:
Q:HashMap是线程安全的吗?
A:不是
Q:为什么?
A:因为jdk1.7中,Hashmap 在多线程的情况下扩容由于链表的数据转换
采用的是头插法所以会造成死循环的问题。而在jdk1.8中则进行了优化
直接将原链表数据放到新数组中,不会造成死循环,但在多线程的情况
下会导致数据覆盖