面试官总是喜欢问我这个小白HashMap为什么线程非安全,经过多次打击的的我下定决心要理解HahsMap线程非安全的原因。
经过多方面的了解,我了解到HashMap线程非安全在不同JDK版本有不同的体现:
- JDK1.7:多个线程进行扩容时会出现链表死循环,数据丢失问题
- JDK1.8:数据被覆盖
下面结合源码详细了解其中的奥秘。
JDK1.7线程非安全
jdk1、7中线程非安全主要是因为在扩容函数 HashMap#transfer() 中会出现死循环,数据丢失问题。
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;
}
}
}
一定要看懂这段代码,对后面理解出现死循环,数据丢失原因很重要。从最后几行代码可以看出,扩容时是通过头插法将旧元素复制到新table里,头插法就是导致死循环的关键点。
下面分析下出现死循环,数据丢失的具体场景。
现有线程A和线程B同时对以下HashMap进行扩容操作:
正常扩容后的结果是下面这样的:
但是当线程A第一次执行到 HashMap#transfer() 函数的以下代码时,由于执行线程A的CPU时间片耗尽,需要将线程A挂起。
此时线程A的相关变量值为:e=3,next=7,e.next=null。
将线程A挂起后,CPU开始执行线程B,并在线程B中完成了整个扩容过程。
接下来就是重点了,根据Java内存模式可知,线程B执行完数据迁移后,此时主内存中newTable和table的数据都是最新的,也就是说:7.next=3、3.next=null。
随后线程A获得CPU时间片继续执行 newTable[i] = e 这行代码,将元素3放入新数组对应的位置,执行完这一轮循环后的线程A数据情况如下:
接着继续执行下一轮循环,此时e=7,从主内存中读取e.next时发现主内存中7.next=3,所以此时next=3,并将元素7采用头插法的方式放入数组中,并继续执行完此轮循环,结果如下:
此时没有任何问题
上一次循环执行的结果next=3,e=3,执行下一次循环可以发现,3.next=null,所以此轮循环将会是最后一轮循环。
接下来当执行到e.next=newTable[i]即3.next=7后,3和7之间就互相连接了,当执行完newTable[i]=e后,3被头插法重新插入到链表中,执行后的结果如下所示:
上面提到此轮循环是最后一次循环。所以线程A的扩容操作到此结束,很明显线程A扩容后的HashMap出现了环形结构,后面对该HashMap进行操作室会出现死循环。且从上图可知,元素5在扩容完成后莫名奇妙丢失了,这就发生了数据丢失问题。
JDK1.8线程非安全
上面提到的扩容期间死循环、数据丢失问题已在JDK1.8中解决了。如果你去阅读1.8的HashMap相关源码会发现找不到 HashMap#transfer() 这个导致出现死循环,数据丢失问题的方法。因为JDK1.8直接在HashMap#resize中完成了数据迁移。
与JDK1.7相比,JDK1.8导致HashMap线程非安全的的场景就没那么复杂了。主要是会出现数据覆盖的情况。
先来看下下面这段JDK1.8中的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)
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行和第38行代码,主要是这两行代码会出现数据覆盖。
首先看下第6行代码:
该代码的作用是判断是否出现hash碰撞,如果不出现则直接进行插入。那么假设线程A和线程B同时执行putVal这个方法,当线程A执行到第六行代码,根据hash函数计算出下标,检测不会出现hash碰撞,准备执行直接插入逻辑时,cpu分配的时间片被消耗完,线程A被挂起。线程B得到时间片开始执行,执行到第六行代码时根据hash函数计算出与线程A一样的下标,由于线程A还未进行插入,所以线程B判断不会出现hash碰撞,于是直接执行正常插入逻辑。线程B执行完后时间片又分给了线程A,线程A之前已经判断过是否会有hash碰撞,此时不会再判断,而是直接执行插入逻辑,从而将线程B插入的数据覆盖掉。
我们再看下第38行的代码。
该行代码有个++size逻辑。同样还是线程A和线程B同时执行putVal这个方法。假设当前HashMap的size为10,当线程A执行到第38行代码时,从主内存中获取到size的值为10,准备进行+1操作,刚好这时间片被消耗完,线程A又被挂起了。线程B分配到了时间片,从主内存中获取到size为10并进行+1操作,完成了put操作并将size+1后的值写回主内存。然后时间片又分给了线程A继续刚才的操作,由于线程A获取的size为10,所以对size+1的值为11,并执行完put操作将size=11写入主内存。此时线程A和线程B都执行了putVal方法,但是size的值只增加了1,所以还是由于数据覆盖导致的线程不安全。
总结
HashMap线程不安全原因
- JDK1.7
某个线程执行扩容操作过程中被挂起,此时其他线程完成了扩容操作,该线程分配到CPU资源继续进行扩容操作时会出现死循环,数据丢失 - JDK1.8:
1、多个线程进行hash碰撞判断时,若根据hash函数计算的下标是相同,就会出现线程A检测不会发生碰撞,然后线程A被挂起,线程B检测不会发生碰撞,直接进行插入操作。线程A分配到CPU资源继续刚刚的逻辑执行,会将线程B的结果覆盖掉。
2、对++size进行操作时,由于线程调度原因,线程A和线程B从主内存中获取相同size的值,然后进行+1,并写入主内存,就会出现线程A和线程B各执行一次,但size只增加1的问题。
参考链接