JAVA并发编程(一):可见性、原子性和有序性

JAVA并发编程:可见性、原子性和有序性

  • CPU、内存、IO直接的关系
  • 线程安全场景一:缓存导致的可见性问题
  • 线程安全场景二:线程切换带来的原子性问题
  • 线程安全场景三:编译优化带来的有序性问题
  • 附送:在 32 位的机器上对 long 型变量进行加减操作存在并发隐患的原因

CPU、内存、IO直接的关系

CPU:判断以及逻辑处理。
内存:处理数据的地方,数据的来源是从硬盘加载进内存。内存本身有一定的存储空间,对内存中的数据进行处理的速度比从硬盘取数据再处理的速度快很多。
硬盘:数据存储。

我们的 CPU、内存、I/O设备都在不断迭代,不断朝着更快的方向努力。但是,在这个快速发展的过程中,有一个核心矛盾一直存在,就是这三者的速度差异。CPU和内存的速度差异可以形象地描述为:CPU 是天上一天,内存是地上一年(假设 CPU 执行一条普通指令需要一天,那么 CPU读写内存得等待一年的时间)。内存和 I/O 设备的速度差异就更大了,内存是天上一天,I/O 设备是地上十年。

由于三者直接速度的巨大差异,如果不加以平衡,就会出现CPU利用率低下的情况(由于CPU处理效率过快,内存以及IO跟不上,CPU就处于长期空闲状态),为了合理利用 CPU 的高性能,平衡这三者的速度差异,计算机体系机构、操作系统、编译程序都做出了贡献,主要体现为:

1、CPU 增加了缓存,以均衡与内存的速度差异;
2、操作系统增加了进程、线程,以分时复用 CPU,进而均衡 CPU 与 I/O 设备的速度差异;
3、编译程序优化指令执行次序,使得缓存能够得到更加合理地利用。

线程安全场景一:缓存导致的可见性问题

多核时代,每颗 CPU 都有自己的缓存,这时 CPU 缓存与内存的数据一致性就没那么容易解决了,当多个线程在不同的 CPU 上执行时,这些线程操作的是不同的 CPU 缓存。比如下图中,线程 A 操作的是 CPU-1 上的缓存,而线程 B 操作的是 CPU-2 上的缓存,很明显,这个时候线程 A 对变量 V 的操作对于线程 B 而言就不具备可见性了。这个就属于硬件程序员给软件程序员挖的“坑”。
JAVA并发编程(一):可见性、原子性和有序性_第1张图片

线程安全场景二:线程切换带来的原子性问题

我们把一个或者多个操作在 CPU 执行的过程中不被中断的特性称为原子性。CPU 能保证的原子操作是 CPU 指令级别的,而不是高级语言的操作符,这是违背我们直觉的地方。因此,很多时候我们需要在高级语言层面保证操作的原子性。

例如代码中的count += 1,至少需要三条 CPU 指令。

指令 1:首先,需要把变量 count 从内存加载到 CPU 的寄存器;
指令 2:之后,在寄存器中执行 +1 操作;
指令 3:最后,将结果写入内存(缓存机制导致可能写入的是 CPU 缓存而不是内存)。

现在有两个线程A,B同时执行了这行count+=1语句,A线程先执行到了指令1,这时候发生线程切换,B线程把三个指令全部执行结束后再次发生线程切换,这时候A线程由于已经读取了count值为1,继续执行+1操作,就发生了线程安全问题,“读、改、写”应该作为一个原子性的操作。

线程安全场景三:编译优化带来的有序性问题

那并发编程里还有没有其他有违直觉容易导致诡异 Bug 的技术呢?有的,就是有序性。顾名思义,有序性指的是程序按照代码的先后顺序执行。编译器为了优化性能,有时候会改变程序中语句的先后顺序,例如程序中:“a=6;b=7;”编译器优化后可能变成“b=7;a=6;”,在这个例子中,编译器调整了语句的顺序,但是不影响程序的最终结果。不过有时候编译器及解释器的优化可能导致意想不到的 Bug。

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()方法,A线程先进入,由于是第一次执行方法,在执行到instance = new Singleton(),我们先把这段语句翻译成CPU指令:

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

但由于编译器及解释器的优化,可能CPU指令就变成了这样:

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

好,我们接着前面的继续走,A线程执行到instance = new Singleton(),我们知道实际上执行的是编译后的CPU指令,当A线程执行完上述指令2时,发生了线程切换,B线程开始执行getInstance()方法,当判断if (instance == null) 为false时,直接返回了instance对象,可是这时候instance对象还未进行初始化,在接下来B线程如果继续调用instance对象,就会发生空指针异常。

附送:在 32 位的机器上对 long 型变量进行加减操作存在并发隐患的原因

long类型64位,所以在32位的机器上,对long类型的数据操作通常需要多条指令组合出来,无法保证原子性,所以存在并发隐患。

文章参考:Java并发编程实战 ——王宝令

你可能感兴趣的:(多线程)