【Design Pattern】Creational - Singleton

Posted by 西维蜀黍 on 2018-11-11, Last Modified on 2022-12-10

单例模式(Singleton Pattern)

动机

在涉及同步问题的日志模块、缓存模块、多线程或线程池设计过程中,对于系统中的某些类来说,只有一个实例很重要。

如何保证一个类只有一个实例?定义一个全局变量并声明一个public方法以提供访问该全局变量的接口,可以确保该全局变量可被任何对象访问到,但缺不一定能保证该全局变量只被实例化一次。

一个更好的解决方案是让这个类自己负责该全局变量的实例化工作,并提供一个访问该全局变量的接口。这就是单例模式的动机。

定义

单例模式(Singleton Pattern)确保某一个类只有一个实例,且由这个类本身管理实例实例化的过程,并提供一个接口以允许整个系统访问这个实例,这个类称为单例类,

特点

  • 某个类只有一个实例
  • 类自身管理实例实例化的过程
  • 提供接口以允许整个系统访问这个实例

构成

  • 单例模式只包含一个角色,即单例类自身
  • 单例类包含一个私有(private)的构造函数,以保证用户无法通过new关键字直接实例化它
  • 单例类还包含一个静态(static)私有成员变量,和一个静态的公有(public)方法
  • 该静态公有方法提供对单例对象的全局访问,并负责检查实例的存在性(若不存在则实例化单例对象,并保存在私有成员变量中),以保证整个系统中只有一个实例被创建了

示例

错误的实现[Bad implementation] - 懒汉式,线程不安全

当被问到要实现一个单例模式时,很多人的第一反应是写出如下的代码,包括教科书上也是这样教我们的。

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

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

这段代码简单明了,而且使用了懒加载模式,但是却存在致命的问题。

在多线程情况下,如果多个线程同时调用getInstance()的话,那么,可能会有多个进程同时通过 (singleton== null)的条件检查,于是,多个实例(通过new Singleton())就创建出来,并且很可能造成内存泄露问题。嗯,熟悉多线程的你一定会说——“我们需要线程互斥或同步”。

待优化的实现[Bad implementation] - 懒汉式,线程安全

为了解决上面的问题,最简单的方法是将整个 getInstance() 方法设为同步(synchronized)。

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

虽然做到了线程安全,并且解决了多实例的问题,但是它并不高效(虽然它的实现是完成正确的)。因为在任何时候只能有一个线程调用 getInstance() 方法。但是同步操作只需要在第一次调用时才被需要,即第一次创建单例实例对象时。这就引出了双重检验锁。

正确的实现

双重检验锁(double-check locking)

双重检验锁模式(double checked locking pattern),是一种使用同步块加锁的方法。程序员称其为双重检查锁,因为会有两次检查 instance == null,一次是在同步块外,一次是在同步块内。为什么在同步块内还要再检验一次?因为可能会有多个线程一起进入同步块外的 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. 给 Singleton对象实体在堆(heap)中分配内存
  2. 调用 Singleton 的构造函数来初始化Singleton对象实体的成员变量
  3. 将instance对象指向分配的内存空间(执行完这步 instance 就为非 null 了)

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

我们只需要将 instance 变量声明成 volatile 就可以了。

public class Singleton {
    private volatile static Singleton instance; //声明成 volatile
    private Singleton (){}

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

使用 volatile 有两个功用:

1)这个变量不会在多个线程中存在复本,直接从内存读取。

2)这个关键字会禁止指令重排序优化。也就是说,在 volatile 变量的赋值操作后面会有一个内存屏障(生成的汇编代码上),读操作不会被重排序到内存屏障之前。

下面一个程序用来尝试证明我的想法:

public class SingletonTest {
    /**
     * 使用volatile防止指令重排序的问题(singletonTest = new SingletonTest();)
     * 不加volatile关键字也没有测试出来问题!!!!!
     */
    public static SingletonTest singletonTest;
    private int a;

    private SingletonTest() {
        a = 1;
    }

