写点什么

23 种设计模式第一种——单例模式

作者:李子捌
  • 2021 年 12 月 04 日
  • 本文字数:10336 字

    阅读完需:约 34 分钟

23种设计模式第一种——单例模式

1、单例模式的定义

单例模式(Singleton Pattern),确保一个类只有一个实例,并提供对它的全局访问点。这是在 java-design-patterns.com 中对于单例模式的定义,其原文定义如下:

Ensure a class has only one instance, and provide a global point of acess to it.

简单来说就是确保系统中只创建特定类的一个对象(全文的重点和围绕展开的都是如何安全的更高效的去将类的实例化限制为一个对象)。​

2、单例模式的简单使用

在深入分析单例模式之前,先简单的有个整体的概念,单例模式大致是怎么使用的。我们通过一个简单的 demo 和其 UML 类图来做个介绍。单例模式通用写法:

package com.lizba.pattern.singleton;/** * <p> *      单例模式的简单使用示例 * </p> * * @Author: Liziba * @Date: 2021/6/27 14:44 */public class SingletonDemo {    private static final SingletonDemo singleton = new SingletonDemo();    private SingletonDemo() {}    public static SingletonDemo getInstance() {        return singleton;    }}
复制代码

单例模式的 UML 类图:

3、单例模式的优缺点和使用场景

在很多技术的学习和选择的前置条件我想可能都会是这两个问题为先驱,所以把本该放置到最后去总结的两个点,优先提到了最前面,也正是出于这个原因。单例模式的编码说简单确实简单,编码一个最优的单例或者如何更好的适用当前场景,对编码人员有一定的要求,如下主要分析其优缺点和使用场景,便于编码人员的选择。​

3.1 单例模式的优点

  1. 只创建一个实例对象,减少内存开销,同时也减少了 GC

  2. 只创建一个示例对象,通过全局唯一的访问点,可以避免对资源的多重占用,例如文件的对同一个文件的写

  3. 通过这个全局访问点为抓手,可以对资源的访问做优化处理,例如访问点中预置缓存


3.2 单例模式的缺点

  1. 单例模式的功能一般写于一个类中,这回导致业务逻辑耦合,如果设计不合理,是违背单一职责原则

  2. 单例模式一般不实现接口,使其扩展困难,是违背开闭原则

  3. 单例模式只存在一个对象,在并发测试中不利于调试


3.3 使用典型场景

  1. 日志类,对于日志的记录等操作,我们通常使用单例模式

  2. 数据库连接和访问的管理

  3. 文件资源访问管理

  4. 生成唯一的序列号

  5. 系统全局唯一的访问端点,例如系统访问请求计数器

  6. 对象的创建需要大量的开销或者对象会被频繁调用可以考虑通过单例模式来优化替换


3.4 源码中的使用

说三道四不如看看 Java 最佳规范 JDK 中对于单例模式的一些使用

  1. java.lang.Runtime

