设计模式之单例模式:7种单例设计模式(Java实现)

在经历了秋招的多场面试的毒打之后,我发现Java后端开发方向的许多面试官对设计模式都挺感兴趣。最近结合着汪文君老师的《Java高并发编程详解》书籍,对设计模式进行了一些学习,本博客主要记录一下对于单例模式的学习心得。

单例模式

单例模式是GoF23种设计模式中最常用的设计模式之一,它在日常生活中的应用有任务管理器、回收站等,特点是只能打开一个。单例模式提供了一种在多线程情况下保证实例唯一性的解决方案。
本博客记录七种单例模式的实现方法,分别是:(1)饿汉式;(2)懒汉式;(3)懒汉式+同步方法;(4)Double-Check;(5)Double-Check + Volatile;(6)Holder方法;(7)枚举方式。
为了比较这七种方式,我们可以从线程安全、高性能和懒加载这三个维度去衡量。

饿汉式

饿汉式的单例模式的代码如下:

package Chapter14.Hungry;

// 饿汉式的单例模式的设计
// final不允许被继承
public final class Singleton {

    // 实例变量
    private byte[] data = new byte[1024];

    // 在定义实例对象的时候直接初始化
    private static Singleton instance = new Singleton();

    // 私有构造函数,不允许外部new
    private Singleton() {}

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

其中,Singleton类是使用final修饰的,不允许被继承。data是该类的一个实例变量,占用1KB的空间。接下来的其他方式中也会存在相似的部分。
实现单例模式的重点在于:instance对象、私有构造函数和获取instance对象的方法。下面具体分析一下:
在饿汉式的单例模式下,如果主动使用了Singleton类,instance在类加载的初始化阶段就会直接完成创建,因此该方法在多线程的情况下也能保证同步,因为不可能被实例化两次,不存在线程不安全的情况。
如果一个类的成员属性比较少,且占用的内存资源不多,饿汉式的性能是比较高的(因为getInstance()方法非常简单);否则,饿汉式存在的缺点就被放大,饿汉式也不是较优的选择了。
饿汉式的主要缺点,就是饿汉式不能提供懒加载,在使用Singleton类时就创建了成员属性,一直占用着内存资源。我们更希望能实现懒加载的方式,创建类时可以没有data这些实例变量,直到使用getInstance()方法时再创建。

懒汉式

懒汉式的单例模式的代码如下:

package Chapter14.Lazy;

public final class Singleton {

    private byte[] data = new byte[1024];

    private static Singleton instance = null;

    private Singleton() {}

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

对比一下饿汉式,我们可以发现懒汉式的主要变动就是,在类加载时,instance对象为空,直到getInstance()方法被调用,判断instance是否仍然为空,若为空才进行赋值。这种区别也体现在“饿汉式”与“懒汉式”的名称的区别上,“饿汉”看到东西就会去吃(类加载时就赋值instance),“懒汉”总是把事情拖到最后一刻(调用了getInstance()方法才去赋值instance)。
懒汉式的效能还是比较高的,也可以实现懒加载。但是上述懒汉式的代码并不能保证线程同步。问题出在getInstance()方法中:原instance为null,如果两个线程A和B,A调用getInstance(),执行instance = new Singletion()需要一定时间;在这段时间中,B也调用了getInstance(),也要去执行instance = new Singletion(),这样instance就被实例化了两次,线程不同步了。

懒汉式+同步方法

为了解决上述懒汉式设计模式中存在的问题,可以强制进行同步,即使用synchronized关键字去修饰getInstance()方法。代码如下:

package Chapter14.LazySynchronized;

public final class Singleton {

    private byte[] data = new byte[1024];

    private static Singleton instance = null;

    private Singleton() {}

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

实际上就是在getInstance()方法前添加了synchronized关键字进行修饰。如此,同步的懒汉式可以保证线程安全,也可以实现懒加载。但是synchronized关键字天生的排他性导致了getInstance()方法在同一时刻只能被一个线程所访问,性能低下。

Double-Check

Double-Check在懒汉式的基础上,提供了一种高效的数据同步策略——首次初始化时加锁,之后则允许多个线程同步调用getInstance()方法获得实例。具体代码如下:

package Chapter14.DoubleCheck;

import java.net.Socket;
import java.sql.Connection;

public final class Singleton {

    private byte[] data = new byte[1024];

    private static Singleton instance = null;

    // conn和socket模拟其他需要初始化的实例变量
    Connection conn;
    Socket socket;

