如何写一个安全的单例模式

335 阅读10分钟

这是我参与 8 月更文挑战的第 5 天,活动详情查看: 8月更文挑战

模式介绍

模式要义 确保一个类只有一个实例,并同一个全局访问点

单件模式 Singleton Pattern 是我们常用并且十分简单的一种设计模式,当我们要求在程序中只能有唯一的实例时,就可以使用单件模式设计。例如注册表、使用日志、打印机驱动等只能有一个实例的对象,如果制造出多个对象会引发一系列的问题。

Java 通过 new 关键字来在堆中创建新的对象,我们要有唯一对象就要保证这个类只被 new 过一次,然后每次的使用该类对象时都获得的是该对象。设想当一个类的构造函数被私有化,那么唯一可以调用改构造函数的方法只能存在于类内部,这样我们就保证了不会在外部创建该类对象。既然这个对象是类的唯一对象,从类被实例化到卸载使用的都是该对象,我们可以在类中声明一个静态的实例对象,这样该对象在类加载时就会在方法区开辟空间,到类卸载后释放。

单件模式的两种类型

饿汉式

饿汉式提前实例化出单例对象,在需要的时候直接返回这个对象

public class SingletonWithHungry extends AbstractSingleton{

    private static final SingletonWithHungry INSTANCE = new SingletonWithHungry();

    private SingletonWithHungry() {
    }
    public static SingletonWithHungry getInstance() {
        return INSTANCE;
    }
}

懒汉式

懒汉式避免了提前实例化的造成的资源浪费,在第一次获取时完成实例化

public class SingletonWithDoubleCheck extends AbstractSingleton {

    private static SingletonWithDoubleCheck singletonWithDoubleCheck;
    private SingletonWithDoubleCheck() {}

    public static SingletonWithDoubleCheck getInstance() {
        if (singletonWithDoubleCheck == null) {
            singletonWithDoubleCheck = new SingletonWithDoubleCheck();
        }
        return singletonWithDoubleCheck;
    }
}

改进

多线程下的懒汉式

当启用多线程时,两个线程同时获取实例化的对象,第一条线程判断 instance == null 后,第二条线程也马上进入该语句判断,两条线程获得的结果都为 true,这样就会创造两个实例对象,这样使得单例失败。多线程的不稳定主要是由于在实例化是延迟加载的,假若我们可以在线程使用之外就将对象实例化出来,使用时不创建,而只是返回已有的实例对象即 饿汉式,这样就可以避免多线程引发的多对象异常。但直接实例化的方式丢失了延迟实例化带来的资源节约的好处。

在 Java 中,为解决线程冲突问题,常用 synchronized 线程锁来进行线程的同步。将 getInstance 修改成线程同步方法,以 this 作为它的锁对象,这样保证在一条线程执行该方法时,其他线程不会同时执行。但这样有一个线程进入这个方法后,其他线程都必须做等待,即使对象已经被实例化了。这会让线程阻塞时间过长,影响系统的性能。

public static synchronized SingletonWithDoubleCheck getInstance() {
  if (singletonWithDoubleCheck == null) {
    singletonWithDoubleCheck = new SingletonWithDoubleCheck();
  }
  return singletonWithDoubleCheck;
}

使用双重校验锁

要改进线程安全的懒汉式,需要使所有线程都能非阻塞调用 getInstance() 方法,这样我们应该只在实例化对象时加锁。要注意的是,假若第一次使用时 Thread A 和 Thread B 同时进入第一个 if 判断语句,如果没有第二个 if 判断语句,那么两个线程都会 new SingletonWithDoubleCheck() ,只是执行顺序的先后问题,因此需要双重校验锁。

另外注意对于双重校验锁必须使用 volatile 关键字修饰实例对象。对于singletonWithDoubleCheck = new SingletonWithDoubleCheck()这段代码其实是分三步执行的:

  1. 在 Heap 中为 singletonWithDoubleCheck 分配内存空间
  2. 初始化 singletonWithDoubleCheck
  3. 将 singletonWithDoubleCheck 指向分配的内存地址

