04 单例模式的几种实现

单例模式是一种对象创建模式,它用于产生一个对象的具体实例,它可以确保系统中一个类只产生一个实例。Java 里面实现的单例是一个虚拟机的范围,因为装载类的功能是虚拟机的,所以一个虚拟机在通过自己的 ClassLoad 装载实现单例类的时候就会创建一个类的实例。

单例模式的优点:

  • 对于频繁使用的对象,可以省略创建对象所花费的时间,这对于那些重量级对象而言,是非常可观的一笔系统开销;

  • 由于 new 操作的次数减少,因而对系统内存的使用频率也会降低,这将减轻 GC 压力,缩短 GC 停顿时间。

单例模式的核心在于通过一个接口返回唯一的对象实例。首要的问题就是要把创建实例的权限收回来,让类自身来负责自己类的实例的创建工作,然后由这个类来提供外部可以访问这个类实例的方法。

饿汉式

public class Singleton {
    private static Singleton instance = new Singleton();
    private Singleton() {}

    public static Singleton getInsatnce() {
        return instance;
    }
}

饿汉式提前实例化,没有懒汉式中多线程问题,但无法对 instance 实例做延时加载,单例会在加载类后一开始就被初始化,保证该实例只有一个,不管是否调用 getInstance() 都会存在一个实例在内存中。

此外饿汉式的创建方式在一些场景中将无法使用:譬如 Singleton 实例的创建是依赖参数或者配置文件的,在 getInstance() 之前必须调用某个方法设置参数给它,那样这种单例写法就无法使用了。

懒汉式

public class Singleton {
    private static Singleton instance;
    private Singleton() {}

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

懒汉式实现了实例延时加载,但是需要添加 synchronized 来保证线程安全。但这样做并不高效,因为在任何时候只能有一个线程调用 getInstance() 方法。

双重检验锁

双重检验锁模式(double checked locking pattern),是一种使用同步块加锁的方法,可以看作是上面懒汉式的改进版。双重检查锁会有两次检查 instance == null,一次是在同步块外,一次是在同步块内。第一个 if 可以提升效率,为什么在同步块内还要再检验一次?因为可能会有多个线程一起进入同步块外的 if,如果在同步块内不进行二次检验的话就会生成多个实例了。

public class Singleton {
    private static Singleton instance;
    private Singleton() {}

    public static Singleton getSingleton() {
        if (instance == null) {                         // Single Checked
            synchronized (Singleton.class) {
                if (instance == null) {                 // Double Checked
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

双重检验锁看起来很完美,但可惜它有问题。主要在于 instance = new Singleton() 这句,这并非是一个原子操作,事实上在 JVM 中这句话大概做了下面 3 件事情:

  1. 给 instance 分配内存

  2. 调用 Singleton 的构造函数来初始化成员变量

  3. 将 instance 对象指向分配的内存空间(执行完这步 instance 就为非 null 了)

但是在 JVM 的即时编译器中存在指令重排序的优化。也就是说上面的第 2 步和第 3 步的顺序是不能保证的,最终的执行顺序可能是 1-2-3 也可能是 1-3-2。如果是后者,则在 3 执行完毕、2 未执行之前,被线程二抢占了,这时 instance 已经是非 null 了(但却没有初始化),所以线程二会直接返回 instance,然后使用,然后顺理成章地报错。

这时需要将 instance 变量声明成 volatile 就可以了:

public class Singleton {
    private volatile static Singleton instance;
    private Singleton() {}

    public static Singleton getSingleton() {
        if (instance == null) {                         // Single Checked
            synchronized (Singleton.class) {
                if (instance == null) {                 // Double Checked
                    instance = new Singleton();
                }
            }
        }
        return instance ;
    }
}

使用 volatile 的主要原因是其一个特性:禁止指令重排序优化。也就是说,在 volatile 变量的赋值操作后面会有一个内存屏障(生成的汇编代码上),读操作不会被重排序到内存屏障之前。比如上面的例子,取操作必须在执行完 1-2-3 之后或者 1-3-2 之后,不存在执行到 1-3 然后取到值的情况。从「先行发生原则」的角度理解的话,就是对于一个 volatile 变量的写操作都先行发生于后面对这个变量的读操作(这里的“后面”是时间上的先后顺序)。

静态内部类

推荐使用静态内部类的方法,这种方法也是《Effective Java》上所推荐的。

public class Singleton {
    private static class SingletonHolder {
        private static final Singleton INSTANCE = new Singleton();
    }

    private Singleton() {}

    public static final Singleton getInstance() {
        return SingletonHolder.INSTANCE;
    }
}

这种写法使用 JVM 本身机制保证了线程安全问题;由于 SingletonHolder 是私有的,除了 getInstance() 之外没有办法访问它,因此它是懒汉式的;同时读取实例的时候不会进行同步,没有性能缺陷;也不依赖 JDK 版本。

枚举

同样也是《Effective Java》推荐的写法,它不仅能避免多线程同步问题,而且还能防止反序列化重新创建新的对象,但使用较少。

public enum Singleton {
    INSTANCE;

    public void whateverMethod() {
    }
}

调用:

Singleton.INSTANCE.whateverMethod();

参考文章: Java 编程设计模式-单例模式 如何正确地写出单例模式

最后更新于