【并发编程系列】是整理自极客时间上王宝令老师的专栏:《Java并发编程实战》。
如有侵权,请告知。
0.定义
可见性: 一个线程对共享变量的修改,另一个线程能够立刻看到。
原子性: 一个或多个操作在CPU执行过程中不被中断,称为原子性。
有序性: 程序按照代码的先后顺序执行。
- 导致可见性问题的原因是CPU缓存;
- 导致有序性问题的原因是编译优化。
-
线程切换可能带来原子性问题
解决问题的直接方法就是禁用缓存和优化。Java内存模型JVM如何按需禁用缓存和编译优化的方法。
具体来说这些方法包括 volatile、synchronized 和final三个关键字,以及六项Happens-Before
1.缓存导致的可见性问题
如果是单核CPU,所有的线程都在一个CPU上运行,那么CPU缓存和内存的一致性很容易解决。多个线程操作的都是CPU中的同一个值
![](https://img.laitimes.com/img/__Qf2AjLwojIjJCLyojI0JCLiAzNfRHLGZkRGZkRfJ3bs92YsYTMfVmepNHL9UFVNFTTU5EMRRVT3V1MMBjVtJWd0ckW65UbM5WOHJWa5kHT20ESjBjUIF2X0hXZ0xCMx81dvRWYoNHLrdEZwZ1Rh5WNXp1bwNjW1ZUba9VZwlHdssmch1mclRXY39CXldWYtlWPzNXZj9mcw1ycz9WL49zZuBnLwYjM2QDNwkTMzEzMwkTMwIzLc52YucWbp5GZzNmLn9Gbi1yZtl2Lc9CX6MHc0RHaiojIsJye.png)
现在是多核CPU时代,每个CPU都有自己的缓存。当多个线程在不同CPU上执行时,这些线程操作的是不同的CPU缓存。这时候A线程对共享变量的操作,对B线程就不具备可见性了。
2.线程切换带来的原子性问题
操作系统允许某个进程执行一小段时间。例如当一个进程在执行IO操作时,进程把自己标记为“休眠中”以让出CPU,等文件读入内存之后,操作系统会将整个进程唤醒,唤醒后的进程有机会重新获取CPU的使用权了。让出CPU是利用IO操作的时间,让CPU可以干其他事,提高CPU使用率。
Java语言中并发实现都是基于线程的,涉及到线程切换。高级语言中的一条语句,往往需要多条CPU指令来完成。比如i++操作有3条CPU指令:
1.将变量i的值从内存加载到CPU的寄存器
2.在寄存器中完成+1操作
3.将结果写入内存(或者CPU缓存)
操作系统的任务切换,可能在任意一条CPU指令完成时执行。这个的结果就是两个线程各自在自己的CPU完成了基于原始值的+1操作,而不是总体+2。
3.编译优化带来的有序性问题
编译器为了优化性能,有时候会改变程序中语句的先后顺序。例如程序中:“a=6;b=7;”编译器优化后可能变成“b=7;a=6";
最典型的是双重检查锁定的单例模式。
private static Singleton instace;
public static Singleton getInstance(){
//第一次null检查
if(instance == null){
synchronized(Singleton.class) { //1
//第二次null检查
if(instance == null){ //2
instance = new Singleton();//3
}
}
}
return instance;
}
上面这段代码看似无懈可击,但是现实中有可能报错。
singleton = new Singleton();这段代码其实是分为三步:
分配内存空间。(1)
初始化对象。(2)
将 singleton 对象指向分配的内存地址。(3)
但是编译器优化指令重排之后,执行顺序变成了:
1.分配内存空间。(1)
2.将 singleton 对象指向分配的内存地址。(3)
3.初始化对象。(2)
第3步在第2步之前被执行,下一个线程拿到的单例对象是还没有初始化的,以致于报空指针异常。
对singleton 使用volatile修饰,就可以解决上述的问题。
使用volatile修饰的变量,它实现的作用是,对于这个变量的读写,不能使用CPU缓存必须从内存中写入或读出。