计算机的 CPU、内存、I/O 设备的速度一直存在较大的差异,依次是 CPU > 内存 > I/O 设备,为了权衡这三者的速度差异,主要提出了三种解决办法:
- CPU 增加了缓存,均衡和内存的速度差异
- 发明了进程、线程,分时复用 CPU,提高 CPU 的使用效率
- 编译指令优化,更好的利用缓存
三种解决办法虽然有效,但是也带来了另外的三个问题,分别就是并发 bug 产生的源头。
1.可见性问题
如果是单核 CPU,多个线程操作的都是同一个 CPU 缓存,那么一个线程修改了共享变量,另一个线程肯定能马上看到。
如果是多核 CPU ,每个 CPU 都有自己的缓存,这样线程对共享变量的修改便对其他线程不可见了。
2.原子性问题
为什么会有线程切换?一个线程在执行的过程中,可能会进行耗时的 I/O 操作,这时线程需要等待 I/O 操作完成。线程在等待的过程中,可以释放 CPU 的使用权,让另一个线程执行,这样能够提高 CPU 的使用率。
例如上图,两个线程同时对变量 count 加 1,线程 A 在执行的过程中切换到了线程 B,最后导致写入到内存的值都是 1,与预期不符。
3.有序性问题
首先看一段很经典的获取单例对象的代码:
public class Singleton { private static Singleton instance; //Java 获取单例对象 public Singleton getInstance(){ if (instance == null){ synchronized (Singleton.class){ if (instance == null){ instance = new Singleton(); } } } return instance; } }
程序的逻辑是:首先判断 instance 是否为空,如果为空,对其加锁,然后再判断是否为空,此时为空的话则初始化 instance 对象。
如果线程 A 和 B 同时执行方法,在 synchronized 处,一个线程会被阻塞,假设被阻塞的是线程 B,此时线程 A 进入并初始化 instance,然后唤醒线程 B,线程 B 进入的时候,发现 instance 不为空了,所以不会创建对象。
但是因为有序性问题的存在,这段代码也不是想象的那么完美,我们期望的初始化对象的过程是这样的:1.分配内存;2.初始化对象;3.将内存地址赋给 instance。但是经过编译优化之后,却是这样的:
- 1.分配内存
- 2.将内存地址赋给 instance
- 3.在内存上面初始化对象
这样,如果线程 A 执行到了第二步,然后切换到 线程 B,线程 B 就会认为 instance 不为空然后直接返回了,实际上 instance 并没有初始化。
最后,总结一下,导致并发问题的三个源头分别是
- 原子性:一个线程在执行的过程当中不被中断。
- 可见性:一个线程修改了共享变量,另一个线程能够马上看到,就叫做可见性。
- 有序性:编译指令重排导致的问题。