CPU、内存、I/O设备
核心矛盾就是这三者的速度差异,为了合理利用CPU的高性能,平衡这三者的速度差异:
- CPU增加了缓存,以均衡与内存的速度差异
- 操作系统增加了进程、线程,以分时复用CPU,进而均衡CPU与I/O设备的速度差异
- 编译程序优化指令执行次序,是的缓存能够得到更加合理地利用
并发程序Bug的根源就在这里。
一、缓存导致的可见性问题
单核不存在这个问题,因为所有线程都在同一个CPU上,一个线程对缓存的写,对另一个线程来说一定是可见的。 多核情况下,每个CPU都有自己的缓存,当多个线程在不同的CPU上执行时,这些线程操作的是不同的CPU缓存,这样不同CPU上的线程对缓存的操作就不具备可见性了。
二、线程切换带来的原子性问题
线程切换的时机大多数是在时间片结束的时候,使用的高级语言里,一条语句往往需要多条CPU指令完成,例如count += 1,至少需要三条CPU指令:
需要把变量count从内存加载到CPU寄存器
在寄存器中执行+1操作
将结果写入内存(缓存机制导致可能写入的是CPU缓存而不是内存)
假设count = 0,如果在结果写入内存之前做线程切换,另一个线程也执行count += 1操作,那么得到的结果不是期望的2,而是1。 案例:在32位的机器上对long型变量进行加减操作存在并发隐患 非volatile类型的long和double型变量是8字节64位的,32位机器读或写这个变量时得把人家 分成两个32位操作,可能一个线程读了某个值的高32位,低32位已经被另一个线程改了。所以官方推荐最好把long\double 变量声明为volatile或是同步加锁synchronize以避免并发问题。
三、编译优化带来的有序性问题
编译器为了优化性能,有时候会改变程序中语句的先后顺序。有时候编译器的优化及解释器的优化可能导致意想不到的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()方法,他们同时发现instance == null,于是同时对Singleton.class加锁,此时JVM保证只有一个线程能够加锁成功(假设是A),线程B会处于等待状态;线程A会创建Singleton实例,之后释放锁,锁释放之后,线程B获取锁,加锁成功之后,线程B检查instance == null时会发现,已经创建过Singleton实例了,所以线程B不会再创建一个Singleton实例。这一切看上去很完美,但实际上这个getInstance()方法并不完美,问题出在new操作上,我们认为的new操作应该是:
- 分配一块内存M
- 在内存M上初始化Singleton对象
- 然后M的地址赋值给instance变量
但是实际上优化后的执行路径却是这样的:
- 分配一块内存M
- 将M的地址赋值给instance变量
- 最后在内存M上初始化Singleton对象
优化的问题:
如果线程A先执行getInstance()方法,当执行完指令2的时候发生了线程切换,切换到线程B上;如果此时线程B也执行getInstance()方法,那么线程B会发现instance != null,所以直接返回instance,而此时instance是没有初始化过的,如果我们这个时候访问instance的成员变量就可能会触发空指针异常。