浅谈Java8的HashMap为什么线程不安全

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/LovePluto/article/details/79712473

PS:本文使用的Java源码是JDK1.8。
事情起因很简单,起源于类似you can,you up的玩笑。我这人喜欢较真,尤其是遇见我会的问题的时候。
我们先上一组代码。

  public static void main(String[] args) {
    Map<String, String> map = new HashMap<String, String>();
    for (int j = 0; j < 100; j++) {
      double i = Math.random() * 100000;
      map.put("键" + i, "值" + i);
      map.remove("键" + i);
      System.out.println(j + "当前时间:" + i + "   size = " + map.size());
    }
  }

结果如图
这里写图片描述
我添加一个K,然后再移出K,size大小为0,逻辑上说是没有任何问题的。结果证明也没有问题。单线程执行代码一般都是没有任何问题的,是按照逻辑来的。即使指令重排,对结果影响基本为0的。
现在我们上一组多线程代码

  public static void main(String[] args) {

    Map<String, String> map = new HashMap<String, String>();
    for (int i = 0; i < 100; i++) {
      MyThread myThread = new MyThread(map, "线程名字:" + i);
      myThread.start();
    }
  }

  static class MyThread extends Thread {
    public Map map;
    public String name;

    public MyThread(Map map, String name) {
      this.map = map;
      this.name = name;
    }
    public void run() {
      double i = Math.random() * 100000;
      map.put("键" + i, "值" + i);
      map.remove("键" + i);
      System.out.println(name + "当前时间:" + i + "   size = " + map.size());
    }
  }

结果如图
这里写图片描述
好像看着没有任何差异,如果我们扩大循环到100000
结果如图
这里写图片描述
这差距就非常明显了,很明显的有问题的,如果我们线程休眠1ms,再来100个循环。

    public void run() {
      double i = Math.random() * 100000;
      map.put("键" + i, "值" + i);
      try{
        Thread.sleep(1);
      }catch (Exception e){
        e.printStackTrace();
      }
      map.remove("键" + i);
      System.out.println(name + "当前时间:" + i + "   size = " + map.size());
    }

结果如图
这里写图片描述
不用我多说,铁一般的事实在眼前,HashMap不是线程安全的。我们一起去看看源码。
先看看size()这个方法源码

    public int size() {
        return size;
    }

很简单的逻辑,然后我们看看size这个变量说明

    /**
     * The number of key-value mappings contained in this map.
     */
    transient int size;

大意就是说包含的键值对数量,还是一个不可序列化对象,当然就和我们的讲解无关了。
首先这个size没有用volatile关键字修饰,代表这不是一个内存可见的变量。了解过多线程应该都知道,我们线程操作数据的时候一般是从主存拷贝一个变量副本进行操作。
示意图
这里写图片描述
能领悟意思就差不多了,线程中的变量,都是从主存拷贝过去,操作完成过后在把size的值写回到主存size的。
接下来我们分析一下源码put(K key,V value)的实现过程。

    public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
    }

好像没有什么操作,就调用了一个putVal(int hash,K key,V value,boolean onlyIfAbsent,boolean evict)方法,我们继续往下看。putVal()方法也没有用synchronized修饰,代表这个方法里面任意的位置时间片耗尽(可以类比休眠状态,休眠是主动进入阻塞,休眠结束进入就绪状态,时间片耗尽是进入直接进入就绪状态)。

    final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
         //这里是核心,大概就是各种判断,然后赋值的问题,感兴趣的可以自己去了解一下。
        ++modCount;
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }

resize()方法是扩大容器的策略,在这里我们不用管,不是我们讲解的重点,问题出在++size上面的,如果键是以前不存在的,那么必然会执行++size这段逻辑。假设现在我两个线程,每个线程都在执行put方法。
这里写图片描述
size的大致变化过程就是这样的,理论结果应该是size=3的,而我们实际执行的结果是size=2,remove()方法的原理也是差不多的,在这里就不详细解释。这肯定和我们的预期是有差距的,你想想如果去银行存钱你存了两次100元,银行只给你帐号增加100元,你怕是马上就要找银行麻烦了,闹得天下皆知。但是如果一笔钱你能花两次,你估计会非常开心吧,还会觉得银行真傻的,心里偷着乐。

这只是一个int型变量size,我还没有分析table储存问题的,假设我两个线程分别调用put(1,”111”)和put(1,”222”),那么我get(1)取到的究竟是哪个值呢?比如我线程A先调用get(1)在get(1)还没有执行完成的时候,A线程时间片用尽进入就绪状态,然后B线程调用remove(1),A继续回来执行的get(1)的剩余逻辑,会不会找到的呢?这些答案无从得知,有兴趣的可以自己模拟实验一下的。

或许你会说,哪有那么巧合的事情?世界之大,无奇不有。世界那么大,你应该出去看看。

总结:线程不安全问题应该属于并发问题之一的,属于相对高级的问题了。这个时候的问题已经不仅仅局限于代码层面了,很多时候需要结合JVM一起分析了。

如有疑问,欢迎留言!

展开阅读全文

没有更多推荐了,返回首页