设计模式之单例模式(指令重排,DCL)

单例模式一直被认为是设计模式中最简单的一种创建型模式,也是最容易编写的一种模式。但是其中存在着很多的细节问题,也经常在面试中被拿来询问。顾名思义,在此种模式下,整个系统环境运行时,其中只有一个实例,而这个实例并不是对象外的其他对象创建,而是由它自己负责创建自己的对象,并提供访问这个唯一实例的方法。

单例模式最常见的就是MySQL的发号器(控制全局生成一个唯一的ID)、Redis的连接对象以及常用Windows的任务管理器进行进程的查看,这些都是单例模式常见的地方。它的实现方式也是很简单,主要核心就是——私有化构造函数、提供获取实例的方法。根据实例的创建时期也将单例设计模式划分为两种——懒汉模式和饿汉模式,下面就围绕这两种模式详细展开。

懒汉模式

所谓懒汉就是指在需要使用的时候才进行的对象的创建,这样可以在一定程度上降低的内存使用。实现一个懒汉模式的单例,按照上面所说的实现核心,就是如下的代码。

/**
 * @author SunRains
 * @date 2022-02-26 16:18
 **/
public class SingleLazy {
    private static SingleLazy instance;

    private SingleLazy() {
    }

    private void doSomething(){
        System.out.println("Do something...");
    }

    public static SingleLazy getInstance() {
        if (instance == null) {
            instance = new SingleLazy();
        }
        return instance;
    }
}

这是单例的最基本实现方式,也是很多书籍上面的写法。但是深入了解后,发现这段代码只适合单线程。如果多线程的情况下,就可能线程A和B在调用getInstance时instance都是没有实例化,从而实例化两次,与单例模式思维就相违背。所以在多线程的模式下,不推荐此种方式,因为容易造成线程不安全的问题。

那么既然线程不安全,肯定有更好的方法,你可能想如果只让getInstance方法只能同时一个线程调用,也就是用synchronized关键词修饰这个方法是不是也能实现,答案是肯定的。

 public static synchronized SingleLazy getInstance() {
        if (instance == null) {
            instance = new SingleLazy();
        }
        return instance;
    }

这种方式虽然能够解决线程安全的问题,但是效率却是最差的。首先,将synchronized加在整个方法上面,会让锁粒度很大,也会造成很大的开销。其次,细想一下我们最终的目的只是想控制实例化的代码在同步操作中,即只是在第一次调用的时候创建实例化对象。

双重校验DCL

所以有了进一步的优化,也就引出了双重校验锁。所谓的双重校验锁,就是在锁的内部和外部分别对instance进行为null的校验。之所以要进行两次判断是因为可能两个线程同时进行同步代码块的外语句,线程一在同步代码块里面执行了初始化后,线程二也会进行初始化,所以有必要在同步块里面再进行一次为null的校验,这样线程二就不会执行任何操作。

public static SingleLazy getInstance() {
        if (instance == null) { // 一重校验
            synchronized (SingleLazy.class) {
                if (instance == null) { // 二重校验
                    instance = new SingleLazy();
                }
            }
        }
        return instance;
    }

指令重排

这样操作其实看起来就比较完美了,就在我们觉得可以的时候,有一个被我们一直忽略的问题——instance = new SingleLazy()。你可能会会疑惑,为什么会是这段代码有问题?如果没有了解JVM虚拟机还真的想不通这个问题。因为这段语句看似是原子性操作,但是实际上并非是。在JVM的内部中,这段话主要执行了三件事。

① 分配空间给instance对象。

② 在空间内实例化对象。

③ 将instance复制给引用instance。

在JVM的编译器中存在指令重排序的优化,也就是说上面的执行顺序可能是1-2-3,也可能是1-3-2.如果线程的执行顺序是1=》3=》2,会把值先写入内存中,但是还未初始化,此时instance是非null,并不是一个完全的对象。因此,如果线程二抢占执行会直接返回instance,然后就直接使用,这样问题就出现了。

此时需要做的只是将instance变量声明成volatile。volatile是Java提供的关键字,它具有可见性和有序性,即会禁止指令重排序优化。指令重排序是JVM对语句执行的优化,只要语句间没有依赖,那JVM就会对语句进行优化。

/**
 * @author SunRains
 * @date 2022-02-26 16:43
 **/
public class SingleLazy {
    private static volatile SingleLazy instance;

    private SingleLazy() {
    }

    private void doSomething() {
        System.out.println("Do something...");
    }

    public static SingleLazy getInstance() {
        if (instance == null) {
            synchronized (SingleLazy.class) {
                if (instance == null) {
                    instance = new SingleLazy();
                }
            }
        }
        return instance;
    }
}

但是特别注意的是Java 5以前的版本使用Volatile的双重校验是存在问题,因为Java 5以前的Java内存模型是存在缺陷的,即将变量声明成volatile也不能完全避免重排序。

饿汉模式

这种方法非常简单,因为单例的实例被声明成static和final变量,且在第一次加载类到内存中时就会初始化。

/**
 * @author SunRains
 * @date 2022-02-28 9:26
 **/
public class SingleHungry {

    private final static SingleHungry instance=new SingleHungry();

    private SingleHungry(){
    }

    public static SingleHungry getInstance(){
        return instance;
    }
}

 这种写法就没必要再考虑双校验锁的问题,只是会在加载类就被初始化,即使客户端没有调用getInstance方法。这种方式在一些单例实例的创建是依赖参数或者配置文件,在 getInstance() 之前必须调用某个方法设置参数给它,那样这种单例写法就无法使用了。

你可能感兴趣的:(设计模式,Java,单例模式,java,设计模式)