我们知道HashMap是线程不安全的,具体表现在多线程操作同一个HashMap对象时,会造成死循环(环形链)、数据丢失、数据覆盖这些问题。其中死循环和数据丢失在JDK1.7中会出现但在JDK1.8中已经得到解决,然而JDK1.8中仍会有数据覆盖的问题以及size自增、modCount自增的线程安全问题。
环形链
首先分析一下在JDK1.7中出现的死循环(环形链)问题,这个问题的根源是发生在哈希数组扩容(resize)时,将旧哈希表中的数据拷贝到新哈希表的过程中(即transfer(Entry[] newTable, boolean rehash)
这个方法),链表形成了一个首尾项链的环;当通过调用HashMap的get
等方法去遍历这个链表时,会造成死循环。
先重新回顾一下源码分析—HashMap(JDK1.7)这篇文章里的transfer
方法源码。
1 | //将老的表中的数据拷贝到新的结构中 |
这段代码的流程简单来说,就是遍历旧的的哈希数组,重新定位每个Entry的下标,并采用头插法将元素迁移到新数组中。
什么是头插法?就是在链表上插入节点时,总是放在头部位置,原来的头结点将会变成第二个节点,以此类推。
头插法是造成死循环的关键,下面对这个问题做一下过程分析。
分析环形链的产生过程
假设现在有两个线程A、B同时对下面这个HashMap进行扩容操作:
但是当线程A在要执行上面transfer
方法的newTable[i] = e
这行代码时,由于CPU时间片耗尽,线程A被挂起。即如下图中位置所示:
当线程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采用头插法的方式放入新数组中,最后e=3(e=next
)并继续执行下一轮循环,结果如下:
接下来当执行完e.next=newTable[i]即3.next=7后,3和7之间就相互连接了,当执行完newTable[i]=e后,3被头插法重新插入到链表中。此外因为从堆中读取到的3.next=null,e将被赋值为null。执行结果如下图所示:
上面说了此时3.next=null即next=null,当执行完e=next后,将不会进行下一轮循环。到此线程A、B的扩容操作完成,很明显当线程A执行完后,HashMap中出现了环形结构,当以后对该HashMap进行操作时会出现死循环。
并且从上图可以发现,元素5在扩容期间被莫名的丢失了,这就发生了数据丢失的问题。
数据覆盖
在JDK1.8中已经不会出现环形链的问题。分析JDK1.8中HashMap的源码,已经没有了tranfer
方法,这个方法的逻辑直接写在resize
方法中。
1 | final Node<K,V>[] resize() { |
从这段代码看出,如果哈希数组中元素如果是链表,扩容时采用的是尾插法,不管是将量表插入到index=j还是index=j+oldCap处,顺序是不变的,并且最后都会设置loTail.next = null;
或者loTail.next = null;
,这直接解决了环形链的问题。但在put
方法中仍会存在数据覆盖的问题,在下图所示的代码处,假设两个线程A、B都在进行put操作,并且hash函数计算出的插入下标是相同的,当线程A在要执行这行代码时由于时间片耗尽导致被挂起,而线程B得到时间片后在该下标处插入了元素,完成了正常的插入,然后线程A获得时间片,由于之前已经进行了hash碰撞的判断,所有此时不会再进行判断,而是直接进行插入,这就导致了线程B插入的数据被线程A覆盖了,从而线程不安全。
线程安全的替代方案
因为HashMap是线程不安全的,如果需要线程安全的Map,可以使用HashTable
、CurrentHashMap
或者Collections.synchronizedMap(Map)
替代。其中HashTable
和Collections.synchronizedMap(Map)
效率比较低,而CurrentHashMap
采用分段锁的设计,从而使得多个线程在访问该Map的不同段的Entry时可以获取不同的锁,从而提高了效率,更推荐使用。
v1.5.2