    /**
     * synchronized双重锁机制
     */
    public static SingletonTest getInstance() {
        if (singletonTest == null) {
            synchronized (SingletonTest.class) {
                if (singletonTest == null) {
                    /*
                     * new操作非原子操作,分为3步:
                     * 1.为singletonTest分配分配内存
                     * 2.调用SingletonTest的构造函数初始化成员变量
                     * 3.将singletonTest指向分配的内存空间(执行完此步骤singletonTest对象就非null了)
                     * 若不加volatile关键字,2、3两步执行顺序不确定。按照1-3-2的顺序执行则可能会return未初始化的对象
                     * 时间 线程1 线程2
                     * t1 1
                     * t2 3
                     * t3 外层if (singletonTest == null)成立,直接return 未初始化的对象;
                     * t4 2
                     */
                    singletonTest = new SingletonTest();
                }
            }
        }
        return singletonTest;
    }

    /**
     * 测试函数
     */
    public int getA() {
        return a;
    }

    /**
     * 启动1000个线程测试
     */
    public static void main(String[] args) throws InterruptedException {

        for (int j = 0; j < 10; j++) {
            Thread[] threads = new Thread[10000];
            CountDownLatch countDown = new CountDownLatch(10000);
            for (int i = 0; i < threads.length; i++) {
                threads[i] = new Thread(new Runnable() {
                    @Override
                    public void run() {
                        countDown.countDown();
                        SingletonTest instance = SingletonTest.getInstance();
                        if (instance.getA() != 1) {
                            System.err.println("error");
                        }
                    }
                });
            }
            for (int i = 0; i < threads.length; i++) {
                threads[i].start();
            }

            Thread.sleep(1000);
            SingletonTest.singletonTest = null;
            SingletonTest s = SingletonTest.singletonTest;
        }
    }
}

但是特别注意,在 Java 5 以前Java的版本中,如果使用 volatile 的双检锁还是会出现问题的。其原因是 Java 5 以前的 JMM (Java 内存模型)是存在缺陷的,即时将变量声明成 volatile 也不能完全避免重排序,主要是 volatile 变量前后的代码仍然存在重排序问题。这个 volatile 屏蔽重排序的问题在 Java 5 中才得以修复,所以在这之后才可以放心使用 volatile。

存疑

个人认为,其实不需要加 volatile 关键字,也是能够保证这个单例模式的正确性的。

原因在于,synchronized不仅能够保证原子性,同时保证可见性和有序性。即在结束synchronized代码块时,会把在synchronized代码块中进行的所有修改操作同步到主存中。

换句话说,只要在synchronized代码块中进行修改的变量,都隐式地声明为了volatile变量。

饿汉式单例类 static final field

这种方法非常简单,因为单例的实例被声明成 static 和 final 变量了,在Singleton类被第一次加载到内存中时,instance实例就会被初始化,所以创建实例本身是线程安全的。

public class Singleton{
    //类加载时就初始化
    private static final Singleton instance = new Singleton();
    
    private Singleton(){}

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

private static final Singleton instance的声明和private的Singleton构造函数保证了当Singleton类被加载时,instance引用对象被赋值(而且只有这一次赋值机会)。

这种写法比较完美的话,唯一的缺点是它不是一种懒加载模式(lazy initialization),单例会在加载类后一开始就被初始化,即使客户端没有调用 getInstance()方法。饿汉式的创建方式在一些场景中将无法使用:譬如 Singleton 实例的创建是依赖参数或者配置文件的,在 getInstance() 之前必须调用某个方法设置参数给它,那样这种单例写法就无法使用了。

静态内部类 static nested class

我比较倾向于使用静态内部类的方法,这种方法也是《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本身的类加载机制来保证线程安全。

分析1

