随着设备的不断迭代,相应的速度也变得更快。但是在发展的过程中有一个矛盾一直存在,即三者的速度差异。
快慢关系:CPU > 内存 > I/O,程序整体的性能取决于最慢的操作,即读写I/O设备,所以单方面的提升CPU性能是无效的。
为了合理利用CPU的高性能,平衡三者的速度差异,计算机体系结构、操作系统、编译程序做出了贡献,体现为:
现在所有程序都是在享受着这些成果,但是并发程序很多诡异的问题的根源也在这里。
所有线程都在一个CPU上执行,CPU缓存与内存的数据一致性容易解决。因为所有的线程操作的是同一个CPU的缓存,一个线程对缓存的写,另一个线程来说一定是可见的。
如下图:线程A和线程B都是操作相同的CPU缓存,所以线程A操作共享变量V的值,线程B访问V的值,一定是V的最新值(线程A写过的值)
多核时代,每个CPU都有自己的缓存,那么保证CPU缓存与内存的数据一致性问题就不怎么容易了,当多个线程在不同的CPU上执行时,这些线程操作的是不同的CPU缓存。
如下图:有两个CPU,对应有两个CPU缓存,线程A和线程B操作在不同的CPU上执行,这时线程A操作的V的值就会对线程B不可见,同理线程B操作的V的值对线程A也不可见。
示例代码:
有一个共享变量count,两个线程t1和t2,线程t1和t2执行方法add10K(),每次add10K会循环10000次count ++操作。
public class Test1 {
private static long count = 0;
public static void main(String[] args) throws InterruptedException {
final Test1 test1 = new Test1();
Thread t1 = new Thread(new Runnable() {
public void run() {
test1.add10K();
}
});
Thread t2 = new Thread(new Runnable() {
public void run() {
test1.add10K();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count);
}
private void add10K() {
int index = 0;
while (index ++ < 10000) {
count ++;
}
}
}
分析:
假设线程t1和t2同时执行,在两个线程分别执行第一次add10K的时候,CPU从内存中读到count的值0并缓存到自己的缓存中;
两个线程分别执行add10K方法中的count ++后,线程t1和t2都将count的值从0增加到1,同时刷新各自的缓存中的值,此时我们看到的count值是1,而非我们期望的2;
由于每个线程都是基于自己的缓存值进行计算的,所以最后导致count的值都是小于20000的,这就是缓存的可见性问题;
之所以在10000到20000之间,并接近20000,是由于两个线程不是同时启动的,有一个时差。
由于读写I/O速度太慢,早期的操作系统便有了多进程,即可以在单核CPU上一边听歌,一边敲BUG。
任务切换:操作系统允许某个进程执行一小段时间后重新选择一个进程来执行,称之为任务切换。这一小段时间称之为时间片
在一个时间片内如果一个进程进行一个I/O操作,当执行读写操作时,CPU会将自己标记为休眠状态
并让出CPU的使用权,让CPU去执行其他任务,待这个读写操作完成时,操作系统会把这个进程唤醒
,唤醒后的进程就会有竞争CPU使用权的权利了。
这里的进程让出CPU使用权,是为了在这个耗时的操作时间段内做别的事情,这样就可以提高CPU的使用率了。
如果此时有其他进程也要读写相同的文件,那么这个进程会排队,等待上一个进程执行完读写操作后,发现有排队的任务,就会立即启动下一次的读写操作,这样I/O的效率也上来了。
早期的操作系统是基于进程来调度CPU,不同进程间是不共享内存空间的。所以进程做任务切换就要切换映射地址,而一个进程创建的所有线程都是共享一个内存空间的,所以线程做任务切换的成本就很低了。现代的操作系统都基于更轻量的线程来调度,现在我们提到的"任务切换"都是指"线程切换"。
Java并发程序都是基于多线程的,也就涉及到了"任务切换",但是"任务切换"也是并发编程里诡异BUG的源头之一。
任务切换的时机大多数是在时间片
结束的时候,我们现在基本都是基于高级语言编程,高级语言里的一条语句往往需要多个CPU指令来完成,例如count += 1
,至少需要三条CPU指令。
操作系统做任务切换,可以发生在任何一条CPU指令
执行后,而非高级语言里的一条语句。
如下图,假设count原始值为0,线程A和线程B都执行count += 1操作,当线程在指令1执行结束后做线程切换,执行线程B,当线程B执行完后切换到线程A并执行完成,但是得到的结果并不是期望中的2,而是1。
由上说明CPU能保证原子性是在CPU指令级别的,而不是高级语言的操作符。
编译器为了优化程序性能,有时候会改变程序中语句的先后顺序,但是不影响程序的最终结果。不过有时候编译器及解释器的优化可能导致意想不到的BUG。
Java单例模式中有个双重检查创建单例对象:先检查singleton是否为空,如果空则锁定Singleton.class并再次检查是否为空,如果还为空,则创建Singleton的一个实例。
public class Singleton {
private static Singleton instance;
public 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释放锁),线程A检查instance为空,则创建Singleton的一个实例,之后释放锁,线程B获得锁,并加锁,检查instance不为空,所以不会再次创建实例了。
看上去上面代码很完美,但实际上这个getInstance方法并不完美,问题出在new操作
上。我们认为new操作应该是:
但实际上优化后的执行路径却是这样的:
优化后导致什么问题呢?假设线程A执行完指令21⃣️后切换到线程B,此时instance已经被指向了一块内存地址,线程B检查instance不为空,直接返回instance,而此时的instance是没有初始化过的,如果我们这个时候访问instance的成员变量就可能触发空指针异常
。
并发编程BUG的源头有:缓存导致的可见性问题、线程切换导致的原子性问题、编译优化导致的有序性问题
对于文中双重锁问题,异常原因是由于发生了编译优化导致指令重排序,为了避免这类情况的发生,就可以用volatile声明变量,禁止指令重排序。
什么时候将CPU缓存刷新到内存中,通常是没有固定时间的,对于有volatile声明的变量,线程A执行完成后会强制将缓存刷新到内存中的,线程B会强制重新从内存中读取并放入到自己的缓存中,这就涉及到了写入屏障问题,即所谓的happen-before问题。
long类型的变量在32位机器上执行,由于long类型是64位的,所以在32位上会将指令拆分位高32位和低32位,计算时分两个指令执行,由于线程切换,无法保证原子性,所以会导致数据计算不及预期的问题。