并发编程之可见性,原子性和有序性解析问题的源头

前言

我们都知道,CPU,内存和I/O 设备都在不断更新迭代,速度提升越来越快,但是其中有一个非常显而易见的问题——那就是这三者的速度差异。速度等级划分:CPU > 内存 > I/O设备。根据木桶理论(一个桶最多能盛下多少水,取决于最短的那块木板)可以得出结论,程序整体的性能取决于最慢的操作,即读写IO设备。

程序里大部分语句都要访问内存,或者读写IO设备,但是这三者之间存在速度差异,为了平衡这三者的速度差异,计算机体系结构,操作系统和编译程序都做了优化:

1、CPU增加了缓存,以均衡与内存的速度差异。
2、操作系统增加了进程,线程,以分时复用CPU,进而均衡CPU与IO设备的速度差异。
3、编译程序优化指令的执行次序,使得缓存能够更加合理的利用。
并发编程通常出现的问题解决方案逃不出上述这三种,下面我们详细说明。

缓存导致的可见性问题

目前项目上一般都用多核CPU,其中每个CPU都有自己的缓存,这时CPU缓存和内存数据就存在一致性问题。当多个线程在不同的CPU上执行时,这些线程操作的不同的CPU缓存。

举个例子:
线程A操作的是CPU1上的缓存,线程B操作的是CPU2上的缓存,这个时候线程A对变量V的操作对于线程B来说就不具备可见性了。
在这里插入图片描述
可见性:一个线程对共享变量的修改,其他线程能够立刻看到,称为可见性。
解决可见性问题用volatile关键字就可以很好的解决。我们知道,volatile关键字是用来解决可见性,有序性问题的,被volatile关键字修饰的变量,会确保值的变化被其他线程所感知,从而从主存中取得该变量最新的值。

线程切换带来原子性问题

由于CPU会进行线程调度,任务切换,所以可能会导致同一个操作不能一次性执行完成,这时就会产生执行结果和我们预期不一致的问题。

Java并发程序是基于多线程的,那就会涉及到任务切换。举个例子来说明线程切换带来的问题:
执行count+=1,至少需要三条CPU指令:
1、指令1:需要把变量count从内存加载到CPU寄存器
2、指令2:在寄存器中执行加1操作
3、指令3:将结果写入内存(缓存机制导致写入的是CPU缓存而不是内存)
在这里插入图片描述
操作系统做任务切换,是可以发生在任何一条CPU指令执行完。如果线程A在指令1执行完后做线程切换,线程A和线程B会按照上图执行,最后的结果是1而不是期望得到的2。

原子性:把一个或多个操作在CPU执行的过程中不被中断的特性称为原子性。
Atomic类可以解决原子性问题。Atomic是原子性的轻量级实现。Atomic相关类在Java.util.concurrent.atomic包中。针对不同的原生类型及引用类型,有 AtomicInteger、AtomicLong、AtomicBoolean、AtomicReference 等。另外还有数组对应类型 AtomicIntegerArray、AtomicLongArray、AtomicReferenceArray。

对于上述例子,我们可以把count变量前用AtomicInteger修饰,即可保证操作的原子性。

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

代码执行过程中,编译器为了优化性能,有时候会改变程序中语句的先后顺序。例如程序中:“a=6;b=7;” 编译器优化后可能会变成“b=7;a=6;”。这种指令的重排序有时不影响程序的执行结果,但是也可能会导致你意想不到的bug。

Java中关于编译优化指令重排序造成的异常问题,典型案例就是双重检查创建单例对象。
假设有两个线程A,B同时调用getInstance方法,会同时发现instance==null,所以同时对Singleton.class 加锁,但是JVM只能保证一个线程加锁成功(假设是线程A),另外一个线程则会处于等待状态(线程B);线程A会创建一个Singleton实例,之后释放锁,锁释放后,线程B被唤醒,线程B再次尝试加锁,此时是可以加锁成功的,加锁成功后,线程B检查install == null 时会发现,已经创建过Singleton实例了,所以线程B不会再创建一个Singleton实例。

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

理想情况是上述描述的。但是这个getInstance方法在new操作上会存在问题。
理想情况:
1、分配一块内存M
2、在内存M上初始化Singleton对象
3、然后M的地址赋值给instance变量

编译优化后:
1、分配一块内存M
2、然后M的地址赋值给instance变量
3、在内存M上初始化Singleton对象

在这里插入图片描述
编译优化后带来的问题:
假设线程A先执行getInstance方法,当执行完指令2时恰好发生了线程切换,切换到了线程B上;如果此时线程B也执行getInstance方法,那么线程B在执行第一个判断时会发现instance!=null,所以直接返回instance,而此时的instance是没有初始化过的,如果我们这个时候访问instance的成员变量可能触发空指针异常。

有序性:代码在运行期间保证按照编写的顺序,换句话说,代码执行的顺序和编写顺序一致。
解决有序性问题用volatile关键字也可以很好的解决。我们知道,volatile关键字是用来解决可见性,有序性问题的,被volatile关键字修饰的变量,会确保代码执行顺序和书写顺序一致,在变量前加volatile关键字可以保证有序性。

小结

只要我们能够深刻理解可见性、原子性、有序性在并发场景下的原理,很多并发 Bug 都是可以理解、可以诊断的。
缓存导致的可见性问题,线程切换带来的原子性问题,编译优化带来的有序性问题,其实缓存、线程、编译优化的目的和我们写并发程序的目的是相同的,都是提高程序性能。但是技术在解决一个问题的同时,必然会带来另外一个问题,所以在采用一项技术的同时,一定要清楚它带来的问题是什么,以及如何规避。

备注:本文参考了极客时间上的课程,欢迎大家一起讨论交流~

猜你喜欢

转载自blog.csdn.net/Sophia_0331/article/details/106340684
今日推荐