HashMap线程不安全实例(jdk1.7)

一、前言

jdk1.7中,以put()方法举例,线程不安全的一些情况:

1,初始化HashMap的桶数组的时候,一个线程初始化了桶数组并插入了第一个元素,但是另一个线程不知道初始化好了,也执行了初始化的操作,清除了前面线程已经插入的元素;

2,两个线程同时触发扩容,在翻转同个桶位上的链表时,链表形成环,类似循环依赖;

 

二、初始化桶数组的例子

1,设计思路

  将HashMap的put()方法分为两个步骤,步骤1打探桶数组是否为空,步骤2根据打探的结果(空就初始化数组,不空则使用已有的数组)插入元素。在步骤1和步骤2间打断点,详细流程如下:

a,准备一个空map;

b,创建线程0,向map中插入元素,走到断点处,发现map的桶数组为空;

c,创建线程1,向map中插入元素,走到断点处,发现map的桶数组为空;

d,选择线程1,前进走过断点,完成步骤2,初始化桶数组,并插入了一个元素;

e,选择线程0,前进走过断点,执行步骤2,虽然此时另一个线程已经初始化过桶数组了,但是线程0是不知道的,它只打探一次,不管过多久都使用自己的消息,所以接下来它会把桶数组初始化,reset,归零。最后再插入自己的元素。

import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.CountDownLatch;

        final Map<Integer, String> map = new HashMap<>();

        // 初始值2,线程执行完后减1
        final CountDownLatch countDownLatch = new CountDownLatch(2);

        Thread thread1 = new Thread(new Runnable() {
            @Override
            public void run() {
                map.put(1, "春");
                countDownLatch.countDown();
            }
        });

        Thread thread2 = new Thread(new Runnable() {
            @Override
            public void run() {
                map.put(2, "花");
                countDownLatch.countDown();
            }
        });

        thread1.start();
        thread2.start();

        Map<Integer, String> testDebugMap = new HashMap<>();
        testDebugMap.put(3, "秋");
        System.out.println("测试debug有没有阻塞其他线程:" + testDebugMap);

        // 等两个线程内部逻辑都执行结束后,再执行下面的代码
        countDownLatch.await();
        System.out.println(map);

 2,设置断点

在map的put()方法中,在(1,设计思路)步骤1和步骤2之间设置断点;

右键断点(行号后面的红点)进行详细配置:Suspend选择Thread;Condition填入!"main".equals(Thread.currentThread().getName());

Suspend=Thread:有线程走到这儿的时候,只停住当前线程,当前线程停住的这段时间其他线程该怎么走就怎么走,除非其他线程后来也有走到这儿的了,也会停住;线程与线程之间互不影响;

Suspend=All:有线程走到这儿的时候,世界按下暂停键,所有线程停止运行,留在原地;但是解除暂停键后,其他线程即使走到这儿也不会停住了;

Condition:进一步加个条件,此处配合Suspend=Thread表示线程走到此处是否停住还要看是否满足这个条件。

3,线程0走到断点处停住

点击debug按钮,开始调试;

线程0走到断点处停住了,如下图所示

 4,线程1也走到断点处停住

线程1走到断点处也停住了,如下图所示

5,主线程走到CountDownLatch的等待处停住

主线程在countDownLatch.await()处停住了,需要等线程0和1都执行完毕了,才会执行下面的代码,如下图所示

6,断点配置的其他说明

Suspend配置成Thread后,可以选择thread0走两步,再选择thread1走几步,还能再选回thread0走几步,线程之间不受影响;

假如Suspend配置成All后,先走到此处的线程停住,同时其他线程都停在了其他地方;随便选哪个线程往下走,其他线程就会直接走到最后;就只是一个暂停键的作用,暂停用过之后,那个断点就没用了。

7,选择线程1,完成桶数组的初始化,并插入第一个元素

如下图,鼠标左键依次点击控制台的“Debugger”——>"倒三角“——>”线程1“,从而选择线程1

点击步进或者快进,让线程1走完;此时HashMap的桶数组插入了一个元素,如下图

8,选择线程0

鼠标左键依次点击控制台的“Debugger”——>"倒三角“——>”线程0“,从而选择线程0;

发现HashMap的桶数组因为线程1先前的操作存在元素了,如下图

9,线程0步进一步

