并发问题的源头—原子性、可见性、有序性。

  • 源头的源头——为什么会有这三个问题。

先说并发问题的源头:

  1. 原子性问题是因为多线程切换,导致程序没有按照自己的意愿正确执行。
  2. 可见性问题是因为数据在缓存中的更新不能及时的通知其它线程。
  3. 有序性问题是因为编译器优化使程序的执行顺序发生变化导致程序发生异常结果。

那么,这三个问题的源头又是什么呢?——那就是为了缓解CPU、内存、硬盘这三者的速度差异带来的问题

我们都知道,这三者的速度差异非常的大,无论哪一代计算机都有这样的特征。由于木桶效应,所以就需要有一些方法优化它们速度差异所带来的性能瓶颈,这里我们说跟并发问题有关的:

  1. CPU增加缓存,避免每次都在内存读取数据。
  2. 操作系统层面增加进程、线程的概念,每个程序分时复用CPU,缓解CPU与磁盘I/O的速度差异。
  3. 编译器优化执行指令,使CPU更好利用缓存。

 

  • 缓存的可见性问题

在现代多核CPU中,每个核心都会有自己的独立缓存。而CPU利用缓存,缓和了CPU与内存之间的速度差异带来的问题。但是,我们都知道一个运行时的程序,它的运行数据是放在内存当中的。而CPU在计算数据的数值后,把存放在缓存中的值再次写回内存的时机是不确定的。这样,就会发生缓存可见性问题。当然,其实还有很多地方都有缓存可见性问题,这里只说了其中一个。

例如:当CPU的多个核心参与一个程序的运行,当不同核心间进行了各自的计算,把计算后的值放入自己的缓存而不选择写入内存中。那么,在CPU的缓存中,这个共享变量有可能存放着不同的值,这就导致了缓存的可见性问题。即一个线程对变量的修改应该对另一个线程可见。具体的情况如图所示:

我们还可以看一段代码,这是其中可以是可见性问题,也可以是下面说到的原子性问题。

 1  public void fun() throws InterruptedException {
 2         final CountDownLatch latch = new CountDownLatch(2);
 3         Runnable task = ()->{
 4             for (int i=0;i<100000;i++){
 5                 a++;
 6             }
 7             System.out.println("done");
 8             latch.countDown();
 9         };
10         ExecutorService pool = Executors.newCachedThreadPool();
11         pool.execute(task);
12         pool.execute(task);
13         //等待线程执行完毕输出a的值
14         latch.await();
15         pool.shutdown();
16         System.out.println(a);
17     }

 

  • 原子性问题

原子性问题是由于多线程程序在运行中,是利用分时策略来运行每一个线程的,当一个线程对一个共享变量进行操作,还没有执行完毕就被剥夺了执行权。那么,当另一个线程也对共享变量进行操作的时候,最后得出的结果可能不是我们理想中的结果。这里我们举一个面试中经常会问到的例子:long型变量在32位的机器里进行值修改为什么会有并发问题

答案就是:在32位的机器中,对long型变量的赋值是非原子的,因为long型的表示位数是64位的,在赋值的时候需要先对高32位进行赋值,再对低32位赋值。这样,在一个进行赋值操作的并发程序中,在对long型的高32位赋值之后,线程的执行权利被剥夺,此时另一个线程进行了读取操作,那么就会读到一个错误的值。过程如图所示:

 

  • 有序性问题

有序性是因为编译器会对我们的代码进行指令重排序优化,虽然它并不会影响程序最终的结果。就如:int a=7; 与 int b = 6; 编译器换一个顺序。但是,在某些时候还是会发生一些意向不到的错误,并且这种错误还比较难想到。这里的例子是:Java的单例模式中的双重检查法带来的问题。

 1 /**
 2  * @author HILL
 3  * @version V1.0
 4  * @date 2019/7/20
 5  * 单例模式demo
 6  **/
 7 public class Single {
 8     private static Single obj = null;
 9     private Single() {
10     }
11     public static Single getInstance(){
12         if (obj==null){
13             synchronized (Single.class){
14                 if (obj==null){
15                     obj =  new Single();
16                     return obj;
17                 }
18             }
19         }
20         return obj;
21     }
22 }

上面的代码中,第一层if判断是为了避免直接进入synchronize关键字,导致并发状态性能下降的问题。第二个if判断是为了防止后面拿到锁的线程,在先于它的线程完成初始化后继续初始化从而导致内存泄漏的问题。这样的一个单例看上去貌似没有什么问题。但是,由于编译器的重排序问题,这里的obj = new Single();与我们想象中的不同。

理论上是这样的:

  1. 为新建对象开辟内存空间
  2. 在内存空间初始化Single对象
  3. 把Single对象的内存地址赋值给obj

但实际上,由于编译器的优化,它会变成这样:

  1. 为新建对象开辟内存空间
  2. 把内存地址赋值给obj
  3. 在内存空间初始化Single对象

那么,在线程执行到第二步的时候,执行权被剥夺。随后另一个线程同样需要一个单例对象,这时,它的非空判断是判断为有对象的,但实际上,Single对象还没有初始化成功,这是就会导致空指针异常。

图片来自:https://time.geekbang.org/column/article/83682

  • 总结

上面提到了问题,可以适当利用相关的关键字解决:volatile 、synchronize、final。当然,并不是简简单单的加上关键字就能完美的解决,因为在解决并发的同时还要考虑性能问题,不然多线程的意义在哪?同时,并发编程的学习我认为不是懂几个概念就够了的,还是要多去思考,不能人云亦云,对于我自己,我也觉得思考的不够。而原子性、可见性、有序性的概念不是为了分清程序到底属于哪一种问题,而是更多的使我们能从这几个概念出发,分析出问题所在。

 

posted @ 2019-07-20 16:00  半生瓜丶  阅读(665)  评论(0编辑  收藏  举报