Java并发编程 | 并发编程BUG的源头

1,056 阅读10分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第16天,点击查看活动详情

本系列专栏 Java并发编程专栏 - 元浩875的专栏 - 掘金 (juejin.cn)

前言

对于并发编程的Bug,它经常是很诡异的出现,难以复现,一般来说我们就说这个方法或者这个变量是不安全的,需要加锁。

但是为什么会出现这些Bug,这个就涉及了一些计算机的知识,必须要清楚这些Bug出现的源头,这样才可以理解不同的并发策略是为什么这样设计的,以及如何解决的。

正文

本篇文章我们就从计算机的相关知识来说明为什么会出现并发编程的问题,这里首先说一下计算机各个硬件之间的速度差异。

硬件的速度差异

计算机技术的发展速度很快,包括CPU、内存等设备都越来越快,但是在这个发展的过程中一直都有一个核心矛盾,就是:CPU、内存和I/O设备之间的速度差异,这里可以形象地描述为:CPU是天上一天、内存是地上一年(假设CPU执行一条指令需要一天,那CPU读写内存就要一年的时间),而内存和I/O设备的速度差异就更大了,内存是天上一天,I/O设备是地上十年。

CPU的速度不容置疑,现在多核CPU速度至少都是每秒几百亿次浮点运算的速度,可谓是相当快了;

而内存的读写速度现在常见的DDR4大概每秒50G左右,假如一段Java程序加载到内存中,这时CPU需要读取其中的值,计算后,再存储到内存,这时CPU很快就执行完了,其中的主要时间就是从内存中读取值和把值保存到内存中

I/O设备就更慢了,比如慢速的I/O设备包括键盘、打印机,快速的I/O设备包括硬盘和光盘等,就按现在最快的固态硬盘来说也就几百M每秒,假如这时有个业务需要从硬盘读取数据再进行操作,那CPU和内存就要等好久了,时间全消耗在读写硬盘上了。

根据木桶理论:一只水桶能装多少水取决于它最短的那块木板,这就说明你使用再快的CPU,内存和I/O设备太慢也是白搭,所以计算机技术通过这几十年的发展,做出了很多解决方案,主要体现为下面3个:

CPU缓存

CPU增加了缓存,用来均衡与内存的速度差异。这里指的是CPU的高速缓存,属于一个硬件,现在CPU已经发展成L1、L2、L3等好几级高速缓存,高速缓存的速度仅次于CPU的寄存器,所以当需要从内存中读取值时,假如高速缓存中有,则不再花费大量时间再去内存中读取。

线程切换

操作系统增加了进程、线程,以分时复用CPU,从而均衡CPU与I/O设备的速度差异。这是操作系统为我们做的事,比如早期在单核CPU上,电脑也是可以边听歌边写程序,这就是多进程的功劳,操作系统允许某个进程执行一小段时间,过了这段时间就会重新选择另一个线程来执行,这就是任务切换,而这个时间就是"时间片"。

image.png

这样切换来切换去一是可以同时运行多个进程,二是某个进程假如要进行一个I/O操作,因为非常耗时,这时这个进程就可以标记自己为"休眠状态",并且让出CPU使用权,等数据加载到内存时,再继续执行,而在休眠期间,其他线程就可以使用CPU,这就一下子提高了CPU使用率。

现在的操作系统都基于更轻量的线程来做调度了,所以都是线程切换了,但是最初操作系统设计多进程、多线程就是为了复用CPU,来均衡CPU和I/O设备的速度差异。

编译器优化

编译器优化指令执行次序,使得缓存能够得到更加合理地利用

这个啥意思呢,就是我们本来代码指令执行的顺序可能会被改变,但是不会影响最终结果。比如代码中有2个连续的赋值语句"a = 1;b = 2",在编译期优化后可能是"b = 2;a = 1",而这只是编译器的优化点之一,现在编译器还会重排序代码、合并代码、删除无用代码等,但是都不会影响最终结果(多线程不一定,后面说)。

问题源头

前面我们介绍了前人的努力为了解决速度差异和提高效率而提出的各种方案,但是也就是这些方案让并发编程存在了很多诡异的Bug,我们就来看一下。

缓存导致可见性问题

前面说了为了平衡CPU和内存之间的速度,在CPU上增加了缓存,在单核时代,只有一个CPU和一个缓存,如下图:

image.png

这里线程A和线程B都是运行在该CPU上,假如线程A更新了变量V的值,那么线程B再访问V,得到的一定是V的最新值,一个线程对共享变量的修改,另外一个线程能够立刻看见,这就是可见性

(注意上面的单核例子是针对内存和缓存之间关系来说的,而不涉及CPU操作,因为后面有其他问题。)