public class Runtime {        private static Runtime currentRuntime = new Runtime();    public static Runtime getRuntime() {        return currentRuntime;    }        private Runtime() {}     // ...}
复制代码
  1. java.awt.GraphicsEnvironment

public abstract class GraphicsEnvironment {    protected GraphicsEnvironment() {    }    public static synchronized GraphicsEnvironment getLocalGraphicsEnvironment() {        if (localEnv == null) {            localEnv = createGE();        }        return localEnv;    }        // ...}
复制代码
  1. java.awt.Desktop

public class Desktop {    private Desktop() {        peer = Toolkit.getDefaultToolkit().createDesktopPeer(this);    }     public static synchronized Desktop getDesktop(){        if (GraphicsEnvironment.isHeadless()) throw new HeadlessException();        if (!Desktop.isDesktopSupported()) {            throw new UnsupportedOperationException("Desktop API is not " +                                                    "supported on the current platform");        }        sun.awt.AppContext context = sun.awt.AppContext.getAppContext();        Desktop desktop = (Desktop)context.get(Desktop.class);        if (desktop == null) {            desktop = new Desktop();            context.put(Desktop.class, desktop);        }        return desktop;    }}
复制代码

除了在 jdk 源码中对于单例模式有不少的使用之外,我们最常见的 Spring 框架中,每个 Bean 默认就是单例的,这样做的原因在于 Spring 容器可以管理 Bean 的生命周期。修改 Spring 中 Bean 的单例属性,只需要设置为 Prototype 类型即可,这样 Bean 的生命周期将不再有 Spring 容器跟踪。

4、饿汉式单例

饿汉式单例主要体现在一个饿字,也就是说在使用这个对象之前,在类加载的时候就立即初始化,创建其实例对象。这样做的好处是线程安全、使用时速度更快。饿汉式单例的写法如下:写法一:

package com.lizba.pattern.singleton.hungry;/** * <p> *      饿汉式单例写法一 * </p> * * @Author: Liziba * @Date: 2021/6/27 15:46 */public class HungrySingletonDemo1 {    private static final HungrySingletonDemo1 HUNGRY_SINGLETON_DEMO_1 = new HungrySingletonDemo1();    private HungrySingletonDemo1() {}    public static HungrySingletonDemo1 getInstance() {        return HUNGRY_SINGLETON_DEMO_1;    }}
复制代码

写法二:

package com.lizba.pattern.singleton.hungry;/** * <p> *      饿汉式单例写法二(静态代码块) * </p> * * @Author: Liziba * @Date: 2021/6/27 15:49 */public class HungrySingletonDemo2 {    private static final HungrySingletonDemo2 HUNGRY_SINGLETON_DEMO_2;    static {        HUNGRY_SINGLETON_DEMO_2 = new HungrySingletonDemo2();    }    public static HungrySingletonDemo2 getInstance() {        return HUNGRY_SINGLETON_DEMO_2;    }}
复制代码

饿汉式单例可以保证线程安全,执行效率高,同时编码简单容易理解。但是饿汉式单例只适用于单例对象减少的情况,如果大量编写饿汉式单例不仅会给系统启动带来负担,也可能会导致内存的浪费,比如创建的单例对象在程序运行过程中并未使用。因此这对内存浪费这个问题,我们衍生出了懒汉式单例。​

5、懒汉式单例

懒汉式单例主要体现在一个懒字,也就是说类加载的时候我懒得实例化,等你需要用了再说吧!接下来的懒汉式单例中我通过一步步的优化和推翻来演进如何编写一个优秀的单例。​

5.1 普通懒汉式单例

package com.lizba.pattern.singleton.lazy;/** * <p> *        简单懒汉式单例示例代码1 * </p> * * @Author: Liziba * @Date: 2021/6/27 16:01 */public class LazySingletonDemo1 {    private static LazySingletonDemo1 LAZY_SINGLETON = null;    private LazySingletonDemo1() {    }    public static LazySingletonDemo1 getInstance() {        if (LAZY_SINGLETON == null) {            LAZY_SINGLETON = new LazySingletonDemo1();        }        return LAZY_SINGLETON;    }}
复制代码

这是一个非常普通的懒汉式单例模式的写法,相信很多初学者会编码成这样,但其实这是一种错误的线程不安全的写法。我们通过一个断点测试来证明其不安全,我采用的是 IDEA 编写代码,通过设置断点为 Thread 模式来使得线程 1 和线程 2 同时满足 LAZY_SINGLETON == null。​

测试代码

package com.lizba.pattern.singleton.lazy;/** * <p> *      测试懒汉式单例1的线程不安全 * </p> * * @Author: Liziba * @Date: 2021/6/27 16:05 */public class LazySingletonTest {    public static void main(String[] args) {        new Thread(() -> run(), "Thread-1").start();        new Thread(() -> run(), "Thread-2").start();        System.out.println("End of test...");       }    public static void run() {        LazySingletonDemo1 lad = LazySingletonDemo1.getInstance();        System.out.println(Thread.currentThread().getName() + " : " + lad);    }}
复制代码

断点设置如下图,邮件打在 LAZY_SINGLETON == null 的断点上,选择 Suspend 为 Thread,然后点击 Done 即可。调试过程,我们执行测试代码,使其两个线程均执行到 LAZY_SINGLETON == null,然后 F8 使得线程 1 进入 if 语句;切换至线程 2F8 进入 if 语句,此时我们的目的便达到了(在实际的并发使用场景中,这种情况是非常可能出现的)此时线程 1 和线程 2 中的对象并不是同一个对象,所以这种单例编码方式是不正确的。

5.2 同步懒汉式单例

关于上述的单例模式编码,部分读者会想到通过同步原语 synchronized 加在 getInstance()方法上来解决,此时代码如下所示:

package com.lizba.pattern.singleton.lazy;
/** * <p> * synchronized修饰方法getInstance()的懒汉式单例 * </p> * * @Author: Liziba * @Date: 2021/6/27 16:37 */public class LazySingletonDemo2 {
private static LazySingletonDemo2 LAZY_SINGLETON = null;
private LazySingletonDemo2() { }
// synchronized修饰getInstance()方法 public static synchronized LazySingletonDemo2 getInstance() { if (LAZY_SINGLETON == null) { LAZY_SINGLETON = new LazySingletonDemo2(); } return LAZY_SINGLETON; }}
复制代码

如上通过给 getInstance()加上 synchronized 关键字,使得同步方法中的代码块线程安全了,但是随之而来的是在大量并发调用该方法的性能问题,大量的线程会阻塞在这个方法上,所以有些我们通过缩小锁锁住的范围来尽可能的提升其并发性能。代码衍生为如下所示:

package com.lizba.pattern.singleton.lazy;
/** * <p> * synchronized修饰方法代码块 * </p> * * @Author: Liziba * @Date: 2021/6/27 16:53 */public class LazySingletonDemo3 {
private static LazySingletonDemo3 LAZY_SINGLETON = null;
private LazySingletonDemo3() { }
public static LazySingletonDemo3 getInstance() { synchronized (LazySingletonDemo3.class) { if (LAZY_SINGLETON == null) { LAZY_SINGLETON = new LazySingletonDemo3(); } return LAZY_SINGLETON; } }
}
复制代码

如上这种锁住代码块的方式,虽然缩小了锁同步的范围,但是并未本质上的改变每个线程均需要阻塞这个问题,因此我们可以前置 if(LAZY_SINGLETON == null)这个判断,使得部分线程判断对象被实例化后无需加锁,直接返回即可,其代码如下所示:

package com.lizba.pattern.singleton.lazy;
/** * <p> * if(LAZY_SINGLETON == null)前置于同步代码块,单例模式示例4 * </p> * * @Author: Liziba * @Date: 2021/6/27 16:56 */public class LazySingletonDemo4 {
private static LazySingletonDemo4 LAZY_SINGLETON = null;
private LazySingletonDemo4() { }
public static LazySingletonDemo4 getInstance() { // 前置if判断 if (LAZY_SINGLETON == null) { synchronized (LazySingletonDemo4.class) { LAZY_SINGLETON = new LazySingletonDemo4(); } } return LAZY_SINGLETON; }
}
复制代码

如上代码看似兼顾了性能和同步对象的实例化,但是这里的同步语言并不能保证对象只实例化一次,它只能保证每次只有一个线程在实例化这个对象。试想一下,如果线程 1 和线程 2 均执行到代码 21 行,此时线程 1 获得锁继续执行,实例化对象 LazySingletonDemo4,当线程 1 退出锁后,线程 2 获取到锁,线程 2 也会对 LazySingletonDemo4 进行实例化,这种情况也可以用上面的断点法测试出来。所以这种情况是错误的,但是只有小小的改动一下即可,改动后的方式如下所示。​

5.3 双重检查锁懒汉式单例

双重检查锁看名字高大上,其实就是在上面的 LazySingletonDemo4 多个 if 判断而已,理解为双重检查+锁可能更明了,其代码如下所示:

package com.lizba.pattern.singleton.lazy;
/** * <p> * 双重检查锁 * </p> * * @Author: Liziba * @Date: 2021/6/27 17:04 */public class LazySingletonDemo5 {
private static LazySingletonDemo5 LAZY_SINGLETON = null;
private LazySingletonDemo5() { }
public static LazySingletonDemo5 getInstance() { // 检查1 if (LAZY_SINGLETON == null) { synchronized (LazySingletonDemo5.class) { // 检查2(这就是双重检查) if (LAZY_SINGLETON == null) { LAZY_SINGLETON = new LazySingletonDemo5(); } } } return LAZY_SINGLETON; }
}
复制代码


双重检查锁看似完全正确,其实还存在一个指令重排序问题,至于什么是指令重排序,在我的并发编程专题中有详细的讲述,这里不再细讲;简单来说,就是编译器和处理器会针对编译后的指令进行重新排序,这种重排序对应编译器和处理器来说是会带来一定的性能提升的,但是对于编写代码的程序员来说,如果不正确的使用同步语义,将会导致非预期结果出现。在上述示例中第 24 行代码 LAZY_SINGLETON = new LazySingletonDemo5(),在编译器编译后生成的字节码会有三条指令如下所示:

memory = allocate();  // 1:分配对象的内存空间ctorInstance(memory); // 2:初始化对象instance = memory;  // 3:设置instance指向刚分配的内存地址
复制代码

这三条指令中,指令 2 和指令 3 可能会出现重排序,也就是说对象的初始化会被后置到将分配的地址指向对象的引用这个指令的后面;这种重排序,假设是在线程 1 执行中发生了,此时线程 2 执行到第 20 行 if (LAZY_SINGLETON == null),此时 LAZY_SINGLETON 并不为 null,程序会直接返回 LAZY_SINGLETON 对象,但是此时的对象是一个实例化不完全的对象,这种情况是不允许存在的。其解决办法是通过 volatile 关键字借助其内存语义来禁止指令重排序,这样 2 和 3 指令之间的重排序将会被禁止,这涉及到 JMM 规范,需要的请查看我的并发专题系列文章。其改造代码如下所示:

package com.lizba.pattern.singleton.lazy;
/** * <p> * volatile修饰 LAZY_SINGLETON禁止指令重排序 * </p> * * @Author: Liziba * @Date: 2021/6/27 17:12 */public class LazySingletonDemo6 {
// volatile修饰LAZY_SINGLETON反正指令重排序 private static volatile LazySingletonDemo6 LAZY_SINGLETON = null;
private LazySingletonDemo6() { }
public static LazySingletonDemo6 getInstance() { // 检查1 if (LAZY_SINGLETON == null) { synchronized (LazySingletonDemo6.class) { // 检查2(这就是双重检查) if (LAZY_SINGLETON == null) { LAZY_SINGLETON = new LazySingletonDemo6(); } } } return LAZY_SINGLETON; }
}
复制代码

上述演进过程,其实已经编写一个比较完整的懒汉式单例的示例代码,但是介于语言的一些特性,也就是反射这个无事不为的操作,能破坏这种场景。我们先来看看怎么破坏的,其破坏示例代码如下:

package com.lizba.pattern.singleton.lazy;
import java.lang.reflect.Constructor;import java.lang.reflect.InvocationTargetException;
/** * <p> * 破坏双重检查锁单例 * </p> * * @Author: Liziba * @Date: 2021/6/27 17:34 */public class DoubleCheckTest {
public static void main(String[] args) { Class<LazySingletonDemo6> singletonClass = LazySingletonDemo6.class; try { Constructor<LazySingletonDemo6> c = singletonClass.getDeclaredConstructor(null); // 破坏private访问权限 c.setAccessible(true); LazySingletonDemo6 lsd1 = c.newInstance(); LazySingletonDemo6 lsd2 = c.newInstance();
System.out.println(lsd1 == lsd2); } catch (Exception e) { e.printStackTrace(); }
}
}
复制代码

输出结果:false 处理方式处理方式十分简单粗暴,就是在构造方法中抛出异常,抛出异常这种解决方式,在很多不合法的场景中非常常见,修改后的代码如下:

package com.lizba.pattern.singleton.lazy;
/** * <p> * 防止反射破坏单例 * </p> * * @Author: Liziba * @Date: 2021/6/27 17:56 */public class LazySingletonDemo7 {
private static volatile LazySingletonDemo7 LAZY_SINGLETON = null;
// 在构造函数中判断如果LAZY_SINGLETON不为空,则抛出异常即可 private LazySingletonDemo7() { if (LAZY_SINGLETON != null) { throw new RuntimeException("This operation is forbidden."); } }
public static LazySingletonDemo7 getInstance() { // 检查1 if (LAZY_SINGLETON == null) { synchronized (LazySingletonDemo7.class) { // 检查2(这就是双重检查) if (LAZY_SINGLETON == null) { LAZY_SINGLETON = new LazySingletonDemo7(); } } } return LAZY_SINGLETON; }
}
复制代码

####

5.4 静态内部类懒汉式单例

在双重检查锁的演进中,我们通过不断的缩小锁的范围,以及对对象是否未实例化做了两次判断,最后对反射获取对象这种操作做了处理;但是归根到底,双重检查锁中有个锁字,就难规避性能讨论的问题,其实这种问题在大部分场景中是可以接受;如果硬要寻求一种既是懒汉式,又不需要锁的单例模式,那么通过静态内部类的加载特性,巧妙的实现懒汉式单例模式会是一种不错的选择。Java 语言中的内部类是延时加载的,只有在第一次使用的时候才会被加载,不使用则不加载。我们通过该特性,编码的懒汉式单例如下所示:

package com.lizba.pattern.singleton.lazy;
/** * <p> * 内部类懒汉式单例 * </p> * * @Author: Liziba * @Date: 2021/6/27 20:14 */public class LazySingletonDemo8 {
private LazySingletonDemo8() { if (LazyHolderInner.LAZY_SINGLETON != null) { throw new RuntimeException("This operation is forbidden."); } }
public static LazySingletonDemo8 getInstance() { return LazyHolderInner.LAZY_SINGLETON; }
/** * 使用内部类,被使用才加载的特性来 */ private static class LazyHolderInner { public static final LazySingletonDemo8 LAZY_SINGLETON = new LazySingletonDemo8(); }
}
复制代码

###

6、单例模式的最佳方式

Joshua Bloch 大师的 Effective Java 第二版中说枚举式单例是实现单例的最佳方式,那我们就来看看枚举式单例的实现吧。编码如下:

package com.lizba.pattern.singleton.hungry;
/** * <p> * 枚举式单例示例 * </p> * * @Author: Liziba * @Date: 2021/6/27 20:29 */public enum EnumSingletonDemo {
SINGLETON_INSTANCE; private Object object;
public Object getObject() { return object; }
public void setObject(Object object) { this.object = object; }
public static EnumSingletonDemo getInstance() { return SINGLETON_INSTANCE; }}

复制代码

测试枚举式单例:

package com.lizba.pattern.singleton.hungry;
/** * <p> * 枚举式单例测试 * </p> * * @Author: Liziba * @Date: 2021/6/27 20:33 */public class EnumSingletonTest {
public static void main(String[] args) { EnumSingletonDemo instance1 = EnumSingletonDemo.getInstance(); instance1.setObject(new Integer(100));
EnumSingletonDemo instance2 = EnumSingletonDemo.getInstance(); Integer value = (Integer) instance2.getObject();
System.out.println(instance1 == instance2); System.out.println(value); }
}
复制代码

查看输出结果:更加上述输出结果,可以非常明确的发现,instance1 和 instance2 就是同一个对象,那么为何枚举可以实现单例呢?为何其被 Joshua Bloch 大师称为最好的单例模式编码方式呢?​

我们通过反编译工具 jad 来一探究竟,首先使用 jad,在 EnumSingletonDemo.class 所在的目录下执行 jad EnumSingletonDemo.class,这样会生成一个 EnumSingletonDemo.jad 文件;

生成的 EnumSingletonDemo.jad 文件内容如下:我们看到代码的最后有一个静态代码块,在这里一看便知。

// Decompiled by Jad v1.5.8g. Copyright 2001 Pavel Kouznetsov.// Jad home page: http://www.kpdus.com/jad.html// Decompiler options: packimports(3) // Source File Name:   EnumSingletonDemo.java
package com.lizba.pattern.singleton.hungry;

public final class EnumSingletonDemo extends Enum{
public static EnumSingletonDemo[] values(){ return (EnumSingletonDemo[])$VALUES.clone(); }
public static EnumSingletonDemo valueOf(String name){ return (EnumSingletonDemo)Enum.valueOf(com/lizba/pattern/singleton/hungry/EnumSingletonDemo, name); }
private EnumSingletonDemo(String s, int i) { super(s, i); }
public Object getObject() { return object; }
public void setObject(Object object){ this.object = object; }
public static EnumSingletonDemo getInstance() { return SINGLETON_INSTANCE; }
public static final EnumSingletonDemo SINGLETON_INSTANCE; private Object object; private static final EnumSingletonDemo $VALUES[];
// 静态代码块中实例化了EnumSingletonDemo static { SINGLETON_INSTANCE = new EnumSingletonDemo("SINGLETON_INSTANCE", 0); $VALUES = (new EnumSingletonDemo[] { SINGLETON_INSTANCE }); }}
复制代码

此外有人应该会想到反射破坏枚举式单例这个问题。一测便知,测试代码如下所示:

package com.lizba.pattern.singleton.hungry;
import java.lang.reflect.Constructor;
/** * <p> * 反射尝试破坏枚举式单例 * </p> * * @Author: Liziba * @Date: 2021/6/27 20:56 */public class EnumSingletonTest2 {
public static void main(String[] args) { Class<EnumSingletonDemo> clazz = EnumSingletonDemo.class; try { // 为何是这两个参数不明白的可以看上面jad生成的代码 Constructor<EnumSingletonDemo> c = clazz.getDeclaredConstructor(String.class, int.class); c.setAccessible(true); EnumSingletonDemo enumSingletonDemo = c.newInstance("liziba", "liziba_98"); } catch (Exception e) { e.printStackTrace(); } }
}
复制代码

测试结果:程序抛出了异常,顺着异常往下跟,进入 java.lang.reflect.Constructor 的 newInstance()方法,其源码如下,异常产生原因,我在代码中注释了,大家一看便知。

public T newInstance(Object ... initargs)    throws InstantiationException, IllegalAccessException,IllegalArgumentException, InvocationTargetException {    if (!override) {        if (!Reflection.quickCheckMemberAccess(clazz, modifiers)) {            Class<?> caller = Reflection.getCallerClass();            checkAccess(caller, clazz, null, modifiers);        }    }    // 异常来源,判断如果是修饰符是枚举类型,则直接抛出异常。    if ((clazz.getModifiers() & Modifier.ENUM) != 0)        throw new IllegalArgumentException("Cannot reflectively create enum objects");    ConstructorAccessor ca = constructorAccessor;   // read volatile    if (ca == null) {        ca = acquireConstructorAccessor();    }    @SuppressWarnings("unchecked")    T inst = (T) ca.newInstance(initargs);    return inst;}
复制代码

到这里枚举式单例就不继续往后介绍了。枚举式单例很明显是一种饿汉式单例,它的优缺点也在前文中有做对比总结。为何我把它单独拎出来,并不仅仅是因为 Joshua Bloch 大师称其为最佳的单例模式编码方式,更多的是这个编码方式带来的思考,我们开发应该如果更好的利用语言的特性,更优雅、高效、安全以及适用当前系统场景的去编码,这个值得每一个认真编码人的深思。​

如果您真的阅读到了这一行,我深深的对您表达我的致意。文章中出现的错误请多多指教,李子捌一定即使修正。如您不嫌弃,高抬贵手给个三连,先行谢过。

发布于: 31 分钟前阅读数: 6
用户头像

李子捌

关注

华为云享专家 2020.07.20 加入

公众号【李子捌】

评论

发布
暂无评论
23种设计模式第一种——单例模式