有序性
我们知道,程序在执行前,需经过编译器或解释器,翻译成机器语言,一条程序代码,会被翻译为多条机器指令。编译器或解释器为了优化程序的执行性能,有时会改变这些指令的执行顺序。而编译器或解释器对指令顺序的调整,可能会导致意想不到的问题。
在Java开发中,一个常见的程序,使用单例模式创建对象,而单例对象的创建,会通过双重检验的机制进行。
public class SingleInstance {
private static SingleInstance instance;
public static SingleInstance getInstance(){
if(instance==null){
synchronized (SingleInstance.class){
if(instance==null){
instance = new SingleInstance();
}
}
}
return instance;
}
}
上述代码通过getInstance()方法创建单例对象时,先判断instance对象是否为空,如果为空,则针对SingleInstance类对象加锁,接着再次判断instance对象是否为空,为空时,创建一个实例对象,并赋值给静态成员变量instance。
如果解释器或编译器不针对以上代码进行指令优化,则整个代码的执行过程如下:
假设有线程A和线程B同时调用getinstance()获取instance对象,两个线程同时第一次判断instance是否为空,此时instance未创建,故instance为空。
这时线程A先获取了SingleInstance类对象锁,线程B未获取锁而处于等待锁状态。
接下来,线程A第二次判断instance对象为空,从而创建了instance对象,并赋值给instance成员变量,线程A释放锁。
这时,处理等待锁状态的线程B被唤醒,获取了锁,线程B第二次判断instance对象不为空,直接返回非空的instance对象。
![图片[1]-并发编程的根源–有序性问题-编程社](https://cos.bianchengshe.com/wp-content/uploads/2024/04/image-29.png?imageMogr2/format/webp/interlace/1/quality/100)
上面的分析看,一切都很完美,但前提是编译器或解释器没有对指令进行指令优化,也就是CPU没有对指令进行重排顺序。
CPU指令优化
在真正的高并发场景下,创建对象的new操作时,会被编译器或解释器进行指令优化,而导致程序运行异常。
也就是说,问题的根源在于以下语句:
instance = new SingleInstance();
以上语句会有3条CPU指令:
- 分配内存空间
- 初始化对象
- 将instance引用指向内存空间
正常的指令顺序为1–>2–>3
![图片[2]-并发编程的根源–有序性问题-编程社](https://cos.bianchengshe.com/wp-content/uploads/2024/04/image-30.png?imageMogr2/format/webp/interlace/1/quality/100)
经编译器或解释器优化的执行顺序可能为: 1–>3–>2,此时可能会出现问题。
![图片[3]-并发编程的根源–有序性问题-编程社](https://cos.bianchengshe.com/wp-content/uploads/2024/04/image-31.png?imageMogr2/format/webp/interlace/1/quality/100)
当CPU指令优化后的执行顺序为1–>3–>2时,我们把线程A和线程B调用getInstance()获取对象实例的两种步骤总结如下:
第一种步骤
- 线程A和线程B同时进入第一个if条件判断。
- 假设线程A先获取synchronized锁,进入synchronized代码块,此时因instance为空,所以执行instance=new SingleInstance()语句。
- 在执行instance=new SingleInstance()时,线程A会在JVM中开辟一块内存空间。
- 线程A将instance指向新开辟的内存空间,在还没有进行对象初始化时,发生了线程切换,线程A释放了synchronized锁,此时,CPU切换到了线程B上。
- 线程B进入synchronized代码块,在进行第二个if判断时,发现instance引用指向了线程A创建的空对象,不为空,但对象未进行初始化。此时线程B若使用未初始化的空对象时,可能会出问题。
第二种步骤
- 线程A先进行第一个if条件判断,instance为空
- 线程A获取synchronized锁,并进行第二次if判断,此时instance为空,则进入instance=new SingleInstance()语句。
- 线程A在内存中申请一块新的内存空间。
- 线程A将instance引用指向了新申请的内存空间,在没有进行对象初始化的时候,CPU进行了线程切换,CPU切换到了线程B上。
- 线程B进行一个if条件判断,发现instance对象不为空,但instance对象并对进行对象初始化,是一个空对象。此时线程B若使用未初始化的空对象时,可能会出问题。
以上过程,可以使用下图描述:
![图片[4]-并发编程的根源–有序性问题-编程社](https://cos.bianchengshe.com/wp-content/uploads/2024/04/image-32.png?imageMogr2/format/webp/interlace/1/quality/100)
有序性问题的解决方案
通过前面的讲述,我们可以清晰的知道,因为有了CPU的编译优化,也就带来了有序性问题。
那么针对有序性问题,我们采用什么方案解决呢?
使用volatile关键字
volatile关键字是Java提供的一种轻量级的同步机制,它可以保证多线程环境中变量的可见性和禁止指令重排。
当我们将一个变量声明为volatile时,JVM会禁止对这个变量的读写操作进行重排序,并且保证每个线程都能看到这个变量的最新值。
public class SingleInstance {
private static volatile SingleInstance instance;
public static SingleInstance getInstance(){
if(instance==null){
synchronized (SingleInstance.class){
if(instance==null){
instance = new SingleInstance();
}
}
}
return instance;
}
}
例子中,instance成员变量被声明为volatile。这确保了当一个线程在同步块中创建了一个新的Singleton实例并赋值给instance后,其他线程能够立即看到这个新的实例,而不是看到旧的、null的值。
这就解决了由于编译优化导致的有序性问题。
使用过度引用
我们知道编译优化,可能会引起对象创建过程中,对象初始化晚于对象引用指向新创建的对象。
那么,我们是否可以让成员变量不要在对象创建时直接赋值,而是采用中间临时变量过度后,再赋值的方式?
public class SingleInstance {
private static SingleInstance instance;
public static SingleInstance getInstance(){
if(instance==null){
synchronized (SingleInstance.class){
if(instance==null){
SingleInstance temp = new SingleInstance();
instance = temp;
}
}
}
return instance;
}
}
例子中,SingleInstance temp = new SingleInstance()存在指令优化的顺序改变,但在引用temp指向新创建的对象,但未执行对象初始化时,发生了线程切换,因synchronized代码块未执行完同步代码块的指令,原线程仍持有同步代码块的锁,且此时成员变量instance还为空,其它线程即使获得了CPU时间片,也会堵塞在同步代码块的地方。
因此,中间过度的临时变量temp,完美规避了编译优化带来的有序性问题。
总结
缓存带来的可见性问题、线程切换带来的原子性问题和编译优化带来的有序性问题,是导致并发编程频繁出现诡异问题的三个源头.
我们已经详细介绍了缓存带来的可见性问题、线程切换带来的原子性问题和编译优化带来的有序性问题。
通过本文我们可以知道,有序性问题是并发编程中需要重点关注的问题之一。
通过使用volatile关键字和过度引用的临时变量,是可以规避编译优化带来的有序性问题的。
![图片[5]-并发编程的根源–有序性问题-编程社](https://cos.bianchengshe.com/wp-content/uploads/2024/04/image-33.png?imageMogr2/format/webp/interlace/1/quality/100)
暂无评论内容