  1. 由于 SingletonHolder 是私有的,除了 getInstance() 之外没有办法访问到它,因此它是懒加载的(lazy initialization);同时读取实例的时候不会进行同步,没有性能缺陷;也不依赖 JDK 版本。
  2. 从外部无法访问静态内部类SingletonHolder,只有当调用Singleton.getInstance方法的时候,才能得到单例对象INSTANCE。
  3. INSTANCE对象初始化的时机,并不是在单例类Singleton被加载的时候,而是在第一次调用Singleton.getInstances()方法的时候,这个调用使得静态内部类SingletonHolder被加载,且此时INSTANCE引用对象被幅值。因此,这种实现方式是利用Classloader的加载机制来实现懒加载,并保证构建单例的线程安全。

分析2

不知道你有没有注意到,上面的三种实现方法都包含了一个private的构造函数。因此,我们是不是就能保证无法创建多个类的实例了呢?

答案是否定的,即我们仍然有其他的高阶方法来创建多个类的实施,以破解单例模式。

  • 序列化(serialization)/反序列化(deserializations)
  • 反射(reflection)
序列化(serialization)/反序列化(deserializations)问题

序列化可能会破坏单例模式。

通过比较一个序列化后的对象实例和其被反序列化后的对象实例,我们发现他们不是同一个对象,换句话说,在反序列化时会创建一个新的实例(即使定义构造函数为private)。

public class Example {
    public static void main(String[] args) {
        Singleton singleton = Singleton.getInstance();
        singleton.setValue(1); // Serialize
        try {
            FileOutputStream fileOut = new FileOutputStream("out.ser");
            ObjectOutputStream out = new ObjectOutputStream(fileOut);
            out.writeObject(singleton);
            out.close();
            fileOut.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
        singleton.setValue(2); // Deserialize

        Singleton singleton2 = null;
        try {
            FileInputStream fileIn = new FileInputStream("out.ser");
            ObjectInputStream in = new ObjectInputStream(fileIn);
            singleton2 = (Singleton) in.readObject();
            in.close();
            fileIn.close();
        } catch (IOException i) {
            i.printStackTrace();
        } catch (ClassNotFoundException c) {
            System.out.println("singletons.SingletonEnum class not found");
            c.printStackTrace();
        }

        if (singleton == singleton2) {
            System.out.println("Two objects are same");
        } else {
            System.out.println("Two objects are not same");
        }
        System.out.println(singleton.getValue());
        System.out.println(singleton2.getValue());
    }
}

// 我们这里只是以 饿汉式 static final field的实现为例,但上面三种实现都有类似的问题
class Singleton implements java.io.Serializable  {
    //类加载时就初始化
    private static final Singleton instance = new Singleton();
    private int value;

    private Singleton() {
    }

    public static Singleton getInstance() {
        return instance;
    }

    public int getValue() {
        return value;
    }

    public void setValue(int i) {
        value = i;
    }
}

结果:

Two objects are not same
2
1
反射(reflection)

类似地,使用反射(reflection)可以强行调用私有构造器。

public static void main(String[] args) throws Exception {
    Singleton singleton = Singleton.INSTANCE;
  
    Constructor constructor = singleton.getClass().getDeclaredConstructor(new Class[0]);
    constructor.setAccessible(true);
    Singleton singleton2 = (Singleton) constructor.newInstance();
  
    if (singleton == singleton2) {
        System.out.println("Two objects are same");
    } else {
        System.out.println("Two objects are not same");
    }
    singleton.setValue(1);
    singleton2.setValue(2);
    System.out.println(singleton.getValue());
    System.out.println(singleton2.getValue());
}

结果:

Two objects are not same
1
2
解决

对于序列化时破坏单例模式的问题,我们虽然可以通过以下方式来避免:

//测试例子(四种写解决方式雷同)
public class Singleton implements java.io.Serializable {     
   public static Singleton INSTANCE = new Singleton();     

   protected Singleton() {     
   }  

