关注Java后端技术栈“
回复“面试”获取最新资料
本文主要探讨下HashMap
在多线程环境下容易出现哪些问题,深层次理解其中的HashMap
。
我们都知道HashMap
是线程不安全的,但是HashMap在咱们日常工作中
使用频率在所有map中确实属于比较高的。因为它可以满足我们大多数的场景了。
上面展示了java
中Map的继承图,Map是一个接口,我们常用的实现类有
HashMap
LinkedHashMap
TreeMap
HashTable
数据覆盖问题
两个线程执行put()
操作时,可能导致数据覆盖。JDK1.7
版本和JDK1.8
版本的都存在此问题,这里以 JDK1.7
为例。
假设 A、B 两个线程同时执行put()
操作,且两个 key 都指向同一个 buekct
,那么此时两个结点,都会做头插法。先看这里的代码实现:
public V put(K key, V value) {
//...
addEntry(hash, key, value, i);
}
void addEntry(int hash, K key, V value, int bucketIndex) {
//...
createEntry(hash, key, value, bucketIndex);
}
void createEntry(int hash, K key, V value, int bucketIndex) {
Entry<K,V> e = table[bucketIndex];
table[bucketIndex] = new Entry<>(hash, key, value, e);
size++;
}
看下最后的createEntry()
方法,首先获取到了 bucket 上的头结点,然后再将新结点作为 bucket 的头部,并指向旧的头结点,完成一次头插法的操作。当线程 A 和线程 B 都获取到了 bucket 的头结点后,若此时线程 A 的时间片用完,线程 B 将其新数据完成了头插法操作,此时轮到线程 A 操作,但这时线程 A 所据有的旧头结点已经过时了(并未包含线程 B 刚插入的新结点),线程 A 再做头插法操作,就会抹掉 B 刚刚新增的结点,导致数据丢失。
其实不光是put()
操作,删除操作、修改操作,同样都会有覆盖问题。
扩容时导致死循环
这是最常遇到的情况,也是面试经常被问及的考题。但说实话,这个多线程环境下导致的死循环问题,并不是那么容易解释清楚,因为这里已经深入到了扩容的细节。这里尽可能简单的描述死循环的产生过程。
另外,只有 JDK1.7
及以前的版本会存在死循环现象,在JDK1.8
中,resize()方式已经做了调整,使用两队链表,且都是使用的尾插法,及时多线程下,也顶多是从头结点再做一次尾插法,不会造成死循环。而JDK1.7
能造成死循环,就是因为 resize()时使用了头插法,将原本的顺序做了反转,才留下了死循环的机会。
在进一步说明死循环的过程前,我们先看下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;
}
}
}
这段代码是HashMap
的扩容操作,重新定位每个桶的下标,并采用头插法将元素迁移到新数组中。头插法会将链表的顺序翻转,这也是形成死循环的关键点。
其实就是简单的链表反转,再进一步简化的话,分为当前结点e
,以及下一个结点e.next
。我们以链表a->b->c->null
为例,两个线程 A 和 B,分别做扩容操作。
原表:
线程 A 和 B 各自新增了一个新的哈希 table,在线程 A 已做完扩容操作后,线程 B 才开始扩容。此时对于线程 B 来说,当前结点e
指向 a 结点,下一个结点e.next
仍然指向 b 结点(此时在线程 A 的链表中,已经是c->b->a
的顺序)。按照头插法,哈希表的 bucket 指向 a 结点,此时 a 结点成为线程 B 中链表的头结点,如下图所示:
a 结点成为线程 B 中链表的头结点后,下一个结点e.next
为 b 结点。既然下一个结点e.next
不为 null,那么当前结点e
就变成了 b 结点,下一个结点e.next
变为 a 结点。继续执行头插法,将 b 变为链表的头结点,同时 next 指针指向旧的头节点 a,如下图:
此时,下一个结点e.next
为 a 节点,不为 null,继续头插法。指针后移,那么当前结点e
就成为了 a 结点,下一个结点为 null。将 a 结点作为线程 B 链表中的头结点,并将 next 指针指向原来的旧头结点 b,如下图所示:
此时,已形成环链表。同时下一个结点e.next
为 null,流程结束。
总结
如果想在多线程环境下使用 HashMap
,很容易引起各类问题,上面仅为不安全问题的两个典型示例,具体问题无法一一列举,但大体会分为以下三类:
死循环
数据重复
数据丢失(覆盖)
注意:在JDK1.5
之前,多线程环境往往使用 HashTable
,但在JDK1.5
及以后的版本中,在并发包中引入了专门用于多线程环境的ConcurrentHashMap
类,采用分段锁实现了线程安全,相比 HashTable
有更高的性能,推荐使用。