    private Singleton() {
        // 此处需要实例化conn和socket等实例变量
    }

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

}

在这里我们设置了conn和socket实例变量,用来模拟单例模式中一些需要进行初始化设置的实例变量,比如Connection conn需要与数据库进行连接,Socket socket需要建立一个TCP连接等。接下来将会提到Double-Check的问题,就会和这些实例变量有关。
分析一下Double-Check如何实现线程安全:当两个线程A和B同时发现instance == null时,只有线程A有资格进入同步代码块,完成对instance的实例化;等到A释放同步资源后,线程B发现instance != null,就只需要去获取instance即可。
因此Double-Check方式可以保证线程安全,也很高效,同时也支持懒加载。但是Double-Check有个致命的问题,那就是可能会引发空指针异常。下面我们来分析一下为什么会发生异常。
在Singleton构造函数中,我们需要实例化instance本身,同时还要实例化conn和socket这两个资源。假设线程A第一个调用getInstance()方法,此时instance == null,因此A获取到同步资源,对instance进行加载。由于指令会被重排序,在Singleton的构造函数中,有可能instance最先被实例化,而conn和socket等资源后被实例化。在conn和socket被实例化的过程中,又有一个线程B调用了getInstance()方法,它发现instance != null,则获取到了instance,然后又去使用conn或socket,就会产生空指针异常。

Double-Check+Volatile

既然我们分析到了,产生异常的原因在于指令可能会被重排序,导致instance被实例化早于其他实例资源。因此我们可以用volatile关键字去修饰instance,并严格管理Singleton构造函数中各个资源与instance的顺序。具体代码如下:

package Chapter14.DoubleCheckVolatile;

import java.net.Socket;
import java.sql.Connection;

public final class Singleton {

    private byte[] data = new byte[1024];

    // 加volatile关键字保证有序性,防止指令重排序
    private volatile static Singleton instance = null;

    Connection conn;

    Socket socket;

    private Singleton() {
        // 先实例化conn和socket等实例变量
        // 最后实例化instance
    }

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

由此,我们实现了一种满足线程安全、高性能、懒加载的单例模式。不过这种方式用得还是比较少,更常见的是下面两种单例模式。

Holder方式

Holder方式的单例模式的代码如下:

package Chapter14.Holder;

public final class Singleton {

    private byte[] data = new byte[1024];

    private Singleton() {}

    // 在静态内部类中持有Singleton的实例,并且可被直接初始化
    private static class Holder {
        private static Singleton instance = new Singleton();
    }

    // 调用getInstance()方法,实际上是获得Holder的instance静态属性
    public static Singleton getInstance() {
        return Holder.instance;
    }
}

Holder方式完全借助了类加载的特性。在Singleton类中并没有instance的静态成员变量,只有在其静态内部类Holder中有。因此,在Singleton类初始化时,并不会创建Singleton的实例。只有在getInstance()方法第一次被调用时,Singleton的实例instance才会被创建,由此实现了懒加载。另外,这个创建过程是在Java编译时期收集至<clinit>()方法(JVM的一个内部方法,在类加载的初始化阶段起作用)中的。这个方法是同步方法,保证内存的可见性、JVM指令的原子性和有序性。
因此Holder方式可以保证线程安全、高性能和懒加载,它也是单例模式中最好的设计之一,使用非常广泛。

枚举方式

枚举方式是《Effective Java》作者力推的方式,在很多优秀开源代码中经常可以看到枚举方式的例子。枚举方式的示例代码如下:

package Chapter14.Enum;

public enum Singleton {
    INSTANCE;

    private byte[] data = new byte[1024];

    Singleton() {
        System.out.println("INSTANCE will br initialized immediately");
    }

    public static void method() {
        // 调用该方法将会主动使用Singleton, INSTANCE将会被初始化
    }

    public static Singleton getInstance() {
        return INSTANCE;
    }

}

枚举方式不能被继承,只能实例化一次,同样也是线程安全,且高效的。但是枚举方式不能懒加载,第一次调用Singleton枚举时,instance就会被实例化。
可以根据Holder方式,对枚举方式进行改造,改造后的代码如下:

package Chapter14.EnumLazyLoad;

public class Singleton {

    private byte[] data = new byte[1024];

    private Singleton() {}

    private enum EnumHolder {
        INSTANCE;

        private Singleton instance;

        EnumHolder() {
            this.instance = new Singleton();
        }

        private Singleton getSingleton() {
            return instance;
        }
    }

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

各种单例模式的对比与总结

下表展示了各种单例模式在线程安全、高性能、懒加载维度的一些比较:

线程安全高性能懒加载其他
饿汉式×
懒汉式×
懒汉式+同步方法×
Double-Check会引发空指针异常
Double-Check+Volatile
Holder方式
枚举方式×可以添加Holder方式实现懒加载
枚举方式+Holder方式

最后,《Java高并发编程详解》的作者汪文君老师指出,在实际开发中,Holder方式和枚举方式是最常见的设计单例的方式。
我们可以多多学习、应用,最终完全掌握单例模式,并进一步学习其他的设计模式。在开发中,各种设计模式还是很重要的。


版权声明:本文为DTDanteDong原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接和本声明。