设计模式-单例模式-指令重排思考

2022-07-25,,,,

1、单例模式

之前写过一篇单例模式的博客,有不了解单例模式的可以看看。

2、指令重排

指令重排指的是在程序执行时,为了性能考虑,编译器和CPU可能会对指令进行重新排序,下面举个例子,比如有如下程序:

int a,b;
a = 2;
b = 2;

这个程序在执行的时候,可能执行顺序就会颠倒,变成先执行“b = 2”,再执行“a = 2”,这个就叫指令重排。
指令重排有几个基本原则,不清楚的可以看我引用的博客,这里要说的是顺序执行原则,指令重排保证在单线程内语义的串行性,举个例子:

int a,b;
a = 2;
b = a;

比如上面这个代码,如果顺序颠倒,先执行“b = a”,再执行“a = 2”,那么程序的意思就会发生改变,那么这种指令重排是不被允许的。

3、单例模式与指令重排

说完指令重排,那么说说其和单例的关系。
看到这的小伙伴想必都知道单例的饱汉模式,而饱汉模式有双重校验锁的实现方式,代码如下:

public class Singleton {
	private static Singleton singleton;
	private Singleton(){
		System.out.println("生成了一个实例");
	}
	public static Singleton getInstance(){
		if(singleton==null){
			synchronized(Singleton.class){
				if(singleton==null){
					singleton = new Singleton();
				}
			}
		}
		return singleton;
	}
}

按照我所了解到的,在上述代码中,语句“singleton = new Singleton()”在程序执行时会发生指令重排,这样一个语句,实际上被分成了以下三个步骤:

  • 分配对象的内存空间
  • 初始化内存空间
  • 将对象指向该内存空间

而当指令重排的时候,三个步骤的顺序可能会变成这样:

  • 分配对象的内存空间
  • 将对象指向该内存空间
  • 初始化内存空间

那么问题就来了,假设我们现在有两个线程,A和B,当A执行到上述步骤中的第二步的时候,B只想到了第一个校验语句“if(singleton==null)”,此时对象已经指向了分配的内存空间,所以singleton不为空,那么B线程就会获得一个未经初始化的对象,从而造成程序错误。


因此需要将singleton声明为volatile类型,以此来禁止指令重排。

4、思考

昨天在写代码的时候,正好写到这个单例模式,突然间想到个问题,单例模式的双重校验锁真的会有指令重排问题吗?


按照上面的说法,B线程确实有可能会获取到未经初始化的对象,但是B线程拿这个对象做什么呢?我认为对对象的操作无非就是读写,那么就引发了另一个问题,像双重校验锁这样的写法,A线程在加了锁之后,B线程是否还能够对singleton进行操作?


于是我写了以下测试代码:

public class Main2 {
    public static void main(String[] args) {
        Thread1 thread1 = new Thread1();
        thread1.start();
        Thread2 thread2 = new Thread2();
        thread2.start();
    }
}
class Thread1 extends Thread{
    @Override
    public void run() {
        Solution solution = new Solution();
        System.out.println("1:" + solution.print());
    }
}
class Thread2 extends Thread{
    @Override
    public void run() {
        Solution solution = new Solution();
        System.out.println("2:" + solution.print());
    }
}

class Solution {
    public static Tmp tmp = null;
    public Solution(){
        if(tmp == null){
            synchronized (Solution.class){
                if(tmp == null){
                    tmp = new Tmp();
                    try{
                        Thread.sleep(3000);
                    } catch (Exception e){
                        System.out.println(e.getMessage());
                    }
                }
            }
        }
    }
    public Tmp print(){
        return tmp;
    }
}
@Data
class Tmp{
    private String string;
    public Tmp(){
        string = "hello";
    }
}

我在加锁的代码里加了个3秒的等待时间,然后启动两个线程去获取tmp对象并输出,在多次测试中,我发现,当A线程在执行下列代码的时候,B线程要输出tmp对象需要等待A线程先执行完,将锁释放:

synchronized (Solution.class){
    if(tmp == null){
        tmp = new Tmp();
        try{
            Thread.sleep(3000);
        } catch (Exception e){
            System.out.println(e.getMessage());
        }
    }
}

为了更加明显的看出这个问题,我对修改了下代码:

public Solution(){
    if(tmp == null){
        synchronized (Solution.class){
            if(tmp == null){
                tmp = new Tmp();
                try{
                    System.out.println("锁内等待开始");
                    Thread.sleep(3000);
                    System.out.println("锁内等待结束");
                } catch (Exception e){
                    System.out.println(e.getMessage());
                }
            }
        }
        try{
            System.out.println("锁外等待开始");
            Thread.sleep(3000);
            System.out.println("锁外等待结束");
        } catch (Exception e){
            System.out.println(e.getMessage());
        }
    }
}

emmmm,最后的测试结果推翻了我上面的结论…双重校验锁确实有指令重排问题!


其实昨天打算写这篇文章的时候,是打着推翻权威的心思的,不过今天写的时候,写着写着就觉得权威果然是权威,写博客还是有点用的,可以让自己理清思路,不愧是我!

本文地址:https://blog.csdn.net/Stone__Fly/article/details/112006789

《设计模式-单例模式-指令重排思考.doc》

下载本文的Word格式文档,以方便收藏与打印。