你真的懂单例模式吗

2,226 阅读9分钟

在面试中我们经常会被问到:“你熟悉单例模式吗?请手写一个单例模式的实现?单例模式的应用有哪些……”。有关单例模式的问题比比皆是,在面试中也是非常常见的。

所谓单例模式就是确保一个类只有一个实例,并对外提供该实例的全局访问点

实现

类图如下:

singleton
解读:

  • 实现单例模式的思路:
    • 在类中有一个自身的变量(这个变量可以在使用时创建,也可以使用前创建);
    • 确保全局只有该变量的一个实例;
    • 对外提供一个访问该变量的公共方法;
  • 对照上面的实现思路,可知实现步骤分为三步,具有以下特点:
    • 私有的静态变量:加 static 关键字,相当于是一个常量,体现该实例是独一份的特点;
    • 构造函数私有化:目的是不允许其他类执行 new 操作,即不允许其他类创建该类,即也能保证全局只有一个该变量的实例;
    • 提供静态的全局访问点:访问该类的私有变量,因私有变量被 static 关键字修饰,所以获取该变量的公共方法也必须是 static 修饰的;

懒汉式-线程不安全

实现代码

public class LazySingleton {
    // 构造函数私有化
    private LazySingleton(){
        System.out.println("当前线程名称: " + Thread.currentThread().getName() + "\t 我是构造方法...");
    }

    // 私有的静态变量
    private static LazySingleton lazySingleton;

    // 提供静态的全局访问点
    public static LazySingleton getSingleton() {
        if (lazySingleton == null) {
            lazySingleton = new LazySingleton();
        }
        System.out.println(lazySingleton); // 打印当前对象的唯一标识
        return lazySingleton;
    }
}

测试代码

public class TestSingletons {
    public static void main(String[] args) {
        // 单线程场景下,直接调用
        LazySingleton.getSingleton();
        LazySingleton.getSingleton();
        LazySingleton.getSingleton();
        LazySingleton.getSingleton();

        // 多线程场景
        /*for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                LazySingleton.getSingleton();
            }, String.valueOf(i)).start();
        }*/
    }
}
  1. 打开单线程场景代码并注释多线程场景代码:
  • 运行结果如下:
当前线程名称: main	 我是构造方法...
singleton.LazySingleton@4554617c
singleton.LazySingleton@4554617c
singleton.LazySingleton@4554617c
singleton.LazySingleton@4554617c
  • 结果分析: 由结果发现整个过程只构造了一次,这个变量的唯一标识为4554617c,说明单线程场景下是没有问题的
  1. 打开多线程场景代码并注释单线程场景代码:
  • 运行结果如下:
当前线程名称: 2	 我是构造方法...
当前线程名称: 8	 我是构造方法...
当前线程名称: 4	 我是构造方法...
singleton.LazySingleton@ae526cf
当前线程名称: 6	 我是构造方法...
当前线程名称: 0	 我是构造方法...
singleton.LazySingleton@134f6cee
当前线程名称: 3	 我是构造方法...
当前线程名称: 9	 我是构造方法...
singleton.LazySingleton@6e154e44
当前线程名称: 5	 我是构造方法...
当前线程名称: 7	 我是构造方法...
当前线程名称: 1	 我是构造方法...
singleton.LazySingleton@2fd04fd1
singleton.LazySingleton@67c084e5
singleton.LazySingleton@47e3e4b5
singleton.LazySingleton@1b9c704e
singleton.LazySingleton@21279f82
singleton.LazySingleton@2ceb2de
singleton.LazySingleton@14550b42
  • 结果分析: 多次运行结果不一致,实例的唯一标识各不相同,也就是构造了十次,每次都会产生一个新的实例。这说明该实现方式在多线程场景下是无法保证线程安全的

懒汉式-线程安全

在上一个实现方式的基础上加以改进,以求保证在多线程条件下可以达到线程安全的目的。基于这种思路,可以得出懒汉式的另外一种实现方式——线程安全的实现方式。

实现代码

public class LazySafeSingleton {
    // 构造方法私有化
    private LazySafeSingleton(){
        System.out.println("当前线程名称: " + Thread.currentThread().getName() + "\t 我是构造方法...");
    }

    // 私有的静态变量
    private static LazySafeSingleton lazySafeSingleton;

    // 提供同步的静态全局访问点
    public synchronized static LazySafeSingleton getSingleton() {
        if (lazySafeSingleton == null) {
            lazySafeSingleton = new LazySafeSingleton();
        }
        System.out.println(lazySafeSingleton); // 打印当前对象的唯一标识
        return lazySafeSingleton;
    }
}