但由于JVM 指令重排的特性,执行的顺序可能会变成 1 3 2,这样在多线程中可能会导致一个线程获得还没有初始化的实例,比如当 T1 刚执行了 1 和 3,此时 T2 调用 getInstance() 方法时就会得到一个还没有初始化的对象。这就是著名的 DCL 失效问题,在双重校验锁中使用 volatile 关键字由 Doug Lea 提出的,由于 Java 内存模型的改进使该方法可行。

public class SingletonWithDoubleCheck extends AbstractSingleton {

    /**
     * 使用 volatile 关键字禁止 JVM 重排指令
     */
    private volatile static SingletonWithDoubleCheck singletonWithDoubleCheck;
    
    private SingletonWithDoubleCheck() {}

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

静态内部类实现

静态内部类的特点是当外部类加载时不会立即加载内部类,因此内部类的对象不会被立即实例化,这样我们就可以将对象在静态内部类中初始化,使得只有一个实例对象、对象延迟初始化并且由 JVM 实现线程安全。

public class SingletonWithStaticInnerClass extends AbstractSingleton{

    private SingletonWithStaticInnerClass() {}

    /**
     * 静态内部类
     */
    private static class SingletonInstance {
        private static final SingletonWithStaticInnerClass INSTANCE = new SingletonWithStaticInnerClass();
    }

    public static SingletonWithStaticInnerClass getInstance() {
        return SingletonInstance.INSTANCE;
    }
}

其线程安全的实现方式与 JVM 的设计有关,它会保证一个类的 () 方法可以在多线程的环境中被正确的加锁和同步。如果有多个线程同时初始化一个类,那么只会有一个线程会执行这个类的 () 方法,其他线程都需要等待。如果一个类的的 () 方法耗时过长,可能会造成多个线程阻塞,但往往是很隐蔽的。可以看出INSTANCE在创建过程中是线程安全的,所以说静态内部类形式的单例可保证线程安全,也能保证单例的唯一性,同时也延迟了单例的实例化。但这种方法得到的对象不能由外部传入参数。

枚举实现

Enum 是由 Class 实现的,其中可以包括成员变量和成员方法,并且有且仅有 private 构造器,防止额外的构造,与 SingletonPattern 中单例类构造类似,因此可以直接用 Enum 来实现单例模式。使用枚举实现在多次进行对象序列化和反序列化后仍然得到同一个实例对象。

public enum  SingletonWithEnum {
    INSTANCE;

    SingletonWithEnum(){}
}

反射攻击和反序列化攻击

反射攻击

我们来测试一下单例模式是否能抗住反射攻击,首先实现一个反射攻击的工具类

/**
 * 反射攻击器
 */
public class SingleReflectAttacker {

    /**
     * 反射攻击器
     * @param classType 使用这个类通过反射拿到对象
     * @param instance 实例对象
     * @return 攻击成功为true
     */
    public static boolean attackWithReflect(Class<?> classType, AbstractSingleton instance) {
        Constructor<?> c;
        Object instanceFromReflect = null;
        try {
            c = classType.getDeclaredConstructor();
            c.setAccessible(true);
            instanceFromReflect = c.newInstance();
        } catch (NoSuchMethodException | IllegalAccessException | InstantiationException | InvocationTargetException e) {
            e.printStackTrace();
        }
        return return instanceFromReflect != null && instance != instanceFromReflect;
    }

    public static boolean attackWithReflect(Class<?> classType, SingletonWithEnum instance) {
        attackWithReflectMethod1(classType);
        attackWithReflectMethod2(classType);
        return true;
    }

    public static void attackWithReflectMethod1(Class<?> classType) {
        try {
            Class.forName(classType.getName()).newInstance();
        } catch (InstantiationException | IllegalAccessException | ClassNotFoundException e) {
            e.printStackTrace();
        }
    }

