【Java并发基础】并发编程bug源头:可见性、原子性和有序性

前言

CPU 、内存、I/O设备之间的速度差距十分大,为了提升CPU的利用率而且平衡它们的速度差别。计算机体系结构、操做系统和编译程序都作出了改进:java

  • CPU增长了缓存,用于平衡和内存之间的速度差别。
  • 操做系统增长了进程、线程,以时分复用CPU,进而均衡CPU与I/O设备之间的速度差别。
  • 编译程序优化指令执行次序,使得缓存可以获得更加合理地利用。

可是,每一种解决问题的技术出现都不可避免地带来一些其余问题。下面这三个问题也是常见并发程序出现诡异问题的根源。编程

  • 缓存——可见性问题
  • 线程切换——原子性问题
  • 编译优化——有序性问题

CPU缓存致使的可见性问题

可见性指一个线程对共享变量的修改,另一个线程能够马上看见修改后的结果。缓存致使的可见性问题即指一个线程对共享变量的修改,另一个线程不能看见。缓存

单核时代:全部线程都是在一颗CPU上运行,CPU缓存与内存数据一致性很容易解决。
多核时代:每颗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;
  }
}

最后执行的结果确定不是20000,cal() 结果应该为10000到20000之间的一个随机数,由于一个线程改变了count的值,有缓存的缘由因此另一个线程不必定知道,因而就会使用旧值。这就是缓存致使的可见性问题。优化

 线程切换带来的原子性问题

原子性指一个或多个操做在CPU执行的过程当中不被中断的特性。spa

UNIX因支持时分复用而名噪天下,早期操做系统基于进程来调度CPU,不一样进程之间是不共享内存空间的,因此进程要作任务切换就须要切换内存映射地址,可是这样代价高昂。而一个进程建立的全部线程都是在一个共享内存空间中,因此,使用线程作任务切换的代价会比较低。如今的OS都是线程调度,“任务切换”——“线程切换”。操作系统

Java的并发编程是基于多线程的。任务切换大多数是在时间片结束时。
时间片:操做系统将对CPU的使用权期限划分为一小段一小段时间,这个小段时间就是时间片。线程耗费完所分配的时间片后,就会进行任务切换。线程

高级语言的一句代码等价于多条CPU指令,而OS作任务切换能够发生在任何一条CPU指令执行完后,因此,一个连续的操做可能会因任务切换而被中断,即产生原子性问题。指针

例如:count+=1, 至少须要三条指令:

  1. 将变量count从内存加载到CPU寄存器;
  2. 在寄存器中执行+1操做;
  3. 将结果写入内存(缓存机制致使写入的是CPU缓存而非内存)

例如:

竞态条件

因为不恰当的执行时序而致使的不正确的结果,是一种很是严重的状况,咱们称之为竞态条件(Race Condition)。

当某个计算的正确性取决于多个线程的交替执行时序时,那么就可能会发生竞态条件。最多见的会出现竞态条件的状况即是“先检查后执行(Check-Then-Act)”操做,即经过一个可能失效的观测结果来决定下一步的动做。

例子:延迟初始化中的竞态条件

使用“先检查后执行”的一种常见状况就是延迟初始化。延迟初始化的目的是将对象的初始化操做推迟到实际被使用时才进行,同时要确保只被初始化一次。

public class LazyInitRace{
    private ExpensiveObject instance = null;
    public ExpensiveObject getInstance(){
        if(instance == null){
            instance = new ExpensiveObject();
        }
        return instance;
    }
}

以上代码便展现了延迟初始化的状况。getInstance()方法首先判断ExpensiveObject是否已经被初始化,若是已经初始化则返回现有的实例,不然,它将建立一个新的实例,并返回一个引用,从而在后来的调用中就无须再执行这段高开销的代码路径。

getInstance()方法中包含了一个竞态条件,这将会破坏类的正确性,即获得错误的结果。
假设线程A和线程B同时执行getInstace()方法,线程A检查到此时instance为空,所以要建立一个ExpensiveObject的实例。线程B也会判断instance是否为空,而此时instance是否为空则取决于不可预测的时序,包括线程的调度方式,以及线程A须要花费多长时间来初始化ExpensiveObject实例并设置instance。若是线程B检查到instance为空,那么两次调用getInstance()时可能会获得不一样的结果,即便getInstance一般被认为是返回相同的实例。

竞态条件并不老是产生错误,还须要某种不恰当的执行时序。然而,竞态条件也可能会致使严重的问题。假设LazyInitRace被用于初始化应用程序范围内的注册表,若是在屡次调用中返回不一样的实例,那么要么会丢掉部分注册信息,要么多个行为对同一组对象表现出不一致的视图。

要避免竞态条件问题,就必须在某个线程修改该变量时,经过某种方式防止其余线程使用这个变量,从而确保其余线程只能在修改操做完成以前或者以后读取和修改状态,而不是在修改状态的过程当中。

编译优化带来的有序性问题

有序性是指程序按照代码的前后顺序执行。编译器以及解释器的优化,可能让代码产生意想不到的结果。

以Java领域一个经典的案例,进行解释。
利用双重检查建立单例对象

public class Singleton{
    static Singleton instance;
    static Singleton getInstance(){
        if(instance == null){
            synchronized(Singleton.class){
                if(instance==null){
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

假设有两个线程A和线程B,同时调用getInstance()方法,它们会同时发现instance==null,因而它们同时对Singleton.class加锁,可是Java虚拟机保证只有一个线程能够加锁成功(假设为线程A),而另外一个线程就会被阻塞处于等待状态(假设是线程B)。
线程A会建立一个Singleton实例,而后释放锁,锁释放后,线程B被唤醒,线程B再次尝试对Singleton.class加锁,此时能够加锁成功,而后检查instance==null时,发现对象已经被建立,因而线程B不会再建立Singleton实例。

可是,优化后new操做的指令,将会与咱们理解的不同:
咱们的理解:

  1. 分配一块内存M;
  2. 在内存M上初始化Singleton对象;
  3. 而后将内存M的地址赋值给instance变量。

可是优化后的执行路径倒是这样:

  1. 分配一块内存M;
  2. 将内存M的地址赋值给instance变量;
  3. 在内存M上初始化Singleton对象。

优化后将形成以下问题:

在如上的异常执行路径中,线程B执行第一个判断if(instance==null)时,会认为instance!=null,因而直接返回了instance。可是此时的instance是没有进行初始化的,这将致使空指针异常。
注意,线程执行synchronized同步块时,也可能被OS剥夺CPU的使用权,可是其余线程依旧是拿不到锁的。

解决如上问题的一个方案就是使用volatile关键字修饰共享变量instance。

public class Singleton {
  volatile static Singleton instance;    //加上volatile关键字修饰
  static Singleton getInstance(){
    if (instance == null) {
      synchronized(Singleton.class) {
        if (instance == null)
          instance = new Singleton();
        }
    }
    return instance;
  }
}

目前能够简单地将volatile关键字的做用理解为:

  1. 禁用重排序;

  2. 保证程序的可见性(一个线程修改共享变量后,会马上刷新内存中的共享变量值)。

小结

本篇博客介绍了致使并发编程bug出现的三个因素:可见性,有序性和原子性。本文仅限于引出这三个因素,后面将继续写文介绍如何来解决这些因素致使的问题。若有不足,还望各位看官指出,万分感谢。

参考: [1]极客时间专栏王宝令《Java并发编程实战》 [2]Brian Goetz.Tim Peierls. et al.Java并发编程实战[M].北京:机械工业出版社,2016

相关文章
相关标签/搜索