   //反序列时直接返回当前INSTANCE
   private Object readResolve() {     
   		return INSTANCE;     
   }    
}   

因为,在反序列化过程中,如果被序列化的类中定义了readResolve 方法,虚拟机会试图调用对象类里的 readResolve 方法,以进行用户自定义的反序列化。

最终,实现了在序列化/反序列化过程中也不破坏单例模式。


类似地,对于反射时破坏单例模式的问题,我们虽然也可以通过以下方式来避免:

public static Singleton INSTANCE = new Singleton();     
private static volatile  boolean  flag = true;
private Singleton(){
    if(flag){
    		flag = false;   
    }else{
        throw new RuntimeException("The instance  already exists !");
    }
}

但是,问题确实也得到了解决,但这样的解决方案使得代码并不优美。

那有没有更简单更高效的呢?当然是有的,那就是枚举单例了。

枚举 Enum

用枚举写单例实在太简单了!这也是它最大的优点。下面这段代码就是声明枚举实例的通常做法。

public enum SingletonEnum {
    INSTANCE;
    int value;

    public int getValue() {
        return value;
    }

    public void setValue(int value) {
        this.value = value;
    }
}

调用

public class EnumDemo {
    public static void main(String[] args) {
        SingletonEnum singleton = SingletonEnum.INSTANCE;
        System.out.println(singleton.getValue());
        singleton.setValue(2);
        System.out.println(singleton.getValue());
    }
}

我们可以通过EasySingleton.INSTANCE来访问实例,这比调用getInstance()方法简单多了。

由于默认枚举实例的创建是线程安全的,所以不需要担心线程安全的问题。但是在枚举中的其他任何方法的线程安全由程序员自己负责。还有防止上面的通过反射机制调用私用构造器。

这个版本基本上消除了绝大多数的问题。代码也非常简单,实在无法不用。这也是新版的《Effective Java》中推荐的模式。

分析

事实上,一个 enum类型是一个特殊的class类型。一个enum声明(declaration)实际上会被编译成这样:

final class SingletonEnum extends Enum
{
		...

    public static final SingletonEnum INSTANCE;
    static 
    {
        INSTANCE = new SingletonEnum("INSTANCE", 0);
        $VALUES = (new SingletonEnum[] {
            INSTANCE
        });
    }
}

当你的代码第一次访问SingletonEnum.INSTANCE时,SingletonEnum会被JVM加载并且初始化。

总结

以实例实例化的时间作为区分,可分为加载时实例化(Early Instantiation)懒实例化(Lazy Instantiation)

  • 加载时实例化(Early Instantiation):在整个系统开始运行时,就进行对象的实例化
  • 懒实例化(Lazy Instantiation):直到该对象被第一次访问时,才进行实例化

优缺点

优点

  • 由于在系统内存中只存在一个对象,因此可以节约系统资源,对于一些需要频繁创建和销毁的对象,单例模式无疑可以提高系统的性能

缺点

  • 单例类的职责过重,在一定程度上违背了“单一职责原则”(Single Responsibility Principle)。因为单例类既充当了工厂角色(提供一个静态的公共方法以允许任何对象访问这个实例,即工厂方法),同时又充当了产品角色(即包含一些属于非静态的业务方法),最终将产品的创建和产品的本身的功能融合到一起。
  • 滥用单例将带来一些负面问题,如为了节省资源将数据库连接池对象设计为单例类,可能会导致共享连接池对象的程序过多而出现连接池溢出。

适用场景

在以下场景下可以考虑使用单例模式:

  • 在整个系统中,只能存在一个实例对象,如资源消耗较大的对象、配置管理类等;
  • 在整个系统中,要求一个类只有一个实例。

模式应用

打印池

在操作系统中,打印池(Print Spooler)是一个用于管理打印任务的应用程序,通过打印池用户可以删除、中止或者改变打印任务的优先级,在一个系统中只允许运行一个打印池对象,如果重复创建打印池则抛出异常。

因此可使用单例模式来实现打印池。

ID生成器

在一个系统中,我们可能会使用到一个ID生成器,比如订单系统。我们需要保证其生成的每一个ID都是唯一且不会重复的。

为此,我们可以把这个IdGenerator设计成一个单例模式。

ConfigManager

对于一个系统而言,通常我们可能会设置一个配置管理中心,以管理一些对该系统的全局设置。

为此,我们可以把这个ConfigManager设计成一个单例模式,以保证这些设置都是全局唯一的,而不会出现一个设置项,在一个系统中,有两个不同的值的情况。

Reference