一、CPU、内存、IO设备的速度差异:cpu > 内存 > IO设备
为了合理利用CPU的高性能,平衡三者的速度差异,计算机体系结构、操作系统、编译程序做了以下优化:
1、CPU增加了缓存,以均衡与内存的速度差异;
2、操作系统增加了进程、线程,以分时复用CPU,进而均衡CPU与I/O设备的速度差异;
3、编译程序优化指令执行顺序,使得缓存能够更加合理的利用。
二、并发程序的问题根源
1、缓存导致的可见性问题
单核时代:所有的线程都是在一颗CPU上执行,一个线程对缓存的写,另一个线程一定是可见的。
多核时代:每颗CPU都有自己的缓存,线程A操作CPU-1的缓存,线程B操作CPU-2的缓存,线程A对变量的操作对线程B就不具备可见性了。
2、线程切换带来的原子性问题
高级语言里一条语句往往需要多条 CPU 指令完成,例如:count += 1,至少需要三条 CPU 指令。
指令1:首先把变量count从内存加载到CPU的寄存器;
指令2:在寄存器执行+1的操作;
指令3:最后将结果写入内存(缓存机制导致写入的可能是CPU缓存而不是内存)。
操作系统做任务切换,可以发生在任何一条 CPU 指令执行完,而不是高级语言里的一条语句。对于上面的三条指令来说,我们假设 count=0,如果线程 A 在指令 1 执行完后做线程切换,线程 A 和线程 B 按照下图的序列执行,那么我们会发现两个线程都执行了 count+=1 的操作,但是得到的结果不是我们期望的 2,而是 1。
3、编译优化带来的有序性问题
双重检查创建单例对象
public class Singleton {
static Singleton instance;
static Singleton getInstance(){
if (instance == null) {
synchronized(Singleton.class) {
if (instance == null)
instance = new Singleton();
}
}
return instance;
}
}
实际上这个 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()方法,当执行到第一个判断
if (instance == null)时,会发现instance != null,所以会直接返回instance,而此时的instance是没有初始化过的,若访问instance的成员变量会触发空指针异常。
为什么32位的机器上对long型变量进行加减操作存在并发隐患?
32位CPU上执行long型变量的写操,long型变量是64位,在32位CPU上执行写操作会被拆分成两次写操作(写高32位和写低32位,如下图所示)。
原因就是:线程切换带来的原子性问题。
如何解决
在单核CPU场景下,同一时刻只有一个线程执行,禁止CPU中断,意味着操作系统不会重新调度线程,也就是禁止了线程切换,获得CPU使用权的线程就可以不间断地执行,所以两次写操作一定是:要么都被执行,要么都没有被执行,具有原子性。
在多核场景下,同一时刻,有可能有两个线程同时在执行,一个线程执行在CPU-1上,一个线程执行在CPU-2上,此时禁止CPU中断,只能保证CPU上的线程连续执行,并不能保证同一时刻只有一个线程执行,如果这两个线程同时写long型变量高32位的话,那就有可能出现我们开头提及的诡异Bug了。
“同一时刻只有一个线程执行”这个条件非常重要,我们称之为互斥。如果我们能够保证对共享变量的修改是互斥的,那么,无论是单核CPU还是多核CPU,就都能保证原子性了。