创建型模式之单例模式

in HandbookDesign Patterns with 0 comment, viewed 843 times

1 概述

单例模式(Singleton Pattern)应该是最简单,同时又是最复杂的一种创建型模式。因为大家都知道这个模式:无非就是保证某个对象在系统中只存在一个实例。然而想要真正实现一个完美的单例模式,却不简单。

2 单例模式

一般单例模式的实现,都需要包含两个步骤:

  1. 将类的构造函数私有化。
  2. 提供一个public的方法,以供外界获取唯一的实例。

下面将一一介绍单例模式的各种实现方式。

3 案例

3.1 注册表式

提供一个注册表类,来维护所有单例的实例

public class Test {
    public static void main(String[] args) {
        SampleClass singleton1 = Registry.getInstance(SampleClass.class);
        SampleClass singleton2 = Registry.getInstance(SampleClass.class);
        System.out.println("Registry singleton instance1: " + singleton1.hashCode());
        System.out.println("Registry singleton instance2: " + singleton2.hashCode());

        System.out.println("We can broke singleton by new a instance through class's construct method");
        SampleClass singleton3 = new SampleClass();
        System.out.println("Registry singleton instance3: " + singleton3.hashCode());
    }
}

public class Registry {
    private static Map<Class, Object> registry = new ConcurrentHashMap<>();
    private Registry() {};
    public static synchronized <T> T getInstance(Class<T> type) {
        Object obj = registry.get(type);
        if (obj == null) {
            try {
                obj = type.newInstance();
            } catch (IllegalAccessException | InstantiationException e) {
                e.printStackTrace();
            }
            registry.put(type, obj);
        }
        return (T) obj;
    }
}

public class SampleClass {
}

输出:

Registry singleton instance1: 21685669
Registry singleton instance2: 21685669
We can broke singleton by new a instance through class's construct method
Registry singleton instance3: 2133927002

注册表实现的单例其实是伪单例,因为它只能保证从注册表中获取的对象是全局唯一的。如果我们不从注册表获取,而是直接new一个实例,这显然破坏了单例模式。我们熟悉的Spring框架,就是用这种模式实现的单例,其中的Registry就是BeanFactory

要从根本上实现实例的全局唯一,我们必须在单例类本身下功夫。

3.1 饿汉式----静态属性

将实例作为类的一个静态变量,来实现唯一性:

public class StaticFieldTest {
    public static void main(String[] args) {
        StaticFieldSingleton fieldSingleton1 = StaticFieldSingleton.getInstance();
        StaticFieldSingleton fieldSingleton2 = StaticFieldSingleton.getInstance();
        System.out.println("StaticFieldSingleton instance1: " + fieldSingleton1.hashCode());
        System.out.println("StaticFieldSingleton instance1: " + fieldSingleton2.hashCode());
    }
}

public class StaticFieldSingleton {
    private static StaticFieldSingleton singletonInstance = new StaticFieldSingleton();

    // 将构造方法私有化
    private StaticFieldSingleton(){};

    // 提供唯一的接口,供外部获取唯一的变量
    public static StaticFieldSingleton getInstance() {
        return singletonInstance;
    }
}

输出:

StaticFieldSingleton in multi-thread instance: 837048303
StaticFieldSingleton in multi-thread instance: 837048303

当类StaticFieldSingleton被加载进JVM的时候,类的实例会作为类的静态属性,随着类一起初始化。这种实现方式其实是依靠类加载器来保证实例的唯一性。优点是,不需要考虑多线程加锁,实现起来比较简单。缺点是,无论后续是否会用到,实例都会在class被加载的时候被创建好。这对于内存资源比较宝贵的场景,或者目标是某些如File System的大对象的时候,会导致资源的浪费。同时,这种方式也无法提供对异常的处理,在某些情况下,会导致程序出错。

3.2 饿汉式----静态块

将类实例的初始化放在类的静态块中:

