在平时的开发中,单例懒汉模式经常会用到。如果单例没设计好,在高并发的场景下会出现一些问题,当然如果是单线程执行是不会有问题出现的。
举个栗子:
/**
* @author: htc
* @date: 2020/8/28 11:26
* @descr:
*/
public class Cpu {
private static Cpu instance;
private Cpu(){}
public static Cpu getInstance(){
//第一次检测
if (instance==null){ // row 1
//同步
synchronized (Cpu.class){ // row 2
if (instance == null){ // row 3
//多线程环境下可能会出现问题的地方
instance = new Cpu(); // row 4
}
}
}
return instance;
}
public void getCount() {
//这里是相关业务代码,省略。。。
}
}
首先我们说下为什么要用双if多次判空:
首先假如我们先去掉row3行的判空逻辑。现在有两个线程A、B。当A线程拿到锁并成功new出一个instance准备释放但还没释放锁(或者是时间片刚好轮转到其他线程),在此期间B线程已执行完row1的判空逻辑,正在等待锁的释放。这时instance实际上是已经创建好了。这时当A线程再次获得时间片释放掉锁,紧接着锁被B线程后会再去对instance进行实例化一次,就会导致不同线程调用getInstance方法时,得到的不是同一个对象。
如果加上row3行判空逻辑,当B线程拿到锁后对instance再次进行判空,这时就会跳过row4行的代码执行,避免上面的问题。
然而,单单经过如上改造是完全不够的,在单线程环境下依然没问题,在高并发场景下还是会出现问题,
问题的关键点还是出在row4行代码。我们知道在javac命令将java文件编译成class文件,再由javap转成字节码文件的时候,可能出现指令重排。比如row4行代码的字节码(伪代码): (这里补充下:synchronized 只会保证原子性,并不会保证有序性,所以还是有可能发生指令重排)
//1、分配对象内存空间
memory = allocate();
//2、初始化对象
instance(memory);
//3、设置instance指向刚分配的内存地址,此时instance应该不为null
instance = memory;
/*
* 由于步骤2 和 步骤3不存在数据依赖关系可能会重排序,而且无论重排前还是重排后程序的执行结果在单线程中并没有改变,这种重排优化是允许的。
* 但指令重排只会保证串行语义执行的一致性(即单线程中),并不会关系多线程的语义一致性
*
* 所以重排后的伪代码可能如下
*/
//1、分配对象内存空间
memory = allocate();
//3、设置instance指向刚分配的内存地址,此时instance应该不为null
instance = memory;
//2、初始化对象
instance(memory);
当指令重排后的结果如上时,线程A执行完instance=memory后刚好发生时间片轮转,此时B线程进行第一次判空(即执行row1),这是instance是不为Null的,然后B线程就直接向instance返回。实际上这会instance并没有完成实例化,这会对于B线程的调用者拿到的instance去执行其他逻辑代码时(如这里的getCount()方法)就会出问题。
如何避免呢?,其实很简单,归根究底是因为row4行代码在编译过程中发生了指令重排,这里只要禁止编译时这行代码指令重排即可。解决方法为在定义instance时添加volatile关键字即可,
private volatile static Cpu instance;