阿里一面HashMap的死循环问题

国内成立最早的白癜风医院 http://www.pfzhiliao.com/

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。




转载请注明:http://www.92nongye.com/gaishu/gaishu/204627610.html