HashMap的线程安全问题,可运行demo

本文深入探讨了JDK 1.7中HashMap在并发场景下的问题,特别是尾插法导致的循环链表问题,并通过示例代码模拟并发环境,揭示了死锁现象的原因。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

前言

在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卡死,最终程序崩溃
左图为线程执行前,两个线程都拿到了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了:
在这里插入图片描述在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值