    public static void attackWithReflectMethod2(Class<?> classType) {
        try {
            Constructor[] constructors = classType.getDeclaredConstructors();
            for (Constructor c : constructors) {
                c.setAccessible(true);
                c.newInstance();
            }
        } catch (IllegalAccessException | InstantiationException | InvocationTargetException e) {
            e.printStackTrace();
        }
    }
}

首先测试饿汉式

boolean result;
/**
	* 普通饿汉式
	*/
result = SingleReflectAttacker.attackWithReflect(SingletonWithHungry.class,
                                                 SingletonWithHungry.getInstance());
System.out.println("【饿汉式】反射攻击结果: " + result);

结果

【饿汉式】反射攻击结果: true

这说明普通的饿汉式无法阻拦反射攻击

如何改进

反射是通过调用构造器来产生新对象的,那么我们在构造方法上增加一层判断,如果对象不为NULL,说明要产生新的对象,抛出异常

/**
	* 改进构造器
	*/
private SingletonWithHungrySafe() {
  synchronized (SingletonWithHungrySafe.class) {
    if (INSTANCE != null) {
      throw new RuntimeException("单例模式被破坏");
    }
  }
}

使用反射攻击测试

result = SingleReflectAttacker.attackWithReflect(SingletonWithHungrySafe.class, SingletonWithHungrySafe.getInstance());
System.out.println("【安全的饿汉式】反射攻击的结果: " + result);

结果

【安全的饿汉式】反射攻击的结果: false
java.lang.reflect.InvocationTargetException
at sun.reflect.NativeConstructorAccessorImpl.newInstance0(Native Method)
at sun.reflect.NativeConstructorAccessorImpl.newInstance(NativeConstructorAccessorImpl.java:62)
at sun.reflect.DelegatingConstructorAccessorImpl.newInstance(DelegatingConstructorAccessorImpl.java:45)
at java.lang.reflect.Constructor.newInstance(Constructor.java:423)
at com.fafnir.SingletonPattern.SingleReflectAttacker.attackWithReflect(SingleReflectAttacker.java:25)
at com.fafnir.SingletonPattern.SingletonTest.main(SingletonTest.java:18)
Caused by: java.lang.RuntimeException: 单例模式被破坏
at com.fafnir.SingletonPattern.SingletonWithHungrySafe.(SingletonWithHungrySafe.java:19)
... 6 more

这为我们指出了避免反射攻击方法:通过一个状态字段避免重复调用构造方法,例如在双重校验锁中

public class SingletonWithDoubleCheckSafe extends AbstractSingleton {

    private volatile static SingletonWithDoubleCheckSafe singletonWithDoubleCheck;
		/**
     * 控制重复构造
     */
    private static boolean flag;

    private SingletonWithDoubleCheckSafe() {
        synchronized (SingletonWithDoubleCheckSafe.class) {
          	// 如果flag为true说明已经构造过
            if (!flag) {
                flag = true;
            } else {
                throw new RuntimeException("单例模式被破坏");
            }
        }
    }
  	// ...
}

在静态内部类中

public class SingletonWithStaticInnerClass extends AbstractSingleton{

    private static boolean flag;

    private SingletonWithStaticInnerClass() {
        synchronized (SingletonWithDoubleCheckSafe.class) {
            if (!flag) {
                flag = true;
            } else {
                throw new RuntimeException("单例模式被破坏");
            }
        }
    }
  	// ...
}

反序列化攻击

实现一个反序列攻击的工具类

public class SingleDeserializeAttacker {

    public static boolean attackWithDeserialize(AbstractSingleton instance) {

        String path = Constants.PATH + instance.toString();
        AbstractSingleton instanceFromDeserialize;
        try {
            // 写入序列化文件
            ObjectOutputStream outputStream = new ObjectOutputStream(
                    new FileOutputStream(path));
            outputStream.writeObject(instance);
            outputStream.close();
            // 读序列化文件得到对象
            ObjectInputStream inputStream = new ObjectInputStream(new FileInputStream(path));
            instanceFromDeserialize = (AbstractSingleton) inputStream.readObject();
        } catch (IOException | ClassNotFoundException e) {
            System.out.println(e);
            return false;
        }
        return instance != instanceFromDeserialize;
    }