该方式与第一种方式只有一点区别:在提供的全局访问点,即获取实例对象的公共方法加了同步锁,保证同一时刻,只能由一个线程访问 getSingleton() 方法。

延伸阅读: java synchronized详解

测试代码

public class TestSingletons {
    public static void main(String[] args) {
        // 单线程场景
        LazySafeSingleton.getSingleton();
        LazySafeSingleton.getSingleton();
        LazySafeSingleton.getSingleton();
        LazySafeSingleton.getSingleton();

        // 多线程场景
        /*for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                LazySafeSingleton.getSingleton();
            }, String.valueOf(i)).start();
        }*/
    }
}
  1. 单线程场景:
  • 运行结果:
当前线程名称: main	 我是构造方法...
singleton.LazySafeSingleton@4554617c
singleton.LazySafeSingleton@4554617c
singleton.LazySafeSingleton@4554617c
singleton.LazySafeSingleton@4554617c
  • 结果分析: 只构造一次,单线程场景下是没有问题的。
  1. 多线程场景下:
  • 运行结果:
当前线程名称: 0	 我是构造方法...
singleton.LazySafeSingleton@134f6cee
singleton.LazySafeSingleton@134f6cee
singleton.LazySafeSingleton@134f6cee
singleton.LazySafeSingleton@134f6cee
singleton.LazySafeSingleton@134f6cee
singleton.LazySafeSingleton@134f6cee
singleton.LazySafeSingleton@134f6cee
singleton.LazySafeSingleton@134f6cee
singleton.LazySafeSingleton@134f6cee
singleton.LazySafeSingleton@134f6cee
  • 结果分析: 多次运行后的结果,会发现都只会构造一次。

双重锁校验-线程安全

分析上一个实现 getSingleton() 方法上加了 synchronized 关键字修饰,虽然能保证同一时刻只能由一个线程访问,保证了多线程场景下的一致性,但是这也会带了另外一个问题:并发性降低。所以,接着对懒汉式-线程安全进行改进。

实现代码

public class DCLSingleton {
    // 构造方法私有化
    private DCLSingleton(){
        System.out.println("当前线程名称: " + Thread.currentThread().getName() + "\t 我是构造方法...");
    }

    // 静态私有变量
    private volatile static DCLSingleton dclSingleton;

    // 提供静态全局访问点
    public static DCLSingleton getDclSingleton() {
        if (dclSingleton == null) {
            synchronized (DCLSingleton.class) {
                if (dclSingleton == null) {
                    dclSingleton = new DCLSingleton();
                }
            }
        }
        System.out.println(dclSingleton); // 打印当前对象的唯一标识
        return dclSingleton;
    }
}

对比上一个实现方式,会发现有两处区别:

  1. 在私有变量上面加了volatile关键字。 原因是:dclSingleton = new DCLSingleton();编译成字节码后分为三个步骤:
1. 为 dclSingleton 分配内存空间
2. 初始化 dclSingleton
3. 将 dclSingleton 执行分配的内存地址

由于jvm具有指令重排的特性,在多线程环境下就可能会出现一个线程获取到的实例还未被初始化的情况。例如:线程T1执行了1和3,此时线程T2调用 getDclSingleton() 方法后发现 dclSingleton 不为空,因此会返回 dclSingleton,但是此时 dclSingleton 还未被初始化。因此在声明静态私有变量时添加volatile关键字保证jvm无法进行指令重排,从而解决上述问题。

  1. 原来的同步方法变成了同步块。
if (dclSingleton == null) {
    synchronized (DCLSingleton.class) {
        dclSingleton = new DCLSingleton();
    }
}

在只有一个 if 的代码中,多线程条件下,假设线程T1和线程T2同时进入 dclSingleton == null 语句,接着T1或T2其中的一个线程会执行 dclSingleton = new DCLSingleton(); ,在执行结束之后会释放锁,另外一个线程也会再次执行 dclSingleton = new DCLSingleton(); 语句,这导致构造函数执行了两次,因此在同步代码块中,需要再次对 dclSingleton 是否为空进行判断。

测试代码

public class TestSingletons {
    public static void main(String[] args) {
        // 单线程
        DCLSingleton.getDclSingleton();
        DCLSingleton.getDclSingleton();
        DCLSingleton.getDclSingleton();
        DCLSingleton.getDclSingleton();

        // 多线程
        /*for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                LazySafeSingleton.getSingleton();
            }, String.valueOf(i)).start();
        }*/
    }

}
  1. 单线程场景下:
  • 运行结果:
当前线程名称: main	 我是构造方法...
singleton.DCLSingleton@4554617c
singleton.DCLSingleton@4554617c
singleton.DCLSingleton@4554617c
singleton.DCLSingleton@4554617c
  • 结果分析: 构造方法只执行一次,单线程场景下是没有问题的。
  1. 多线程场景下:
  • 运行结果:
当前线程名称: 0	 我是构造方法...
singleton.LazySafeSingleton@2ceb2de
singleton.LazySafeSingleton@2ceb2de
singleton.LazySafeSingleton@2ceb2de
singleton.LazySafeSingleton@2ceb2de
singleton.LazySafeSingleton@2ceb2de
singleton.LazySafeSingleton@2ceb2de
singleton.LazySafeSingleton@2ceb2de
singleton.LazySafeSingleton@2ceb2de
singleton.LazySafeSingleton@2ceb2de
singleton.LazySafeSingleton@2ceb2de
  • 结果分析: 多次运行后的结果,会发现都只会构造一次。

饿汉式-线程安全

懒汉式与饿汉式的最主要区别在于,懒汉式的静态私有变量为空,在使用时进行构造;而饿汉式则在加载时就已经构造好了,即在使用前即已经构造完毕。这种方式会造成一定的资源浪费。

public class HungrySingleton {
    // 构造方法私有化
    private HungrySingleton() {
        System.out.println("当前线程名称: " + Thread.currentThread().getName() + "\t 我是构造方法...");
    }

    // 静态私有变量
    private static HungrySingleton hungrySingleton = new HungrySingleton();

    // 提供静态全局访问点
    public static HungrySingleton getSingleton() {
        System.out.println(hungrySingleton); // 打印当前对象的唯一标识
        return hungrySingleton;
    }
}

测试方法与上面的懒汉式的创建方式一致,会发现不管是单线程环境下还是多线程条件下,这种方式都是只会构造一次。

其他方式

静态内部类

对 饿汉式-线程安全 的实现方式进行改进,可以对 创建实例对象 和 使用实例对象 两个步骤进行解耦,即实现使用时在进行创建。静态内部类完全符合。

public class InnerClazzSingleton {
    // 私有化构造方法
    private InnerClazzSingleton(){}

    // 静态内部类,保证使用时才加载
    private static class InnerClassSingletonHolder {
        private static final InnerClazzSingleton SINGLETON = new InnerClazzSingleton();
    }

    // 提供静态全局访问点
    public static InnerClazzSingleton getInstance() {
        return InnerClassSingletonHolder.SINGLETON;
    }
}

这种方式利用了静态内部类在使用时才会进行加载的特性。即调用 getInstance() 方法时 InnerClassSingletonHolder 才会被加载,此时会初始化 SINGLETON 实例,并且也能保证只被初始化一次。这种方式不仅具有饿汉式的线程安全的特点,又具有延迟初始化节省系统资源的特点。 测试方法略。

延伸阅读:朝花夕拾——Java静态内部类加载

枚举类

public enum EnumSingleton {
    INSTANCE;

    private String name;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}

单例模式的应用

  • Logger Classes
  • Configuration Classes
  • Accesing resources in shared mode
  • Factories implemented as Singletons
  • java.lang.Runtime#getRuntime()
  • java.awt.Desktop#getDesktop()
  • java.lang.System#getSecurityManager()

总结

  1. 手写单例模式的步骤
  • 在类中有一个自身的变量(这个变量可以在使用时创建,也可以使用前创建);
  • 确保全局只有该变量的一个实例;
  • 对外提供一个访问该变量的公共方法;
  1. 各种模式的区别
不同的实现方式 特点
1. 懒汉式-线程不安全 线程不安全;具有延迟加载解决资源的特点;
2. 懒汉式-线程安全 对1进行改造——在全局访问点处添加同步机制。能保证线程安全,虽说多线程能保证一致性,但是无法保证并发性
3. 双重锁校验式-线程安全 对2进行改造,以求提高并发性,使用volatile 修饰静态实例变量,同步前和同步后均需要校验实例变量是否为空。线程安全
4. 饿汉式-线程安全 对1进行改造,在使用前即会创建实例变量。全局只会创建一次,因此能保证线程安全,但是会造成资源浪费的问题
5. 静态内部类-线程安全 对4进行改造,利用静态内部类使用时才会加载的特性将 实例变量的使用权 和 构造权 解耦。线程安全
6. 枚举类-线程安全 线程安全,多适用于单元素场景

延伸阅读: 单例模式