点击步进按钮,执行断点处的操作,发现线程0初始化了HashMap的桶数组,即又让桶数组回到了初始化状态,全是null,如下图

 10,线程0执行剩下的代码

点击步进或者快进,让线程0走完剩下的代码;

最终结果是HashMap中只有线程0的元素,因为线程1的元素被线程0初始化桶数组的时候清除了;但是size=2,因为初始化的是桶数组,并没有初始化size,如下图

11,主线程走完

线程0和线程1走完后,主线程也被唤醒了,走完了剩下的代码;

打印结果也和上面分析一致,如下图

 12,其他情况

方法只要不是加了锁,操作共有资源的时候,容易发生线程安全问题,如下面图示的情况等

 举个简单的例子:

单线程的时候,老对象是“春”->“花”->null,待插入的元素是“秋”,按照头插法,新元素插到老对象的前面,即最终结果为“秋”->“春”->“花”->null,没有问题;

多线程的时候,

a,线程0的情况是,老对象是“春”->“花”->null,待插入的元素是“秋”,断点打在给变量e赋值之后,插入元素之前;

b,线程1的情况是,老对象是“春”->“花”->null,因为线程0还没有插入,保持原样,待插入的元素是“月”,断点打在给变量e赋值之后,插入元素之前;

c,线程0完成插入动作,桶位上的链表为一个新的new出来的对象,“秋”->“春”->“花”->null;

d,线程1现在的情况是,无法得知线程0的操作,因为线程0断开了桶位和老对象之间的联系,指向了一个新的对象,线程0的改动都体现在新对象上了,而线程1持有的还是老对象。所以,线程1执行插入动作,将新元素插到老对象的前面,变成“月”->“春”->“花”->null,并赋值给了桶位。

最终,桶位的链表是,“月”->“春”->“花”->null,丢失了线程0插入的元素“秋”。

 

三、扩容翻转链表形成环的例子

1,设计思路

线程0和线程1同时触发扩容翻转,在翻转时,两个线程配合好,使链路出现环路。

2,准备工作

a,准备两个key,使它们落在同一个桶位上,且扩容后还在一个桶位上;同理,再准备三个key,使它们落在同一个桶位上,且扩容后还在一个桶位上,且和前面步骤的桶位有所区分

    public static void main(String[] args) {
        // 打印和1(key)落在同一桶位的key,且扩容后也在同一桶位
        printSameBucketKey(1);

        System.out.println("++++++++++++++++++++++++++++++++++++++++++");

        // 打印和2(key)落在同一桶位的key,且扩容后也在同一桶位
        printSameBucketKey(2);
    }

    /**
     * 打印和targetKey落在同一桶位的key,且扩容后也在同一桶位
     *
     * @param targetKey
     */
    public static void printSameBucketKey(int targetKey) {
        for (int i = 1; i < 100; i++) {
            if (hash(targetKey) % 8 == hash(i) % 8 && hash(targetKey) % 16 == hash(i) % 16) {
                System.out.println(i);
            }
        }
    }

    // 直接copy的源码,为jdk1.7中hashMap计算hash值的方法
    public static int hash(Object k) {
        int h = 0;
        if (0 != h && k instanceof String) {
            return sun.misc.Hashing.stringHash32((String) k);
        }
        h ^= k.hashCode();
        h ^= (h >>> 20) ^ (h >>> 12);
        return h ^ (h >>> 7) ^ (h >>> 4);
    }

所以,key为1和16时,在map容量为8时在一个桶位,且容量为16时也在一个桶位;

key为2、19、32时,在map容量为8时在一个桶位,且容量为16时也在一个桶位;

b,测试代码:

        // 初始容量为8,且元素数量达到8*0.3=2时,满足扩容的两个条件之一
        final Map<Integer, String> map = new HashMap<>(8, 0.3f);

        // 在指定桶位上,先后插入两个个元素,按照“头插法”,最后结果为:“花”->"春“->”null“
        map.put(1, "春");
        map.put(16, "花");

        // 在另一个桶位xxx上,插入一个元素,此时不会触发扩容,因为插入前该桶位为空
        map.put(2, "扩容翻转");

        // 初始值2,线程执行完后减1
        final CountDownLatch countDownLatch = new CountDownLatch(2);

        Thread thread1 = new Thread(new Runnable() {
            @Override
            public void run() {
                // 在桶位xxx上插入元素,同时满足扩容的两个条件,即map的元素数量达到2,且插入前桶位不为空
                map.put(19, "扩容翻转1");
                countDownLatch.countDown();
            }
        });

        Thread thread2 = new Thread(new Runnable() {
            @Override
            public void run() {
                // 在桶位xxx上插入元素,同时满足扩容的两个条件,即map的元素数量达到2,且插入前桶位不为空
                map.put(32, "扩容翻转2");
                countDownLatch.countDown();
            }
        });

        thread1.start();
        thread2.start();

        // 等两个线程内部逻辑都执行结束后,再执行下面的代码
        countDownLatch.await();
        System.out.println(map);

