可见性、原子性、有序性问题
1.可见性
可见性指的是当一个线程修改了共享变量后,其他线程能够立即得知这个修改。在单核的时候,线程都是在同一 CPU 上执行,一个线程对 CPU 缓存的操作对其它线程都是可见的。
但是有多核时,情况就发生了变化,每个 CPU 有自己的缓存,我们可以假设这样的场景,有两个线程修改同一变量,分别在在不同的 CPU 上执行,两个 CPU 首先会把内存中的变量加载到各自的缓存中,线程 1 操作的是 CPU1 上的缓存,线程 2 操作的是 CPU2 上的缓存,它们对彼此并不可见,所以这就存在可见性问题。
单核:
多核:
2.原子性
原子性指的是一个或者多个操作在 CPU 执行的过程中不被中断的特性,所以线程切换带来了原子性问题。Java 并发程序都是基于多线程的,操作系统为了充分利用 CPU 的资源,将 CPU 分成若干个时间片,在多线程环境下,线程会被操作系统调度进行任务切换。
我们可以看下面这个常见面试题:
下面哪些操作是原子性操作
int count =0;//1
count++;//2
int b = count;//3
我们分析上面三个操作,只有操作 1 是原子操作,操作 2 在编译器作用下等同于 count = count+1 从 CPU 的角度它会有三条指令:
- 将变量 count 从内存加载到 CPU 缓存
- 将变量 count+1
- 将结果写入内存
操作 3 也不是原子的,我们可以通过 javap 将代码翻译成字节码文件查看,同时我们假设有两个线程,一个线程执行了 int b = count 操作,另一个线程操作 count 值。这时候线程 1 获取的 b 就有问题。
3.有序性
有序性指的是程序按照代码的先后顺序执行编译优化,带来的有序性问题,编译器为了性能优化,会将代码执行顺序做一定程度的优化,但是这种优化在并发程序下会带来一些诡异的 Bug,甚至让我们无从解决。例如本来程序是:
int a = 10;
int b = 12;
经过编译器优化可能变成了:
int b = 12;
int a = 10;
从这个例子看,似乎对程序结果看不出有什么影响。我们再看一个经典的面试题,也是工作中经常接触到的模式——单例模式。
如果在面试的时候让我们手写一个单例模式,我相信大多数同学都能信手拈来,例如用双重检验锁实现:
class A {
}
示例:1-1
public class Singleton {
private static Singleton INSTANCE;
private Singleton(){}
public static Singleton getSingleton() {
if (INSTANCE == null) {
synchronized (Singleton.class) {
if (INSTANCE == null) {
INSTANCE = new Singleton();
}
}
}
return INSTANCE;
}
}
写完这段代码,似乎好像完成了面试官要求的单例模式。一切看上去似乎很顺利,内心窃喜,这时候面试官可能会问你,你觉得这段代码有什么问题吗?问题就出现在 new Singleton();。
对于 CPU 来说创建这个对象,会有三个 CPU 指令:
- 分配内存空间
- 初始化对象
- INSTANCE 引用指向分配好的内存空间
但是重排序后执行顺序可能为 1-3-2,那么问题就来了,这时候引用指向了堆内存的一块地址,但是对象还没有初始化完成。
如果这时候有其它线程进来,发生了时间片切换,判断引用 INSTANCE 不等于 null,这时候返回了未初始化完成的对象,这时候使用该对象就会出问题。
所以问题就在指令重排序,我们可以使用 volatile 对指令禁止重排序,修改后的代码如下:
示例:1-2
public class Singleton {
private static volatile Singleton INSTANCE;
private Singleton(){}
public static Singleton getSingleton() {
if (INSTANCE == null) {
synchronized (Singleton.class) {
if (INSTANCE == null) {
INSTANCE = new Singleton();
}
}
}
return INSTANCE;
}
}