很久没有复习单例模式了,今天做一个笔记,对 Java 中的单例模式做一个总结,以做备忘。

手写单例模式是有可能在面试过程中被问到的问题,一般可以从单例模式的使用场景、具体实现方式、线程安全与否、延迟加载与否、如何选择哪种单例模式等方面入手,下面介绍 5 种不同的单例模式。

适用场景

单例模式是一种对象创建模式,用于产生一个对象的具体实例,能够确保系统中一个类只产生一个实例。对于一些关键组件和被频繁使用的对象,使用单例模式可以有效的改善系统性能,由于 new 关键字操作的次数减少,可以减轻 GC 压力,缩短 GC 停顿时间。其核心在于通过一个接口返回唯一的对象实例,让某个类自己负责自身类的创建工作(通常使用 private 关键字),然后由这个类来提供外部可以访问这个类实例的方法(通常使用 public 关键字)。适用场景如下:

  • 需要生成唯一序列的环境;
  • 需要频繁实例化然后销毁的对象;
  • 创建对象时耗时过多或消耗的资源过多,但又是经常需要用到的对象;
  • 方便资源相互通信的环境。

饿汉式

饿汉式的实例在类加载的时候就完成了初始化任务,导致类加载的速度比较慢,但获取对象的速度快。该方式是线程安全的,并且不能延迟加载,因为在类初始化时,已经自行实例化了。同时,提供了一个私有的构造器,只有这样才能保证单例不会在系统中的其它地方被实例化。并对外部提供一个公共的获取实例的方法,该方法和INSTANCE一样,都是使用static修饰。代码如下所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
public class SingletonOne {

    private static final SingletonOne INSTANCE = new SingletonOne();

    private SingletonOne() {
    }

    public static SingletonOne getInstance() {
        return INSTANCE;
    }
}

懒汉式

顾名思义,懒汉式只有在想要获取实例(即调用getInstance()方法)的时候才创建实例,这种方式可以做到懒加载,即延迟加载。同时使用了synchronized关键字能够保证该方式是线程安全的,因为在多线程的情况下,当线程 A 正在新建单例的时候,在完成赋值操作之前,此时如果线程 B 将instance判断为null的话,则线程 B 也会进行单例的创建,从而会导致多个实例被创建的错误,因此需要使用线程同步。

这里没有对instance使用final修饰是因为懒汉式每次创建的实例都是不同的。

getInstance()方法中,首先判断当前实例是否已经存在,存在则返回,不存在才进行创建。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
public class SingletonTwo {

    private static SingletonTwo instance;

    private SingletonTwo() {
    }

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

虽然懒汉式在第一次调用的时候才进行初始化,看似节约了系统资源,但第一次加载的时候需要实例化,无疑会出现效率低等问题。

双重校验锁

双重检验锁(Double CheckLock)和懒汉式很相似,能够保证线程安全的同时进行延迟加载。与懒汉式不同的是,在getInstance()方法内部进行了两次判空操作,第二次判空是因为在多线程环境下其它的线程在调用getInstance()方法的时候,有可能在此之前instance已经被创建过了,所以需要再次进行判断。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
public class SingletonThree {
    private volatile static SingletonThree instance;

    private SingletonThree() {
    }

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

此外,使用volatile关键字修饰instance,这样可以避免在进行instance = new SingletonThree();时造成的指令重排问题。

原本的指令instance = new SingletonThree();可分解为以下三步:

1
2
3
memory = allocate();  // 1. 分配对象的内存空间
ctorInstance(memory); // 2. 初始化对象
instance = memory;    // 3. 设置 instance 指向刚分配的内存空间。

假如不使用volatile关键字进行修饰,则有可能会在第 11 行发生指令重排,如下:

1
2
3
4
memory = allocate();  // 1. 分配对象的内存空间
instance = memory;    // 3. 设置 instance 指向刚分配的内存空间。
                      // 注意,此时对象还没有初始化!
ctorInstance(memory); // 2. 初始化对象

静态内部类

通过使用静态的内部类来创建实例,可以实现延迟加载,即第一次加载SingletonFour类时不会初始化INSTANCE,只有在调用getInstance()方法的时候才初始化对象。此方法可以保证线程安全也能够保证该类的唯一性。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
public class SingletonFour {
    // 私有构造器
    private SingletonFour() {
    }

    // 对外提供公共的获得实例的方法
    public static SingletonFour getInstance() {
        return Inner.INSTANCE;
    }

    // 静态内部类
    private static class Inner {
        private static final SingletonFour INSTANCE = new SingletonFour();
    }
}

枚举类

使用枚举方式实现的单例虽然不能延迟加载,但可以保证线程安全,并且能够防止反射和反序列化调用。因为枚举默认是线程安全的,在任何情况下都是单例的。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class SingletonFive {

    // 私有化构造器
    private SingletonFive() {
    }

    // 枚举
    private static enum Singleton{
        INSTANCE;

        private SingletonFive singleton;

        // JVM 会保证此方法绝对只调用一次
        private Singleton() {
            singleton = new SingletonFive();
        }
        public SingletonFive getSingleton() {
            return singleton;
        }
    }

    public static SingletonFive getInstance() {
        return Singleton.INSTANCE.getSingleton();
    }
}

最后再简单说一下如何选择,对于单例对象占用资源少、不需要延时加载的情况,枚举好于饿汉式;对于单例对象占用资源多、需要延时加载的情况,静态内部类要好于懒汉式。

参考