c,断点设置

设置在transfer()方法中,遍历链表的地方,如下图所示;

断点配置:Suspend=Thread;Condition=线程的名称不为main

d,链表翻转逻辑预知

简化代码并举例

public class MyNode {
    public int key;
    public String value;
    public MyNode next;

    public MyNode(int key, String value, MyNode next) {
        this.key = key;
        this.value = value;
        this.next = next;
    }

    public static void main(String[] args) {
        // “花” -> “春” -> null
        MyNode node = new MyNode(1, "花", new MyNode(16, "春", null));

        // 新的桶数组
        MyNode[] newTable = new MyNode[1];

        MyNode e = node;
        System.out.println("翻转前:" + e);
        while (null != e) {
            MyNode next = e.next;
            e.next = newTable[0];
            newTable[0] = e;
            e = next;
        }

        System.out.println("翻转后:" + newTable[0]);
    }

    @Override
    public String toString() {
        return "MyNode{" +
                "key=" + key +
                ", value='" + value + '\'' +
                ", next=" + next +
                '}';
    }
}

第一次while循环

执行next = e.next;

执行e.next = newTable[0];

执行newTable[0] = e;

执行e = next;

第二次while循环,执行next = e.next;

执行e.next = newTable[0];

执行newTable[0] = e;

执行e = next;

第三次while循环

while条件(e!=null)不满足,跳出while循环;

打印桶数组,和上图结果一样,桶位上的链表发生了翻转

提示:可以使用亿图软件画图,赋值的时候,只需要通过调整箭头指向来模拟。

3,点击debug按钮,开始调试程序

点击debug按钮,线程0和线程1都走到了断点处,如下图

4,选择线程0,步进一步

执行next = e.next;

5,选择线程1,完成翻转操作

选择线程1,快进或跳过断点,执行到最后;

根据“d,链表翻转逻辑预知”的经验,完成翻转后,结果如下图;并同步修改了table(map的桶数组)

6,选择线程0,步进执行翻转操作

因为线程1的操作,堆中各个对象的关系发生了改变,如下图

执行e.next = newTable[0];

无变化;

执行newTable[0] = e;

执行e = next;

第二次while循环,执行next = e.next;

执行e.next = newTable[0];

无变化

执行newTable[0] = e;

执行e = next;

第三次while循环,执行next = e.next;

执行e.next = newTable[0];

 至此,链表出现了环路。

 

四、总结

1,初始化桶数组的例子

  是一个覆盖的问题,提交svn、git代码的时候经常发生。比如放假前,领导安排了一个任务,和张三、李四说,放假后,你们在家如果谁闲了的话就去完成这个任务。张三打了一天篮球回到家,更新了下代码发现李四没提交代码,觉得他应该是有事在忙,自己就开始在本地写代码。李四也打了一天麻将回来了,也更新了下代码发现张三没提交代码,也觉得对方应该是有事在忙,自己就开始在本地写代码,李四自己写的快就提交了。张三写的慢,可能边写边看电视了,很晚才提交代码。殊不知,张三覆盖了李四的代码。造成了人力的浪费。

2,扩容翻转链表形成环的例子

 

 

 如果有一个球,后面是一个环,前面是一个钩子;有多个这样的球,互相钩连在一起。最后有一条链子,链子的最后一个球是null球。还要有一个单独的null球。再来三个人,每个人抓住其中一个球,然后按照上表中4个步骤顺序操作,有时候人要换个球抓,有时候球前面的钩子要解开然后钩到另外一个球上。

posted @ 2024-03-10 23:11  seeAll  阅读(138)  评论(0编辑  收藏  举报