前言
我们都知道HashMap是线程不安全的,只是简单知道它源码中put/get方法都没有加同步锁,多线程操作会无法保证安全,但是除了这一点,它到底为什么线程不安全??在本章就让我们来深入了解一下HashMap线程不安全的问题。
HashMap是线程不安全的,其主要体现:
- 在jdk1.7中,在多线程环境下,扩容时会造成环形链或数据丢失。
- 在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扩容造成死循环的分析过程
- 我们假设hash算法为 key mod 链表的大小
- hash表size为2,key取3,7,5,所以都在table[1]中
- 然后扩容,size变为4
resize之前的数据结构如下:
单线程的环境下,扩容之后的结果如下:
然后在多线程环境下,假设有两个线程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覆盖了,从而线程不安全。