volatile在单例模式中的使用&双端检锁| 8月更文挑战

386 阅读3分钟

单例模式

单例模式常见的写法有懒汉模式,饿汉模式,双重检查模式等。

  • 懒汉模式就是用的时候再去创建对象。
  • 饿汉模式就是提前就已经加载好的静态static对象。
  • 双重检查模式就是在加锁前和加锁后共两次检查防止多线程创建多个对象。

单例模式有以下特点:

  1. 单例类只能有一个实例。
  2. 单例类必须自己创建自己的唯一实例。
  3. 单例类必须给所有其他对象提供这一实例。

优点: 不会频繁的创建和销毁对象,浪费系统资源

单线程情况下的单例模式

class Singleton {
    private static Singleton instance = null;

    private Singleton() {
        System.out.println("我是构造方法" + Thread.currentThread().getName());
    }

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

    public static void main(String[] args) {
        Singleton instance1 = Singleton.getInstance();
        Singleton instance2 = Singleton.getInstance();
        Singleton instance3 = Singleton.getInstance();
        System.out.println(instance1 == instance2);
        System.out.println(instance2 == instance3);

运行结果:

我是构造方法main

true

true

多线程情况下的单例模式

可以看到在单线程情况下这种写法是符合单例模式的要求的,构造方法只被调用一次。

那么我们再试下在多线程环境下

class Singleton {
    private static Singleton instance = null;

    private Singleton() {
        System.out.println("我是构造方法" + Thread.currentThread().getName());
    }

    static Singleton getInstance() {
        if (instance == null) { //先判断一次再进行加锁比直接加锁效率更高(锁的粒度更小)
            instance = new Singleton();
        }
        return instance;
    }

    public static void main(String[] args) {
        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                Singleton.getInstance();
            }, String.valueOf(i)).start();
        }
    }
}

运行结果:

我是构造方法0
我是构造方法4
我是构造方法3
我是构造方法2
我是构造方法1

这个是因为多线程情况下,instance对象还没有被创建的时候,多个线程进入了创建对象的代码(因为这个时候它们判断都是instance == null),这样就new出了多个实例

当然这明显是不符合单例模式的要求的,因此我们引入了双端检查锁模式下的单例模式

DDL模式下的单例模式

  • D:Double
  • C:Check
  • L:Lock

看下面的代码可以看到加锁前和加锁后都对instance ==null 进行了判断,所以叫做双端检锁。

class SingletonDCL {
    private static SingletonDCL instance = null;

    private SingletonDCL() {
        System.out.println("我是构造方法" + Thread.currentThread().getName());
    }

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

    public static void main(String[] args) {
        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                SingletonDCL.getInstance();
            }, String.valueOf(i)).start();
        }
    }
}

运行结果:

我是构造方法0

可以看到运行结果是我们想要的,在多线程环境下只创建了一个实例,虽然双端检查锁大大降低了创建多个实例的几率,但是还是存在一些问题的。

存在的不足

某一个线程读到instance不为空时,instance的引用对象可能还没有完成初始化,这是由于指令重排的原因。

instance = new SingletonDCL();这个过程可以分为以下3个步骤:

  • memory = allocate(); //1.分配对象内存空间
  • instance(memory);//2.初始化对象
  • instance = memory;//3.设置instance指向刚分配对象的内存地址,这个时候instance != null;

但是由于在单线程环境下,2,3之间是没有数据依赖关系的,所以可能发生指令重排,

  • memory = allocate(); //1.分配对象内存空间
  • instance = memory;//3.设置instance指向刚分配对象的内存地址,这个时候instance != null但是对象还没有初始化完成。
  • instance(memory);//2.初始化对象

所以此时如果有一个线程访问instance != null,由于对象还没有初始化完成,就会返回一个空值,这就造成了线程安全的问题,所以我们需要加上volatile来防止指令重排

private volatile static SingletonDCL instance = null;

总结:多线程环境下安全的单例模式 = 双端检查锁 + volatile

当然也可以在方法上加synchronized,但是这样锁住了整个方法太降低性能了,不推荐。