HashMap是线程不安全的,如果是在单线程下使用,是没有问题的,但是在并发使用的时候就有可能出现死循环的问题。
想要了解这个问题,我们要先了解HashMap的数据结构。简单回顾一下HashMap的数据结构:
HashMap内部使用的是一个Entry数组来保存key和value,当我们向HashMap中加入数据的时候,会通过一个散列函数,也就是hash算法得到hash值,然后再和内部数组的长度取模计算出要存放的位置,如果这个位置上已经有数据存在,则产生hash冲突,此时就会在这个位置上生成一个链表。
我们假设有这样一种极端情况,就是所有的元素都定位到了同一个位置,形成一个很长的链表,这样我们get一个值的时候,时间复杂度就变成了O(n),所以说元素的hash值算法和HashMap的初始大小很重要。
不管是极端情况还是正常情况下,我们向HashMap中插入新元素的时候,如果不存在相同的key,则会判断当前内部元素是否已经达到阈值(默认是数组大小的0.75),如果已经达到了阈值,就会对数组进行扩容(默认是扩大两倍),然后再对数组中每个元素进行rehash。
源码分析我们先通过源码分析一下HashMap中Entry数组扩容的整体流程:
1.检查容量是否达到阈值(当前元素/数组大小),如果元素的个数已经达到了阈值,则进行扩容,并把原来的元素移动到新的Entry数组中。
voidaddEntry(inthash,Kkey,Vvalue,intbucketIndex){
if((size=threshold)(null!=table[bucketIndex])){
//如果达到了阈值,就会进行resize扩容操作,新数组长度是旧数组长度的2倍
resize(2*table.length);
hash=(null!=key)?hash(key):0;
bucketIndex=indexFor(hash,table.length);
}
createEntry(hash,key,value,bucketIndex);
}
2.扩容
voidresize(intnewCapacity){
Entry[]oldTable=table;
intoldCapacity=oldTable.length;
...
//创建新数组
Entry[]newTable=newEntry[newCapacity];
...
//移动元素到新数组中,
transfer(newTable,rehash);
table=newTable;
threshold=(int)Math.min(newCapacity*loadFactor,MAXIMUM_CAPACITY+1);
}
3.移动元素到新数组中,并且重新计算每个元素的hash值,拿着新的hash值和新数组的长度做取模操作,找到这个元素在新数组中存放的位置。
voidtransfer(Entry[]newTable,booleanrehash){
intnewCapacity=newTable.length;
for(EntryK,Ve:table){
while(null!=e){
EntryK,Vnext=e.next;
if(rehash){
e.hash=null==e.key?0:hash(e.key);
}
inti=indexFor(e.hash,newCapacity);
e.next=newTable[i];
newTable[i]=e;
e=next;
}
}
}
模拟出现死循环的情况为了方便,我们假设HashMap初始化的大小为4,如果按照默认的负载因子0.75,那么我们插入第三个元素的时候,就会触发扩容操作,为了更好的验证效果,我们假设负载因子是1,也就是插入第四个元素的时候会扩容,而且这些插入的元素全部都在同一个链表上。
在所有的元素都插入到相同的链表中,并且在多线程情况下,两个线程同时进行扩容和rehash操作,会发生什么呢?下面我们来进行逐步分析。
//扩容和rehash的核心步骤
voidtransfer(Entry[]newTable,booleanrehash){
intnewCapacity=newTable.length;
for(EntryK,Ve:table){
while(null!=e){
EntryK,Vnext=e.next;
if(rehash){
e.hash=null==e.key?0:hash(e.key);
}
inti=indexFor(e.hash,newCapacity);
e.next=newTable[i];
newTable[i]=e;
e=next;
}
}
}
就像上面说的极端情况一样,所有的元素都插入到一个链表上。
假设线程2在执行到EntryK,Vnext=e.next;之后,cpu时间片用完了,这时变量e指向节点a,变量next指向节点b。
线程1继续执行,然后a、b、c节点rehash之后又是在同一个位置7,开始移动节点
第一步,移动节点a
第二步,移动节点b
这里需要注意一下,顺序是反过来的。
这个时候线程1的时间片用完,内部的table还没有设置成新的newTable,线程2开始执行,这时内部的引用关系如下:
这时,在线程1中,变量e指向节点a,变量next指向节点b,开始执行循环体的剩余逻辑。
EntryK,Vnext=e.next;
inti=indexFor(e.hash,newCapacity);
e.next=newTable[i];
newTable[i]=e;
e=next;
执行之后的引用关系如下图
执行后,变量e指向节点b,因为e不是null,则继续执行循环体,执行后的引用关系
变量e又重新指回节点a,只能继续执行循环体,这里仔细分析下:1、执行完EntryK,Vnext=e.next;,目前节点a没有next,所以变量next指向null;2、e.next=newTable[i];其中newTable[i]指向节点b,那就是把a的next指向了节点b,这样a和b就相互引用了,形成了一个环;3、newTable[i]=e把节点a放到了数组i位置;4、e=next;把变量e赋值为null,因为第一步中变量next就是指向null;
所以最终的引用关系是这样的:
节点a和b互相引用,形成了一个环,当在数组该位置get寻找对应的key时,就发生了死循环。
此外,如果线程2把newTable设置成到内部的table,节点c的数据就丢了,也会产生数据丢失的问题。
总结所以在并发的情况,发生扩容时,可能会产生循环链表,在执行get的时候,会触发死循环,引起CPU的%问题,所以一定要避免在并发环境下使用HashMap。