小知识,大挑战!本文正在参与“程序员必备小知识”创作活动。
HashMap 为什么是线程不安全的
上一篇文章分析了HashMap数据结构,在编程中我们都知道HashMap是线程不安全的,HashTable是线程安全的,那么HashMap为什么线程不安全了?又有什么替代的方法吗?
线程不安全的表现
HashMap的线程不安全主要体现在下面两个方面:
-
在JDK1.7中,当并发执行扩容操作时会造成环形链和数据丢失的情况。
-
在JDK1.8中,在并发执行put操作时会发生数据覆盖的情况。
为什么线程不安全
HashMap的线程不安全主要是发生在扩容函数中,即根源是在transfer函数中。
- 在JDK1.7中,会产生死循环的现象。
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;
}
}
}
头插法会将链表的顺序翻转,当多个线程同时进行时,正常情况下是 1->2->3 变为 3->2->1,但当多个线程进行时,会发生冲突,可能会1->2的同时另一个线程进行2->1。这也就是1.7中线程不安全的关键。
- 在JDK1.8中,put操作时会发生数据覆盖现象
阅读1.8的源码会发现找不到transfer函数,因为JDK1.8直接在resize函数中完成了数据迁移。值得注意的是,JDK1.8在进行元素插入时使用的是尾插法,在数据量大时,还有红黑树的算法提高效率。
引用一段解释的很完美的段落
比如有两个线程A和B,首先A希望插入一个key-value对到HashMap中,首先计算记录所要落到的桶的索引坐标,然后获取到该桶里面的链表头结点,此时线程A的时间片用完了,而此时线程B被调度得以执行,和线程A一样执行,只不过线程B成功将记录插到了桶里面,假设线程A插入的记录计算出来的桶索引和线程B要插入的记录计算出来的桶索引是一样的,那么当线程B成功插入之后,线程A再次被调度运行时,它依然持有过期的链表头但是它对此一无所知,以至于它认为它应该这样做,如此一来就覆盖了线程B插入的记录,这样线程B插入的记录就凭空消失了,造成了数据不一致的行为。
替代方案
大家都知道HashTable是线程安全的,但hashTable的效率很低,因为它是锁住的整个对象,在工作中已经用的很少了。
其实在多线程操作Map的场景时,使用ConcurrentHashMap可以很好的解决这个问题,又可以保证效率,因为ConcurrentHashMap采用了锁分段技术,首先将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个数据段时,其他段的数据也能被其他线程访问。