前言
在jdk1.7中,hashMap的实现是数组加链表(jdk1.8冲突不强的情况下也是链表)。但是1.7中在转移数组的时候,链表转移方式是尾插法。[扩容的时候会转移数组]
尾插法将带来一定的并发问题,核心在于形成了一个闭环,导致while循环无法结束
由于使用hashMap去重现这个问题,不一定能重现,所以自己写了一个demo,一跑就知道尾插法的问题出在哪里
Demo
模拟代码假设旧的节点数组长度为10,现在要扩容到20。经过hash计算要将原来位于node[5]的链表移植到新的节点数组的newNode[hash=3]位置。
以下模拟了两种移植算法,头插法和尾插法,其中头插法会产生循环数组,导致死循环[好多地方管这叫死锁??没懂]。尾插法就不会。
public class HashMapSimulation {
static Node[] newNodes = new Node[20];
public static void main(String[] args) throws InterruptedException {
Node[] nodes = new Node[6];
nodes[5]=new Node(1);
nodes[5].next=new Node(2);
nodes[5].next.next=new Node(3);
//头插法,jdk1.7。导致循环数组----很多地方说是死锁,感觉叫法不太科学==
new Thread(()->transByHead(nodes,5,3),"thread1").start();
new Thread(()->transByHead(nodes,5,3),"thread2").start();
//尾插法,不会导致循环列表
// new Thread(()->transByTail(nodes,5,3),"thread3").start();
// new Thread(()->transByTail(nodes,5,3),"thread4").start();
}
//将nodes数组中第idx个元素移动到新数组的hash下标处。
public static void transByHead(Node[] nodes,int idx,int hash) {
Node e = nodes[idx];
int i=0;
while(e!=null){
Node next = e.next;
try{
if(i++==0){
if(Thread.currentThread().getName().equals("thread1")) //模拟死锁环境
Thread.sleep(10);
else Thread.sleep(5);
}
}catch (Exception ignored){}
e.next=newNodes[hash]; //当前节点指向新数组位置的头结点
newNodes[hash]=e; //当前节点替换头结点
e=next;
}
}
//尾插法
public static void transByTail(Node[] nodes,int idx,int hash){
Node e = nodes[idx];
Node last = newNodes[hash];
int i=0;
while(e!=null){
Node next = e.next;
try{
if(i++==0){
if(Thread.currentThread().getName().equals("thread1"))
Thread.sleep(10);
else Thread.sleep(5);
}
}catch (Exception ignored){}
if(last!=null)
last.next=e;
last=e;
e=next;
}
last.next=null;
}
}
class Node{
int val;
Node next;
Node(int val){
this.val=val;
}
}
死循环分析
文字版:
最开始的链表为1->2->3.我们的线程1将cur指向1,next指向cur.next=2。然后线程1阻塞
然后线程2仍然将其的cur指向1,next指向cur.next=2。此时线程2继续执行,将链表1->2->3转移到新的数组,最后新的数组节点变成3->2->1的逆序形式[看代码画个图就知道了]
问题来了,此时线程1开始执行。注意我们的线程1中的cur指向1,next指向2.首先第一步将cur.next指向newNode[i],即指向了3,变成了3->2->1->3,,死循环就形成了,因为一旦形成了这样的循环链表,next永远不会为null,trans方法永远无法执行完毕
相反的,尾插法就没有这个问题。
同样,线程1将旧数组中的1->2->3转换到新的数组成为了1->2->3【这个结构实际上没有变,详见尾插法的代码】。此时线程2的cur指向1,next指向2,循环两次后next指向了null,线程2就也正常结束了。所以没问题
图形版
左图为线程执行前,两个线程都拿到了cur,next值。然后线程1正常执行完毕,由于采用了尾插法,得到的新链表如右图所示。注意到线程2的cur和next实际在链表中的位置变了,此时线程2开始执行,首先执行cur.next=newNode[i],一旦这条语句执行完毕,循环链表就形成了,而一旦形成了循环链表,hashmap卡死,最终程序崩溃
其他问题
说hashmap并发不安全,通常指两个方面,一个是上面说的循环链表卡死。二个是两个hash值相同的对象同时put,丢失数据,[还有一个A线程扩容的时候,B线程插入的所有数据都将丢失]
第二种情况可以观察下面这个demo:
public class ConCurrentPutTest {
public static void main(String[] args) throws InterruptedException {
Set<MapObj> set = new HashSet<>();
CountDownLatch latch = new CountDownLatch(1000);
IntStream.rangeClosed(1,1000).forEach(i->{
new Thread(()->{
latch.countDown();
try {
latch.await();
} catch (InterruptedException ignored) {}
set.add(new MapObj());
}).start();
});
Thread.sleep(10000);
System.out.println(set.size());
}
static class MapObj{
@Override
public int hashCode(){ //hashcode一样会被放在一个链表(红黑树)里面[充分不必要]
return 0;
}
@Override
public boolean equals(Object obj){ //equals相同的话就会被判定是同样的对象,会覆盖
return false;
}
}
}
输出如:
线程更多一点,更明显:
如果将程序改成concurrentHashMap就不会有问题——因为没有concurrentHashSet,这里就用map了: