并发编程中,常见的问题主要有缓存导致的可见性问题,线程切换带来的原子性问题,编译优化带来的有序性问题
1、可见性问题
1.1 单核CPU时的线程可见性问题
在单核CPU时,所有线程都是在同一个CPU上执行,操作的都是同一个CPU缓存。因此,一个线程对CPU缓存的写对其他线程都是可见的。如下图中所示,线程 A 和线程 B 操作同一个 CPU 里面的缓存变量V,彼此的操作都是相互可见的。
1.2 多CPU时缓存与内存关系
在多CPU的情况下,每颗 CPU 都有自己的缓存,当多个线程在不同的 CPU 上执行时,这些线程操作的是不同的 CPU 缓存。如上图中所示,线程 A 操作的是 CPU-1 上的缓存,而线程 B 操作的是 CPU-N 上的缓存。两个缓存在默认情况下是不可见的。
1.3 可见性问题Demo
public class VisibilityTest {
private long count = 0;
private void add10K() {
int idx = 0;
while (idx++ < 10000) {
count += 1;
}
}
public static void main(String[] args) throws InterruptedException {
final VisibilityTest test = new VisibilityTest();
// 创建两个线程,执行add()操作
Thread threadA = new Thread(() -> {
test.add10K();
});
Thread threadB = new Thread(() -> {
test.add10K();
});
// 启动两个线程
threadA.start();
threadB.start();
// 等待两个线程执行结束
threadA.join();
threadB.join();
System.out.println("count="+test.count);
}
}
运行结果
上述代码正常情况下,count的值为10000到20000之间的任何值。 假设上述代码中的线程A和线程B同时执行,第一次线程读取到的count值都为0。之后执行count+=1,每个线程的CPU缓存中的count值都为1。之后将count值写入内存。执行完之后 ,内存中的count值为1,而不是预期的2。因为两个线程都是基于各自的CPU缓存中的count值来进行计算的,所以最终的count结果都小于20000。2、原子性问题
2.1 count+=1存在的问题
count+=1不是一个原子性的操作,它可以分为三个步骤:
- 将count变量从内存加载到CPU的寄存器。
- 在寄存器中将变量count的值加1。
- 将寄存器的count值写入内存。
在单线程的情况下,这个操作是没有任务问题的。但在多线程的情况下,因为改操作是非原子性的,就会出现问题,如下图中所示。
3、有序性问题
有序性指的是程序按照代码的先后顺序执行。编译器为了优化性能,有时候会改变程序中语句的先后顺序,例如程序中:“a=1;b=2;”编译器优化后可能变成“b=2;a=1;”。 在Java单例模式中经常的双重锁校验就是一个经典例子。
public class SingletonClass {
private volatile static SingletonClass instance = null;
public static SingletonClass getInstance() {
if (instance == null) {
synchronized (SingletonClass.class) {
if(instance == null) {
instance = new SingletonClass();
}
}
}
return instance;
}
private SingletonClass() {
}
}
在上述代码中,调用getInstance()方法时,会判读instance是否为空。如果为空,会通过synchronized锁定SingletonClass.class。之后再判断instance是否为空,如果还是为空,则创建对象。 但这个方式不是完美的,问题出现在new SingletonClass()。 在Java创建对象时,会分为三个步骤,
- 申请内存
- 在内存中初始化对象
- 将对象的内存地址赋值给变量
但是实际上上述步骤不一定是绝对的,步骤二跟步骤三可能会互换,变成
- 申请内存
- 将对象的内存地址赋值给变量
- 在内存中初始化对象
这就是出现问题的原因。
我们假设线程 A 先执行 getInstance() 方法,当执行完指令 2 时恰好发生了线程切换,切换到了线程 B 上;如果此时线程 B 也执行 getInstance() 方法,那么线程 B 在执行第一个判断时会发现 instance != null ,所以直接返回 instance,而此时的 instance 是没有初始化过的,如果我们这个时候访问 instance 的成员变量就可能触发空指针异常。