并发编程三大bug产生背景

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

程序里大部分语句都要访问内存,有些还需要访问I/O,根据木桶原理,程序整体的性能取决于最慢的操作----- 读写I/O设备,也就是说不能单方面的提供某一性能。

为了合理利用CPU的高性能,平衡三者之间的速度差异,计算机体系机构、操作系统、编译程序都做了相对应的优化和改善:

1、CPU增加缓存,以均衡与内存的速度差异

2、操作系统增加了进程、线程,以分时复用CPU,进而均衡CPU和I/O设备的速度差异

3,编译程序优化指令执行秩序,使得缓存能够得到更加合理的利用

但是,优化的同时,还带来了一些新的问题:

缓存导致共享资源的可见性问题

在单核时代,所有的线程都在在一颗CPU上执行,CPU缓存与内存的数据一致性容易解决。因为所有线程都是操作一个CPU缓存,一个线程对缓存的读写,对另一个线程来说一定是可见的。

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

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

由于IO太慢,早期的操作系统就发明了多进程,即使在单核的CPU上,我们也可以一边听歌,一边写bug,这就是多进程的功劳。

操作系统允许某个进程执行一小段时间,例如50毫秒,过了50毫秒操作系统就会重新选择一个进程来执行,这个50毫秒就称之为 时间分片

在一个时间分片内,如果一个进程运行一个IO操作,例如读个文件,这个时候该进程可以把自己标记为“休眠状态”并出让CPU的使用权,待文件读进内存,操作系统会把这个休眠吧的进程唤醒,唤醒后的进程就有机会重新获取CPU的使用权。

这里的进程在等待IO时之所以释放CPU使用权,是为了让CPU在这段等待时间里可以做别的事情,这样一来,CPU的使用率就上来了。

早期的操作系统基于进程来调度CPU,不同进程间是不共享内存空间的,所以进程要做任务切换就要切换内存隐射地址,而一个进程创建的所有线程,都是共享一个内存空间的,所以线程做任务切换的成本就很低了。

现在的操作系统都基于更轻量的线程来调度,现在我们提到的“任务切换”都是指“线程切换”。

java并发程序都是基于多线程的,自然也会涉及到任务切换。任务切换的时间大多数都是在时间片结束的时候,我们现在基本都使用高级语言编程,高级语言里一条语句往往需要多条CPU完成。我们把一个或者多个操作在CPU执行的过程中不被中断的特性称之为原子性

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

有序性是指程序按照代码的先后顺序执行。编译器为了优化性能,有时候会改变程序中语句的先后顺序。

在 Java 领域一个经典的案例就是利用双重检查创建单例对象,例如下面的代码:在获取实例 getInstance() 的方法中,我们首先判断 instance 是否为空,如果为空,则锁定 Singleton.class 并再次检查 instance 是否为空,如果还为空则创建 Singleton 的一个实例。

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 加锁,此时 JVM 保证只有一个线程能够加锁成功(假设是线程 A),另外一个线程则会处于等待状态(假设是线程 B);线程 A 会创建一个 Singleton 实例,之后释放锁,锁释放后,线程 B 被唤醒,线程 B 再次尝试加锁,此时是可以加锁成功的,加锁成功后,线程 B 检查 instance == null 时会发现,已经创建过 Singleton 实例了,所以线程 B 不会再创建一个 Singleton 实例。 这看上去一切都很完美,无懈可击,但实际上这个 getInstance() 方法并不完美。问题出在哪里呢?出在 new 操作上,我们以为的 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 的成员变量就可能触发空指针异常。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章