本文主要探讨下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
有更高的性能,推荐使用。
推荐阅读
本文分享自微信公众号 - Java后端技术栈(t-j20120622),作者:田老师
原文出处及转载信息见文内详细说明,如有侵权,请联系 yunjia_community@tencent.com 删除。
原始发表时间:2020-05-15
本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。
我来说两句