    public static boolean attackWithDeserialize(SingletonWithEnum instance) {

        String path = Constants.PATH + instance.toString();
        SingletonWithEnum instanceFromDeserialize = null;
        try {
            ObjectOutputStream outputStream = new ObjectOutputStream(
                    new FileOutputStream(path));
            outputStream.writeObject(instance);
            outputStream.close();
            ObjectInputStream inputStream = new ObjectInputStream(new FileInputStream(path));
            instanceFromDeserialize = (SingletonWithEnum) inputStream.readObject();
        } catch (ClassNotFoundException | IOException e) {
            System.out.println(e);
            return false;
        }
        return instance != instanceFromDeserialize;
    }
}

反序列化攻击饿汉式

result = SingleDeserializeAttacker.attackWithDeserialize(SingletonWithHungry.getInstance());
System.out.println("【饿汉式】反序列化攻击结果: " + result);

结果

【饿汉式】反序列化攻击结果: true

如何改进:定义readResolve 方法,见单例模式的攻击之序列化与反序列化

public class SingletonWithHungrySafe extends AbstractSingleton {
  	// ...

  	/**
  		* 
  		*/
    private Object readResolve() {
        return INSTANCE;
    }

}

模式结构

单例模式是一个简单实用的设计模式, 在为了获得单例时使用,它主要有以下组成部分

  • 静态实例对象或引用
  • 私有构造函数
  • 静态 getInstance 方法 (全局访问点)

模式分析

单例模式的目的是保证一个类仅有一个实例,并提供一个访问它的全局访问点。单例模式包含的角色只有一个,就是单例类——Singleton。单例类拥有一个私有构造函数,确保用户无法通过 new 关键字直接实例化它。除此之外,该模式中包含一个静态私有成员变量与静态公有的工厂方法,该工厂方法负责检验实例的存在性并实例化自己,然后存储在静态成员变量中,以确保只有一个实例被创建。## 优点

  • 提供了对唯一实例的受控访问。因为单例类封装了它的唯一实例,所以它可以严格控制客户怎样以及何时访问它,并为设计及开发团队提供了共享的概念。
  • 由于在系统内存中只存在一个对象,因此可以节约系统资源,对于一些需要频繁创建和销毁的对象,单例模式无疑可以提高系统的性能。
  • 允许可变数目的实例。可以基于单例模式进行扩展,使用与单例控制相似的方法来获得指定个数的对象实例。

缺点

  • 由于单例模式中没有抽象层,因此单例类的扩展有很大的困难。
  • 单例类的职责过重,在一定程度上违背了“单一职责原则”。因为单例类既充当了工厂角色,提供了工厂方法,同时又充当了产品角色,包含一些业务方法,将产品的创建和产品的本身的功能融合到一起。
  • 滥用单例将带来一些负面问题,如为了节省资源将数据库连接池对象设计为单例类,可能会导致共享连接池对象的程序过多而出现连接池溢出;现在很多面向对象语言 ( 如 Java、C# ) 的运行环境都提供了自动垃圾回收的技术,因此,如果实例化的对象长时间不被利用,系统会认为它是垃圾,会自动销毁并回收资源,下次利用时又将重新实例化,这将导致对象状态的丢失。

适用环境

  • 系统只需要一个实例对象,如系统要求提供一个唯一的序列号生成器,或者需要考虑资源消耗太大而只允许创建一个对象。
  • 客户调用类的单个实例只允许使用一个公共访问点,除了该公共访问点,不能通过其他途径访问该实例。
  • 在一个系统中要求一个类只有一个实例时才应当使用单例模式。反过来,如果一个类可以有几个实例共存,就需要对单例模式进行改进,使之成为多例模式。