public class StaticBlockTest {
    public static void main(String[] args) {
        for (int i = 0; i < 2; i++) {
            new Thread(() -> {
                StaticBlockSingleton staticBlockSingleton = StaticBlockSingleton.getInstance();
                System.out.println("StaticBlockSingleton in multi-thread instance: " + staticBlockSingleton.hashCode());
            }).start();
        }
    }
}

public class StaticBlockSingleton {
    private static StaticBlockSingleton singletonInstance;

    private StaticBlockSingleton(){};

    // 静态块会在类被加载进内存的时候被执行
    static {
        try {
            singletonInstance = new StaticBlockSingleton();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    public static StaticBlockSingleton getInstance() {
        return singletonInstance;
    }
}

输出:

StaticBlockSingleton in multi-thread instance: 2132107705
StaticBlockSingleton in multi-thread instance: 2132107705

静态块中初始化与静态变量上初始化本质上是一样的,都是通过类加载器来保证实例只会被初始化一次。区别是,静态块初始化可以做异常的捕获与处理,同时还允许我们在静态块中做一些额外的事情,比静态变量的方式更自由。

但两种饿汉式都不可避免地会造成额外内存的占用,于是出现了按需加载懒汉式创建方式。

3.3 懒汉式----基础版

将类实例的初始化放在方法中。只有当方法第一次被访问的时候,去初始化实例:

public class SynchronizedTest {
    public static void main(String[] args) {
        for (int i = 0; i < 2; i++) {
            new Thread(() -> {
                SynchronizedSingleton synchronizedSingleton = SynchronizedSingleton.getInstance();
                System.out.println("SynchronizedSingleton in multi-thread instance: " + synchronizedSingleton.hashCode());
            }).start();
        }
    }
}

public class SynchronizedSingleton {
    private static SynchronizedSingleton singletonInstance;

    private SynchronizedSingleton(){};

    // 加了同步锁,保证new SynchronizedSingleton()只会被第一个线程访问
    public static synchronized SynchronizedSingleton getInstance() {
        if (singletonInstance == null) {
            singletonInstance = new SynchronizedSingleton();
        }
        return singletonInstance;
    }
}

输出:

SynchronizedSingleton in multi-thread instance: 554449003
SynchronizedSingleton in multi-thread instance: 554449003

懒汉式解决了饿汉式存在的最大问题:可能导致的内存浪费。只有当getInstance()方法第一次被访问的时候,实例才会去真正创建。而方法上加了synchronized,保证了后续对方法的访问,都只会返回之前创建好的实例,保证了唯一性。
这种方式的不足是,每次对getInstance()方法的访问,都需要获取锁,众所周知,锁的获取与释放是一笔昂贵的开销。而事实上只有当第一次实例创建的时候需要加锁。于是有了改进的方式:双检锁

3.4 懒汉式----双检锁

双检锁(Double Check Lock)是一个很多人都熟悉的概念,是上述模式的增强版。实现如下:

public class DoubleCheckLockTest {
    public static void main(String[] args) {
        for (int i = 0; i < 2; i++) {
            new Thread(() -> {
                DoubleCheckLockSingleton doubleCheckLockSingleton = DoubleCheckLockSingleton.getInstance();
                System.out.println("DoubleCheckLockSingleton in multi-thread instance: " + doubleCheckLockSingleton.hashCode());
            }).start();
        }
    }
}

public class DoubleCheckLockSingleton {
    // 变量必须声明为volatile,否则可能会得到一个“半初始化”的实例
    private static volatile DoubleCheckLockSingleton singletonInstance;

    private DoubleCheckLockSingleton(){};

    // 若实例已经被创建,则不需要再进入同步块
    // 若实例还没创建,则在同步块中检查并创建实例
    public static DoubleCheckLockSingleton getInstance() {
        DoubleCheckLockSingleton instance = singletonInstance;
        if (instance == null) {
            synchronized (DoubleCheckLockSingleton.class) {
                instance = singletonInstance;
                if (instance == null) {
                    instance = singletonInstance = new DoubleCheckLockSingleton();
                }
            }
        }
        return instance;
    }
}

输出:

DoubleCheckLockSingleton in multi-thread instance: 837048303
DoubleCheckLockSingleton in multi-thread instance: 837048303

单例模式中,水最深的应该就是是双检锁了。在上述实现中,有几个要点:

  1. 为什么需要两次if检查:第一次if检查在synchronized块之外,当实例已经被创建好之后,可以立即返回。第二次if检查,是因为在高并发的情况下,可能会有好多线程走到第一个if块中,去争抢synchronized锁,我们必须保证只有第一个抢到锁的线程能创建实例,所以后面的线程必须再进行一次if判断,发现实例已经被第一个抢到锁的线程初始化好了,直接返回该实例。这也是双检名字的由来。
  2. 为什么成员变量singletonInstance要声明为volatile:因为new DoubleCheckLockSingleton()其实并不是一个原子操作,主要可以分为给实例分配堆内存执行类的构造函数将实例引用赋给调用者三步。而由于重排序的存在,在某一些机器上,第三步会先于第二步发生,于是可能出现,线程A走到了new DoubleCheckLockSingleton(),但并未执行完构造函数时,线程B发现instance != null了,于是对instance的属性进行访问,结果看到的属性都是默认值。而JMMJava1.5之后进行了增强,volatile关键字可以禁止编译器的重排序,并会在volatile关键字修饰的变量前后适当位置添加内存屏障,保证程序不会读到半初始化的实例。关于JMM的增强,可以扩展阅读Doug Lea大神的文章
  3. 为什么要加局部变量instance:加这个局部变量,主要是为了提高程序的性能。因为成员变量singletonInstance是声明为volatile的,而所有对volatile变量的操作(读写)都必须与主内存交互,开销相对较大。加局部变量可以减少与volatile变量的交互。这也是java.util.concurrent包中很多工具类的常见做法。

到这里,似乎双检锁的方案已经很完美了,确实,这也是被很多人所采用的单例模式实现方案。但其实懒汉式还有一种更为通用的实现方式。

3.5 懒汉式----静态内部类

引入一个静态内部类,来实现对静态变量的延迟加载

public class InnerClassWrappedTest {
    public static void main(String[] args) {
        for (int i = 0; i < 2; i++) {
            new Thread(() -> {
                InnerClassWrappedSingleton innerClassWrappedSingleton = InnerClassWrappedSingleton.getInstance();
                System.out.println("InnerClassWrappedSingleton in multi-thread instance: " + innerClassWrappedSingleton.hashCode());
            }).start();
        }
    }
}
public class InnerClassWrappedSingleton {

    private InnerClassWrappedSingleton(){};

    // 内部类持有单例,仅当getInstance()方法被调用的时候,SingletonHolder类才会被加载
    // final关键字保证了不会得到“半初始化”的实例
    private static class SingletonHolder {
        private static final InnerClassWrappedSingleton instance = new InnerClassWrappedSingleton();
    }

    public static InnerClassWrappedSingleton getInstance() {
        return SingletonHolder.instance;
    }
}

输出:

InnerClassWrappedSingleton in multi-thread instance: 2132107705
InnerClassWrappedSingleton in multi-thread instance: 2132107705

上述实现其实可以看作是饿汉式----静态块的升级版,只不过把实例的初始化,放到了静态内部类中。而该静态内部类只有在getInstance()被调用的时候,才会被加载,从而对单例进行初始化。同样,由类加载器保证了,只有一个实例会被创建。同时,final关键字在Java1.5之后也进行了增强,可以保证得到的一定是一个完整的单例。
这种方式是本人觉得比较好的方式,因为实现简单线程安全,而且适用性很强。

3.6 破坏单例----序列化

其实所有上述的实现方式,都不可能完全保证类的唯一,因为尽管我们把类的构造器设为了private,但仍然有办法用其他方式创建新的实例。比如不巧,单例的类正好实现了Serializable接口,那么黑客们可以通过序列化的方式,得到一个新的“单例”:

public class SingletonDestroyerSerialization {
    public static void main(String[] args) throws IOException, ClassNotFoundException {
        InnerClassWrappedSingleton instance1 = InnerClassWrappedSingleton.getInstance();
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        ObjectOutputStream oos = new ObjectOutputStream(baos);
        // 将单例序列化
        oos.writeObject(instance1);

        ByteArrayInputStream bais = new ByteArrayInputStream(baos.toByteArray());
        ObjectInputStream ois = new ObjectInputStream(bais);
        // 反序列化,创建一个新的“单例”
        InnerClassWrappedSingleton instance2 = (InnerClassWrappedSingleton) ois.readObject();

        System.out.println("singleton instance1: " + instance1.hashCode());
        System.out.println("singleton instance2: " + instance2.hashCode());
    }
}

public static class InnerClassWrappedSingleton implements Serializable {

    private InnerClassWrappedSingleton(){};
    private static class SingletonHolder {
        private static final InnerClassWrappedSingleton instance = new InnerClassWrappedSingleton();
    }
    public static InnerClassWrappedSingleton getInstance() {
        return SingletonHolder.instance;
    }
}

输出:

singleton instance1: 1173230247
singleton instance2: 764977973

显然,输出了不同的hashcodeJVM中存在了两个“单例”对象。
为了防止以上情况出现,我们可以在单例类中,添加一个readResolve()方法,并返回单例实例。这样,在反序列化之后,我们得到的依然是原先的实例:

public static class InnerClassWrappedSingleton implements Serializable {
    private InnerClassWrappedSingleton(){};
    private static class SingletonHolder {
        private static final InnerClassWrappedSingleton instance = new InnerClassWrappedSingleton();
    }
    public static InnerClassWrappedSingleton getInstance() {
        return SingletonHolder.instance;
    }
    // 添加此方法,防止序列化与反序列化创建新的实例
    private Object readResolve() {
        return SingletonHolder.instance;
    }
}

3.7 破坏单例----反射

如果说序列化与反序列化我们还有应对的办法,那么对于反射攻击,上述所有的实现方案,都无可奈何:

public class SingletonDestroyerRefelct {
    public static void main(String[] args) throws IllegalAccessException, InvocationTargetException, InstantiationException {
        InnerClassWrappedSingleton instance1 = InnerClassWrappedSingleton.getInstance();
        System.out.println("singleton instance1: " + instance1.hashCode());

        Constructor[] constructors = InnerClassWrappedSingleton.class.getDeclaredConstructors();
        for (Constructor constructor : constructors) {
            // 利用反射,创建一个新的“单例”变量
            constructor.setAccessible(true);
            InnerClassWrappedSingleton instance2 = (InnerClassWrappedSingleton) constructor.newInstance();
            System.out.println("singleton instance2: " + instance2.hashCode());
            break;
        }
    }
}

public static class InnerClassWrappedSingleton {

    private InnerClassWrappedSingleton(){};
    private static class SingletonHolder {
        private static final InnerClassWrappedSingleton instance = new InnerClassWrappedSingleton();
    }
    public static InnerClassWrappedSingleton getInstance() {
        return SingletonHolder.instance;
    }
}

输出:

singleton instance1: 1735600054
singleton instance2: 21685669

反射其实类似Java中的一个后门,非常强大,它能破坏单例模式也是情理之中。类似JSON序列化与反序列化,也能创建多个不同的“单例”,利用的也是反射机制。

3.8 究极单例----Enum

有没有办法防止反射调用破坏单例呢?答案是肯定的,即用enum创建单例

public enum EnumSingleton {
    INSTANCE;

    public void doSomething() {

    }
}

首先,JVMenum型变量的序列化与反序列化做了特殊处理,保证反序列化之后得到的依然是内存中的那个enum
第二,Java从语言层面保证,无法通过反射创建enum类型变量。
所以,如果说要选一种最安全的单例模式实现方案,那非Enum模式莫属。这也是「Effective Java」的作者Joshua Bloch所推荐的方式。

4 总结

本文介绍了形形色色很多的单例模式,其实也并不是越到后面的实现越好,而是要看每个版本的特性,选择最适合自己项目的那个版本。

文中例子的github地址

Responses