单例模式(双重检测锁式单例不安全的原因与解决)

单例模式

核心作用

  • 保证一个类只有一个对象,并且提供一个访问该实例的全局访问点

单例模式的优点

  • 由于单例模式只生成一个实例,减少了系统的开销,当一个对象需要比较的多的资源时,如读取配置、产生其它依赖对象时,则可以通过在应用启动时直接产生一个单例对象,然后永久驻留内存的方式来解决
  • 单例模式可以在系统设置全局访问点,优化环共享资源访问,例如可以设计一个单例类,负责所以数据表的映射处理

常见的五种单例模式实现方式

  • 主要
    • 饿汉式(线程安全,调用效率高。但是,不能延时加载)
    • 懒汉式(线程安全,调用效率不高。但是,可以延时加载)
  • 其它
    • 双重检测锁式单例(由于JVM底层内部模型原因,偶尔会出问题。不建议使用)
    • 静态内部类实现(线程安全,调用效率高。但是,可以延迟加载)
    • 枚举单例(线程安全,调用效率高,不能延迟加载)

饿汉式单例:

饿汉式单例,顾名思义就是无论是否需要使用该类的实例,在类加载时都创建该类的实例。(并不是只有下面一种实现,下文的枚举单例也是饿汉式单例。)

Java 代码实现如下:

/**
 * 饿汉式单例模式
 */
public class Singleton {

    //类初始化时立即加载
    private static final Singleton instance = new Singleton1();

    private Singleton1() {}

    public static Singleton getInstance() {
        return instance;
    }
	
    //...
}

在该类被加载时实例就会被创建,无论是否要使用该类,所以称为饿汉式

懒汉式单例:

懒汉式单例,就是类加载时不创建类的实例,在第一次使用时再创建。(同样也不只有下面的一种实现,下文的静态内部类实现的单例以及双重检测锁式单例都是懒汉式单例。)

Java 代码实现如下:

/**
 * 懒汉式单例模式
 */
public class Singleton {

    //类初始化时,不初始这个对象,用到的时候再创建(延时加载)
    private static Singleton instance = null;

    private Singleton() {}

    /**
     * 对象延迟加载,只要在要用的时候才会创建
     * 但是为了防止对象多线程时重复创建对象,所以必须加synchronized,效率不高,因为多线程时需要等待
     */
    public static synchronized Singleton getInstance() {
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }

    //...
}

在类加载时,实例不会被初始化,默认为null;当第一次调用 getInstance() 方法时才会创建实例;为了保证实例不被重复创建,对 getInstance() 方法添加了 synchronized 锁。但这种实现并不高效,因为任何时候调用都必须承受同步带来的性能开销,然而又只有第一次调用需要同步,所以并不建议使用。

静态内部类实现单例模式

Java 代码实现如下:

/**
 * 静态内部类实现单例模式
 */
public class Singleton {

    private static class SingletonClassInstance {
        private static final Singleton instance = new Singleton4();
    }

    private Singleton() {}

    public static Singleton getInstance() {
        return SingletonClassInstance.instance;
    }

    //...
}

因为内部类不会因为外部类的加载而加载,只有在使用到内部类时才加载,所以静态内部类的单例实现是延时加载且线程安全的(实例在且只在内部类被加载时创建),又因为没有添加同步锁,所以调用效率也高。

枚举单例

Java 代码实现如下:

/**
 * 枚举单例
 */
public enum Singleton5 {

    //枚举元素本身就是单例的
    INSTANCE;

    public void operation() {
        //...
    }

}

枚举元素天生就是线程安全的单例,调用效率也高,只是无法延时加载!

双重检测锁式单例

为了解决上述的懒汉式单例因为同步带来的性能损耗,聪明的程序员想到了使用双重检测锁来解决每次调用都需要同步的问题。尽管双重检测锁背后的理论是完美的,但不幸的是由于 Java 的内存模型允许“无序写入” , 错误的双重检测锁式单例并不能保证它会在单处理器或多处理器计算机上顺利运行。

错误双重检测锁式单例:

/**
 * 错误双重检测锁式单例
 */
