欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  IT编程

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

程序员文章站 2022-03-03 09:41:11
1、单例模式之前写过一篇单例模式的博客,有不了解单例模式的可以看看。2、指令重排指令重排指的是在程序执行时,为了性能考虑,编译器和CPU可能会对指令进行重新排序,下面举个例子,比如有如下程序:int a,b;a = 2;b = 2;这个程序在执行的时候,可能执行顺序就会颠倒,变成先执行“b = 2”,再执行“a = 2”,这个就叫指令重排。指令重排有几个基本原则,不清楚的可以看我引用的博客,这里要说的是顺序执行原则,指令重排保证在单线程内语义的串行性,举个例子:int a,b;a =...

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