前言
经典的设计模式有23种,分为创建型、结构型、行为型,分别适用于不同的场景。由于设计模式过多,很难一篇文章就讲清楚,因此后面的文章会将常见的设计模式做一个拆分的介绍。
什么是单例模式?
一个类只允许创建一个实例,这个类就叫做单例类,这种设计模式就叫做单例模式。单例的范围包括线程内唯一、进程内唯一、集群内唯一。通常情况我们所说的单例范围是指进程内的单例。在我们常用的 Spring 框架中,甚至实现了容器范围内的单例。
在 Java 中,类由类加载器 ClassLoader 加载到 JVM 中,通常情况下,JVM 中的 ClassLoader 使用双亲委派模型不会重复加载相同的类,而如果使用多个自定义的 ClassLoader 加载同一个类,则每个 ClassLoader 都会加载一份类到 JVM 中。这也就导致了在 Java 中线程或进程内的单例必须限定在 ClassLoader 范围内。
什么情况下可以使用单例模式?
有些数据在系统中只能保存一份,可以设计为单例,如配置类;
有些对象是无状态的也可以设计为单例,此时类似于静态类,可以减少内存占用;
如何实现一个单例模式?
单例具有自己的适用范围,不同的范围有不同的实现方式,下面按照从小到大的范围进行介绍。
线程范围内的单例模式
线程范围内的单例模式需要保证同一个类在某个线程中只有一份实例。很自然的,我们可以将线程 ID 作为 key,单例对象作为 value 存入 Map 中,这样就保证了每个线程对应一个单例对象。在 Java 中,我们可以使用 ThreadLocal 类,其实现方式和我们所述一致。用代码的形式实现线程范围内的单例模式如下。
public class Singleton { private static final ThreadLocal<Singleton> THREAD_LOCAL = ThreadLocal.withInitial(Singleton::new); private Singleton() { } public static Singleton getInstance() { return THREAD_LOCAL.get(); } }
进程范围内的单例模式
在 Java 中,进程内的单例模式有多种实现方式,包括饿汉式、懒汉式、双重检测、静态内部类、枚举。
饿汉式单例模式实现
“饿汉式”、“懒汉式”词语来源已无从考证,不过用这两个词语来形容其实现方式确实比较恰当。“饿汉式”表示比较饥饿,迫切的想要吃东西,对应到单例模式实现则是说希望尽快创建单例对象。代码实现如下。
public class Singleton { private static final Singleton INSTANCE = new Singleton(); private Singleton() { } public static Singleton getInstance() { return INSTANCE; } }
饿汉式单例模式在类被加载到 JVM 时就会创建实例,由 JVM 保证不会在多线程下创建多个类的实例,如果类的实例化比较耗时,将会降低程序的启动速度、提前占用内存。不过这种实现的思想很像 fail-fast,如果有异常就尽快抛出,并且实现方式也比较简单,因此不失为是一种好的实现方式,个人也比较喜欢这种方式。
懒汉式实现单例模式
“懒汉式”表示实例化单例对象是懒惰的、延迟的,只有使用的时候才会创建单例对象。用代码表示如下。
public class Singleton { private static Singleton INSTANCE; private Singleton() { } public synchronized static Singleton getInstance() { if (INSTANCE == null) { INSTANCE = new Singleton(); } return INSTANCE; } }
懒汉式实现单例模式,为了避免多线程下同时调用获取单例的方法导致创建多个对象,在方法上加了类级别的锁,由于锁的粒度较大,因此可能会产生性能问题。多线程编程提高性能的一个方法就是降低锁的粒度,因此又诞生了双重检测的方法。
双重监测实现单例模式
双重监测实现单例模式第一次判断单例对象是否已创建时并不加锁,只有当单例对象不存在时才加锁再次判断对象是否存在以决定是否需要创建。对应代码如下。
public class Singleton { private static volatile Singleton INSTANCE; private Singleton() { } public static Singleton getInstance() { if (INSTANCE == null) { synchronized (Singleton.class) { if (INSTANCE == null) { INSTANCE = new Singleton(); } } } return INSTANCE; } }
细心的同学可能会发现这个版本的代码,在 INSTANCE 变量中添加了 volatile 关键字。
这是因为实例化单例和赋值单例对象给 INSTANCE 变量是两个操作,由于指令重排序,可能刚为新创建的对象分配好内存还未创建对象结束就把这个内存的地址赋值给 INSTANCE 变量,然后方法返回这就导致 INSTANCE 变量对应的对象还是 null ,多线程下仍可能出现多次创建单例对象的情况。
通过为 INSTANCE 添加 volatile 关键词禁止指令重排序可以解决这个问题。不过在高版本的 JDK 中已经解决了这个问题,原理就是将多个操作合并为一个原子操作。
静态内部类实现单例模式
饿汉式单例模式在类加载到 JVM 时就会创建单例对象,为了延迟创建单例对象,还可以利用静态内部类的特性。静态内部类在外部类初始化时并不会初始化,也就达到了延迟创建单例对象的目的。转换为代码如下。
public class Singleton { private static class SingletonHolder { private static Singleton INSTANCE = new Singleton(); } private Singleton() { } public static Singleton getInstance() { return SingletonHolder.INSTANCE; } }
这种实现方式也比较简洁,如果一定要延迟初始化单例对象的话推荐使用这种方式。
枚举实现单例模式
和饿汉式、静态内部类相似,通过枚举实现单例模式也是借助了 JVM 保证多线程下不会创建多个单例对象。使用枚举实现单例模式的代码如下。
public enum Singleton { INSTANCE; public static Singleton getInstance() { return INSTANCE; } }
集群范围内的单例模式
集群范围内的单例模式,是说在整个集群中一个类只有同时只能有一个实例。由于每个进程都可以创建类的实例,因此这需要分布式锁来保证,同时类需要支持序列化,以便将单例对象在网络中传输并反序列化,通常情况下应该没有需求需要这样做。示例代码如下。
public class Singleton { private static DistLock lock = new DistLock(); private static ObjectStorage storage = new ObjectStorage(); private static Singleton INSTANCE; public static Singleton getInstance() { if (INSTANCE == null) { try { lock.tryLock(); INSTANCE = storage.load(Singleton.class); } finally { lock.unLock(); } } return INSTANCE; } public synchronized void freeInstance() { storage.save(this, Singleton.class); INSTANCE = null; lock.unLock(); } }
单例模式有何弊端
尽管使用单例模式可以实现全局范围内只有一个实例的目的,很多人都在使用,不过也有人把它称为反模式,这是因为其自身仍然存在一些缺陷。
单例模式会隐藏类之间的依赖关系。直接在方法中使用类似Singleton.getInstance().invoke(..)的代码,阅读者不能一眼看成某个类依赖了单例对象。
单例模式对OOP不够友好,通常情况单例类只有一种实现并没有什么问题,而如果单例类有不同的实现,则需要对单例类做较大的改造。
单例模式对扩展性不够友好,不支持创建多个实例,如果未来需求变化需要创建多个实例则不能满足,这是单例的优点也是单例的缺点。
另外单例模式还不支持参数、由于无法做到依赖注入因此做测试时无法mock单例对象。
总结
单例模式理解相对简单,即全局范围内只有一个实例,不同的范围具有不同的实现方式。它虽然可以保证只能有一个对象,但同时也带来了一系列的问题。
尽管单例模式有不足的地方,但单例模式并非一无是处,例如 Spring 支持 IOC 容器内的单例,巧妙的解决了上述的问题。如果非 Spring 环境我们也可以使用工厂模式创建单例对象,同时还方便了以后的扩展。是否使用单例模式这种问题可以说仁者见仁智者见智,如果能接受其弊端则可以使用,否则改用工厂模式或使用 Spring 保证单例即可。