public class Singleton {
    
    private static Singleton instance = null;
    
    private Singleton() {}
    
    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();//erro
                }
            }
        }
        return instance;
    }
}

下面是上述代码的运行顺序:

  1. 检测实例是否已经初始化创建,如果是则立即返回
  2. 获得锁
  3. 再次检测实例是否已经初始化创建成功,如果还没有则创建实例

执行双重检测是因为,如果多个线程通过了第一次检测,并且其中一个首先通过了第二次检测并实例化了对象,剩余的线程不会再重复实例化对象。这样,除了初始化的时候会加锁,后续的调用都是直接返回,解决了多余的性能消耗。

隐患

看似天衣无缝,但是这种实现是有隐患的,这个隐患来自于上述代码中注释了 erro 的一行,这行代码大致有以下三个步骤:

  1. 在堆中开辟对象所需空间,分配地址
  2. 根据类加载的初始化顺序进行初始化
  3. 将内存地址返回给栈中的引用变量

由于 Java 内存模型允许“无序写入”,有些编译器因为性能原因,可能会把上述步骤中的 2 和 3 进行重排序,顺序就成了

  1. 在堆中开辟对象所需空间,分配地址
  2. 将内存地址返回给栈中的引用变量(此时变量已不在为null,但是变量却并没有初始化完成)
  3. 根据类加载的初始化顺序进行初始化

现在考虑重排序后,两个线程出现了如下调用:

Time Thread A Thread B
T1 第一次检测, instance 为空
T2 获取锁
T3 再次检测, instance 为空
T4 在堆中分配内存空间
T5 instance 指向分配的内存空间
T6 第一次检测,instance不为空
T7 访问 instance(此时对象还为初始化完成)
T8 初始化 instance

此时 T7 时刻 Thread B 对 instance 的访问,访问到的是一个还未完成初始化的对象。所以在使用 instance 时可能会出错。

解决无序写入问题的尝试

基于上文的问题,自然而然的可以得出以下修复代码

public class Singleton {

    private static Singleton instance = null;

    private Singleton() {}

    public static Singleton getInstance() {
        if (instance == null) {
            Singleton temp;
            synchronized (Singleton.class) {
                temp = instance;
                if (temp == null) {
                    synchronized (Singleton.class) {
                        temp = new Singleton();//*
                    }
                    instance = temp;//*
                }
            }
        }
        return instance;
    }
}

此代码的理论是通过一个局部变量和内部同步代码块,使创建实例在内部同步块中进行并赋值给局部变量,退出内部同步块时实例已经初始化完成,然后再从局部变量赋值给 instance,从而使 instance 引用内存空间时,指向的是一个已经初始化完成的实例。

这个代码理论上是可行的,但是理论却与实际背道而驰。实际上这个代码并不是按照上述理论的步骤执行的, Java 语言规范要求不能将 synchronized块中的代码移出来。但是,并没有说不能将 synchronized 块外面的代码移 synchronized 块中。 编译器在这里会看到一个优化的机会,此优化会删除上面注释 * 的两行代码,组合并产生以下代码

//编译器优化后的代码
public class Singleton {

    private static Singleton instance = null;

    private Singleton() {}

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

同样还是会遇到之前无序写入导致的问题。

正确的双重检测锁式单例

/**
 * 正确的双重检测锁实现单例模式
 */
public class Singleton3 {

    private static volatile Singleton3 instance = null;

    private Singleton3() {}

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

为了解决上述问题,需要在instance前加入关键字volatile。使用了volatile关键字后,重排序被禁止,所有的写(write)操作都将发生在读(read)操作之前。 但是只在 JDK5 及之后有效。

JDK5 以及后续版本扩展了volatile语义,不再允许volatile写操作与其前面的读写操作重排序,也不允许volatile读操作与其后面的读写操作重排序。

总结

上述五种常见的单例模式实现中,如果需要延时加载建议使用静态内部类实现,至于饿汉式单例两种实现都可以。

原文请点击此处
posted @ 2020-04-21 00:27  GoodBoyDing  阅读(234)  评论(0)    收藏  举报