而在多核时代,每颗CPU都有自己的缓存,这时缓存和内存数据的一致性就没那么容易解决了,当多个线程在不同的CPU上执行,这些线程操作的是不同CPU的缓存,比如下图:

image.png

假如线程A修改了CPU-1里缓存的变量V的值,而CPU-1和内存之间的同步又没有完成,这时线程B对变量V的值进行操作就不具备可见性了。

线程切换导致的原子性问题

前面说了CPU会不断的切换线程来保证它自己一直有活干,而且为了保证多线程正常执行,这个切换是肯定会发生的,也就因为这个切换导致了一些问题所在。

导致的啥问题呢 叫做原子性问题,原子顾名思义就是不能再拆分了,而原子操作指的也是不能再被拆分的操作,但是高级语言中的一条语句往往需要多条CPU指令来完成,这就对原子操作的定义不一样了,在高级语言看来它应该是一个原子操作的语句,在CPU看来却不是的,比如count += 1这条语句,就需要3条CPU指令:

  • 指令1:先把变量count从内存中加载到CPU寄存器中;
  • 指令2:在寄存器中执行+1操作;
  • 指令3:将结果写入内存(或者CPU缓存);

而操作系统的线程切换可以发生在任何一条CPU指令完成后,主要是CPU指令,而不是高级语言中的一条语句,所以会出现下图所示:

image.png

假如线程A和线程B都同时执行一次count += 1操作,当线程A把count(0)加载到寄存器后,发生了线程切换,线程B也把count(0)加载到寄存器,再进行加1操作,然后写入内存(本例不考虑缓存问题),这时再切换到线程A,A对count进行加1操作,然后写入内存(count = 1),这就不是我们预期的2。

因为在我们潜意识中认为count+=1是一个原子操作,然而在计算机的CPU指令却不是的,我们把一个或者多个操作在CPU执行的过程中不被中断的特性叫做原子性,所以我们就需要想办法来让count += 1 也变成是一个原子操作(后面文章继续)。

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

前面说到在不影响结果的情况下,编译器会优化代码中指令的顺序,但是在多线程中就有问题了,这里我们来看个Java中著名的双重校验实现单例方法:

public class Singleton {
  static Singleton instance;  //(1)
  static Singleton getInstance(){
    if (instance == null) {  //(2)
      synchronized(Singleton.class) {  //(3)
        if (instance == null)  (4)
          instance = new Singleton();  //(5)
        }
    }
    return instance;
  }
}

假设线程A和线程B同时调用getInstance()方法,会同时发现(2)成立,然后去进行加锁,但是JVM只允许一个线程能够加锁成功,假设是A线程,则B线程处于等待状态;当A创建完instance对象,线程B被唤醒,B再去加锁,加锁成功后,发现(4)不满足,说明已经有实例了,所以不会再创建了;而后面有线程C再调用getInstance()方法,会发现(2)不满足,直接返回instance,也不用再加锁了。

上面看起来代码很完美,尤其2个判空,但是还是有问题,问题就是new操作,这里new在Java语言中我们认为是原子操作,但是CPU指令却不是,我们期望的CPU指令操作应该是:

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

但是优化后的顺序可能是:

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

这个步骤在单线程看来对结果没有影响,但是在多线程中会有影响,假设A线程执行到了(5),而刚好CPU执行完上面第二步后发生了线程切换,这时线程B刚执行到(2),这时就会发现条件满足,会返回一个未初始化的instance实例。如下图所示:

image.png

所以看着完美的代码,在多线程编程中都可能会有问题。

总结

本篇文章至关重要,因为后续的知识从某种程度上说就是解决这些问题。我们来做个总结:

  1. 由于CPU和内存的速度差异过大,为了减少CPU从内存中读取值和写入值的操作,CPU增加了CPU缓存这个硬件设备。
  2. 为了提高效率、让CPU一直有工作可以做,或者在单核CPU可以执行多个任务,现代操作系统以线程为单位,通过线程切换来分时复用CPU。
  3. 各种编译器会在单线程情况下不影响结果时,对代码编译成的指令进行编译和优化。

而上面这些提高效率的工作,就是导致并发编程Bug的源头:

  1. CPU缓存导致了可见性问题;可见性:一个线程对变量的修改,最另一个线程是可见的。这个原因很好理解,就是CPU缓存中的数据不一定会立马更新到内存中。
  2. 线程切换导致了原子性问题;原子操作:不可以再分割的操作。这是由于高级语言中认为的是一个原子操作,在CPU看来可能是多个操作,而线程切换发生在CPU的原子操作。
  3. 编译器优化导致的有序性问题;虽然编译器在单线程情况下进行的优化没有问题,但是在多线程情况下,再根据原子性和可见性问题就有可能出现新的问题,比如上面说的双重校验锁的单例。