Java之HashMap在非线程安全时的行为
我们知道HashMap在多线程环境下是不安全的,这里指的不安全是相对的(至少有2个写线程)。若都是读线程或者只有一个写线程,那么HashMap还是可以正常使用的。
接下来进入正题:
- 可能出现的现象
- 具体分析
- 总结
- 附录
tips:
下面提到的hashIndex指的是一个元素put到hashmap中时,要根据其key.hashcode & (table.size()-1)来决定其在table中的位置。
table是一个数组,类型为Node。Node是hashmap的一个内部类,用来描述hashmap的元素的一些属性。
1.可能出现的现象
1.数据错乱(出现重复数据)
2.数据丢失(数据插入成功却被丢失)
3.rehash导致的数据丢失
4.rehash导致的死循环
2.具体分析
1.数据错乱
table
------
| 33 |
------
------ ------
| 1 | ->| 9 |
------ ------
------
| |
------
------
| |
------
不考虑rehash。
当两个线程分别插入同一个元素A时,先检查A的hashIndex (由A.hashcode()&(table.size()-1)得到)。
再到该hashIndex对应的链表中对比key是否存在。
此时两个线程进行写入操作时,都发现key不存在,那么都将准备进行插入操作。
插入操作是将元素插入到该链表头部。假设写入时有短暂的先后顺序,结果可能变成:
table
------
| 33 |
------
------ ------
| 1 | ->| 9 |
------ ------
------ ------
| A | ->| A |
------ ------
------
| |
------
我们知道hashmap是不允许出现两个相同的key的,但是这种情况下是有可能出现的。
2.数据丢失
还是两个线程,这次写入的是不同的数据A,B. 计算hashIndex的值是相同的,也就是说A,B将会被插到同一个链表中。
假设他们几乎同时写入同一个链表.
我们应该了解插入元素A的过程,这里简述一下:
(1)计算出A的hashIndex
(2)在对应链表中查找key是否存在
(3)把A.next = table[hashIndex], 把链表头结点与A.next相连
(4)tablep[hashIndex] = A。 插入A为新的链表头结点
假设两个线程几乎同时执行了(3)那么在执行(4)后,可能会有一个元素链会被丢弃。也就是说有一个数据会被丢失。
table
------
| 33 |
------
------ ------
| 1 | ->| 9 |
------ ------
------ ------ ------
| | ----->| B | ----> | A |
------ \ ------ ------
\(丢失)
\ ------ ------
------ -->| C | ----> | A |
| 33 | ------ ------
------
图比较丑,见谅。
由上图所示:
B,C都希望成为链表新的头结点,且均与原头结点A完成了链接。
但实际上B,C只有一个能成为头结点,所以其中一个所在的数据链会丢失。 这也就是数据就是的原因。
3.rehash引起的数据丢失
既然多次提到rehash,那这里也说明一下rehash的过程,以下是部分代码:
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
说明一下 e是table数组中的元素,也就是各个链表的头结点。
oldCap指的是旧table的长度。
newTab指的是新的table。
在rehash时的hashIndex计算方式与put时略有不同。
put: A.hashcode & (table.size() -1)
rehash: e.hash & oldCap
形象的举个例子:
table 假设长度为4
index索引 | 0 | 1 | 2 | 3 |
---|---|---|---|---|
头结点hash | 1 | |||
下一个结点 | 5 | |||
下一个结点 | 9 |
ok,由于元素个数/散列槽个数 = 3/4 到达默认的rehash临界点,下面开始rehash。
先取index索引为0的链表,发现为空,所以不操作。
取index索引为1的链表,发现头结点不为空,e = 1, next = 5。
根据e.hash & oldCap = 1 & 4 = 0。loHead = e; loTail = e; loHead = loTail = e。
next != null 。
根据e.hash & oldCap = 5 & 4 != 0。hiHead = e; hiTail = e; hiHead = hiTail = e。
next != null 。
根据e.hash & oldCap = 9 & 4 = 0。loHead = e; loTail = e; loHead = 1 -> 9 = loTail。
至此index索引为1的链表rehash完毕,放入新的数组。
这里解释下loHead,loTail 与 hiHead,hiTail 的意义。
根据之前e.hash & oldCap的结果,可以看出,如果当e.hash & oldCap != 0 ,只可能 oldCap < e.hash < newCap,1rehash时,table数组长度变为原来的2倍,这点我相信我们都是知道的。
那么e.hash & oldCap != 0这种情况下,e.hash & (newCap - 1) = e.hash ,比如5 & (8-1) = 5。
但是一旦e.hash > newCap,e.hash & oldCap = 0。这个同样可以验证出来。
知道了这个结论,那么loHead,loTail的作用就是存放rehash后,hashIndex仍为原值的元素。
而hiHead,hiTail存放hashIndex变化的元素,一般来说,这里只会有一个元素。
那么接下来的操作就比较明朗了,分别把两个链表接到新的table中。
至于hihead,hiTail插入到哪个tableIndex下,newTab[j + oldCap] = hiHead 该操作已经说明了,j指的是该元素在原table中所处的tableIndex。
说到这里rehash的过程大致就说完了,那为什么多线程写也可能在rehash操作时产生异常呢。
rehash发生在插入一个数据后(注意:此时已经插入完毕).发现负载因子达到阈值。那么会触发rehash操作。
现有线程A,B 同时对一个hashmap进行写入,A写入后,发现元素过多,进行rehash。
假设原table长度是4.
A在遍历到tableIndex = 1时(说明tableIndex = 0 下的链表已经rehash完毕)。线程B在tableIndex = 0 处插入一个元素,但是线程A对于tableIndex = 0 已经处理完毕,不再理会。
所以A不知道B插入了新数据,或者说丢弃了B插入的数据。继续遍历。
接下来有两种情况:
1.在B线程插入操作完成时,A线程的rehash操作已经完成了,新的table替换了旧的table,B线程完成插入后检查是否需要进行rehash时拿到的是新的table,所以不用进行rehash。
但是B线程此刻却还不知道自己插入的数据可能已经被丢失。
2.在B线程插入操作完成时,A线程还没有完成rehash操作。结果B插入完数据后发现也需要进行rehash,那么B对旧的table再进行一次rehash。然后A完成rehash,继而有可能继续对hashmap进行插入操作,但B不知道A已经做了一次rehash,所以在B完成rehash后直接覆盖掉了A线程rehash产生的新的table,这样的话,会导致严重的数据丢失。
4.rehash时导致的死循环
这种情况出现的几率并不大,而且说明起来也比较复杂,这里就简单讲一下。
首先在之前的rehash导致的数据丢失中已经说明了,在对一个节点进行rehash时,会保存其当前节点e 以及e.next。
第二,在map中插入一个元素我们知道是插入在链表头部,也就是说,原本链表中的元素 1 -> 9 在rehash后,如果还是在同一链表中,先后顺序会颠倒,变成 9 ->1。
同样是两个线程A,B。也同样是进行rehash操作。
table
------
| 33 |
------
------ ------
| 1 | ->| 9 |
------ ------
------
| |
------
------
| |
------
那上图中的1 , 9 为例 。取到1后, e = 1。 next = e.next = 9
A,B线程同时完成了读取结点1,以及保存其next 9。
此时B线程有序CPU调度问题,挂起了一段时间,在此期间A线程完成了将1, 9 rehash的过程。
在A创建的新table中, 已经形成了 9 ->1这样的链表结构。
此时B恢复运行,由于它已经加载了1跟它的next结点9,观察上面给出的rehash的源代码,在将结点1放入lohead或hiHead后,判断next是否为空(当然此时next是9,当然非空)。
同上 e = 9 ; next = e.next = 1。 这里要注意,因为A线程已经改变了结点1跟9的指向,所以B线程在获取9的next的时候能获取到,而且是结点1。
接下去B线程也将9插入到loHead或者hiHead中,并使1重新指向9。
至此 1 ->9 且 9 ->1
由于循环条件是 while ((e = next) != null);
产生了死循环。
3.总结
1.对HashMap究竟为何线程不安全有所了解。
2.从源码角度理解了rehash的部分实现。