Java之HashMap在非线程安全时的行为

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索引0123
头结点hash1
下一个结点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的部分实现。


4.附录

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值