美文网首页
01 并发编程bug源:原子性、可见性和有序性

01 并发编程bug源:原子性、可见性和有序性

作者: 写啥呢 | 来源:发表于2019-06-19 15:28 被阅读0次
    计算机发展过程中存在一个核心矛盾:CPU、内存与I/O设备,这三者的速度差异。

    形象比喻:

    1.CPU是天上一天,内存地上一年(假设CPU执行普通指令需要一天,那么CPU读写内存需要一年)。
    2.内存是天上一天,I/O是地上十年。
    
    总结:根据木桶原理(一只桶能装多少水取决于最短的那块木板),
         程序整体性能取决于最慢的I/O设备。单方面提升CPU性能是无效的。
        (类比机械硬盘与固态硬盘对性能的提升。只更换为机械硬盘,内存和CPU不变,你的电脑都能有很大改善。)
    

    如何平衡速度差异:

    1.CPU加缓存----------平衡与内存速度差异。
        举例:(数组在内存中是占据连续的内存空间的,而CPU在从内存中读取数据的时候会把该内存地址后面的一部分数据也
    缓存进去。这样CPU在访问数组数据的时候先从CPU缓存的数组中寻找,找不到再从内存中复制。这也就是CPU缓存的意义,)
    2.操作系统增加了进程、线程以分时复用CPU-------平衡CPU和I/O设备的速度差异。
    3.编译程序优化指令执行次序,使得缓存利用更加合理。
    
    缓存导致的可见性问题

    在如今的多核时代,每科CPU都有自己的缓存,当多个线程在不同CPU上执行,这些线程操作的是不同的缓存。某个线程对共享变量的操作,会出现对另外线程不可见的情况。

    public class Test {
      private long count = 0;
      private void add10K() {
        int idx = 0;
        while(idx++ < 10000) {
          count += 1;
        }
      }
      public static long calc() {
        final Test test = new Test();
        // 创建两个线程,执行 add() 操作
        Thread th1 = new Thread(()->{
          test.add10K();
        });
        Thread th2 = new Thread(()->{
          test.add10K();
        });
        th1.start();
        th2.start();
        th1.join();
        th2.join();
        return count; //最终结果会是10000到200000之间的随机数
      }
    }
    
    
    线程切换带来的原子性问题

    时间片和任务切换简单理解:

    1.操作系统运行某个进程执行一小段时间,假如50毫秒,过了50毫秒后操作系统会选择新的进程执行(称之为“任务切换”)
    ,这50毫秒称为时间片。
    
    时间片.png

    线程调度

    计算机通常只有一个CPU,在任意时刻只能执行一条机器指令,每个线程只有获得CPU
    的使用权才能执行指令。所谓多线程的并发运行,其实是指从宏观上看,各个线程轮流获
    得CPU的使用权,分别执行各自的任务。在运行池中,会有多个处于就绪状态的线程在等
    待CPU,JAVA[虚拟机]的一项任务就是负责线程的调度,线程调度是指按照特定机制为多
    个线程分配CPU的使用权。
    

    参考百度百科:https://baike.baidu.com/item/%E7%BA%BF%E7%A8%8B%E8%B0%83%E5%BA%A6/10226112

    调度模型:分时调度模型和抢占式调度模型。


    线程调度模型.png

    放弃CPU使用权的原因:

     1.java虚拟机让当前线程暂时放弃CPU,转到就绪状态,使其它线程获得运行机会。
    2.当前线程因为某些原因而进入阻塞状态。
    3. 线程结束运行。
    
    放弃cpu的原因.png

    bug源之一:任务切换------非原子性。(操作系统做任务切换,可以发生在任何一条CPU指令执行完,而不是高级语言的一条指令。)
    以代码 count+=1为例子。

    指令1:把变量count加载到CPU寄存器。
    指令2:在寄存器执行+1操作。
    指令3:将结果写入内存。(缓存机制可能导致写入的是CPU缓存。)
    

    图示:当两个线程由于任务切换,出现这种情况。线程B没有在线程A的结果基础上进行操作。也就是说线程A count+=1不具备原子性。会导结果异常。


    线程切换.png
    编译器优化:有序性问题

    编译器为了优化性能,有时候会改变程序语句执行顺序。例如 a = 6; b = 7这样的顺序可能有化成b=7;a=6;大多时候不影响程序最终结果。不过有时候编译器及解释器的优化会导致意想不到的BUG。

    以java中经典的一个单例模式写法为例。

    public class Singleton {
      static Singleton instance;
      static Singleton getInstance(){//获取单例
        if (instance == null) { //首先判断是否为空
          synchronized(Singleton.class) {//为空就加锁
            if (instance == null)//并再次检查instance是否为空
              instance = new Singleton();//如果空就创建实例
            }
        }
        return instance;
      }
    }
    
    解释双重检查目的,避免每次都进行加锁操作。
    
    

    java中的new操作

    1.分配一块内存M。
    2.在内存M上初始化Singleton对象。
    3.将M的地址赋值给instance对象。
    
    优化后会变成这样:
    
    1.分配一块内存M。
    2.将M的地址赋值给instance对象。
    3.在内存M上初始化Singleton对象。
    

    上面的new操作在多线程中会出现这样的情况。

    public class Singleton {
      static Singleton instance;
      static Singleton getInstance(){
        if (instance == null) { //线程B刚执行这,发现不为空,立即返回,而线程A由于优化了new的执行顺序还没有真正
    //的初始化。这时会导致空指针异常。
          synchronized(Singleton.class) {
            if (instance == null)       
               instance = new Singleton();        }
        }
        return instance;
      }
    }
    
    

    并发涉及的知识面挺广的,推荐阅读:http://gk.link/a/103WI

    相关文章

      网友评论

          本文标题:01 并发编程bug源:原子性、可见性和有序性

          本文链接:https://www.haomeiwen.com/subject/pvjhqctx.html