可见性、原子性、有序性,往往这些多线程的三要素都会出现在高级编程知识中。并且涉及到了很多操作系统的知识,如果多操作系统不是很熟悉的话,就会出现很多的问题。。。
多线程编程经常会出现一些玄学问题,所以编写正确的并发程序是一件极困难的事情,今天就重点来聊聊这些Bug的源头。。。
这些年,我们的CPU、内存、I/O设备都在不断迭代,不断朝着更快的方向努力。但是还是有一个核心矛盾一直存在,就是这三者速度的差异。。 比如说,CPU执行一条普通指令要一天,CPU读写内存需要等待一年,内存和IO设备的速度差异更大。
程序中大部分语句都要访问内存,有些还需要访问IO,根据木桶理论,程序整体的性能取决于最慢的操作——读写IO设备,就是说单方面提高CPU性能是无效的。。。
为了合理利用CPU的高性能,平衡三者的速度差异,计算机体系结构、操作系统、编译程序都做出了贡献,主要体现为:
在单核时代,所有的线程都是在一个CPU上执行,CPU缓存和内存的的数据一致性容易解决。因为所有线程都是操作在同一个CPU的缓存,一个线程对缓存的写,对另一个线程来说一定是可见的。比如:线程A更新了V的值,那么线程B访问V,得到的一定是V的最新值,这就称为可见性。。。
多核时代,上面提到的可见性就不存在了,因为每个CPU都有自己的缓存,在不同的CPU上执行不同的线程,必然无法读到对方修改的数据。。
public class Test {
private long count = 0;
private void add10K(){
int idx = 0;
while(idx++ < 10000){
count += 1;
}
}
public static long calc() {
final Test test = new Test();
// 创建两个线程,执⾏add()操作
Thread th1 = new Thread(()->{
test.add10K();
});
Thread th2 = new Thread(()->{
test.add10K();
});
// 启动两个线程
th1.start();
th2.start();
// 等待两个线程执⾏结束
th1.join();
th2.join();
return count;
}}
程序执行之后,count表面上应该是20000,实际上为10000,因为各自的CPU都有各自的缓存,更新数据都是基于自己CPU的缓存去更新,而不是基于主存。
由于IO太慢,早期的操作系统就发明了多线程,使单核的CPU可以同时做很多事情。。。
操作系统允许某个进程执行一小段时间之后再去任务切换,执行另一个进程,执行一个进程的时间长度叫做时间片。
早期的操作系统基于线程来调度 CPU,不同进程间是不共享内存空间的,所以线程要做任务切换就要切换内存映射地址,一个线程创建的所有线程都是共享一个内存空间的,所以线程做任务切换成本就很低了。。现在的操作系统都基于更轻量的线程来调度,现在提到的“任务切换”都是指“线程切换”。。
Java并发程序都是基于多线程的,自然会涉及到任务切换,任务切换是并发编程里诡异Bug的源头之一。任务切换,往往就是一个很复杂的事情,容易因此产生原子性相关的Bug。
例如上面代码中的count+=1,至少需要三条CPU指令。
操作系统做任务切换,可以发生在任何一条CPU指令执行完,比如在指令1结束之后,做线程切换,虽然,两个线程都执行了count++,但是结果却是1,而不是2。
原子性就是指令在执行过程中,不会被中断的特性,就叫做原子性。原子性操作水平是CPU指令级别的,而不是高级语言的层面。。
程序的编译中,JVM会帮我们进行优化,比如调整代码的执行顺序,这会导致一致性问题,例如程序中:“a=6;b=7;”编译器优化后可能变成“b=7;a=6;”,这个行为是不会报错的所以不影响程序的输入问题。。。
在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被notify,这个时候B加锁,但是通过检查instance==null的时候已经不成立了,因为之前A已经实例化了,这就是单例模式。
但是实际上这个getIntance并不是使用的很好,众所周知,实例化的过程是三步:
但是实际上优化后的执行路径是这样的:
这样优化就会产生有序性的问题了:假设A执行完了getInstance(),那么,指令2执行后进行线程切换,切换到B上,B也执行getInstance(),这时因为instance已经被实例化了,所以不为null值,就能直接返回instance了,而此时的instance没有进行过初始化,所以访问会报空指针异常。
线程A进入第二个判空条件,进行初始化时,发生了时间片切换,即使没有释放锁,线程B刚要进入第一个判空条件时,发现条件不成立,直接返回instance引用,不用去获取锁。如果对instance进行volatile语义声明,就可以禁止指令重排序,避免该情况发生。CPU缓存不存在于内存中的,它是一块比内存更小、读写速度更快的芯片,至于什么时候把数据从缓存写到内存,没有固定的时间,同样地,对于有volatile语义声明的变量,线程A执行完后会强制将值刷新到内存中,线程B进行相关操作时会强制重新把内存中的内容写入到自己的缓存,这就涉及到了volatile的写入屏障问题,当然也就是所谓happen-before问题。