设计模式 - Java中单例模式的6种写法及优缺点对比

时间:2022-01-22 16:10:21

1 为什么要用单例模式

1.1 什么是单例模式

单例模式就是: 在程序运行期间, 某些类有且最多只有一个实例对象.

我们的应用中可能存在这样的需求: 某些类没有自己的状态, 在程序运行期间它们只需要有一个实例, 换句话说, 无论为这些类创建多少个实例, 对程序的运行状态、运行结果都不会产生影响.

更重要的一点是: 有些类如果存在两个或者两个以上的实例, 应用程序就会发生某些匪夷所思的错误, 不同于空指针、数组越界、非法参数等错误, 这样的问题一般都很难提前发觉和定位.

这个时候, 我们就应该把这样的类控制为单例结构 —— 确保程序运行期间最多只有一个相对应的实例对象.

关于类的状态的理解:
① 比如有一个 Person 类, 它有成员变量name、age等等, 不同的姓名和年龄就是不同的人, 也就是说这些变量都是不确定的, 这样的类就是有状态的类.
② 而像一些配置类, 比如 RedisProps (Redis的配置信息)类, 它的所有属性和方法都是static的, 没有不确定的属性, 这样的类就可以认为是没有状态的类.
—— 纯属个人看法, 若理解有误, 还请读者朋友们提出, 欢迎批评和交流????

1.2 单例模式的思路和优势

(1) 单例模式的实现思路是:

① 静态化实例对象, 让实例对象与Class对象互相绑定, 通过Class类对象就可以直接访问;
私有化构造方法, 禁止通过构造方法创建多个实例 —— 最重要的一步;
③ 提供一个公共的静态方法, 用来返回这个类的唯一实例.

(2) 单例模式的优势:

单例模式的好处是: 尽可能节约内存空间(不用为一个类创建多个实例对象), 减少GC(垃圾回收)的消耗, 并使得程序正常运行.

接下来就详细描述单例模式的6种不同写法.

2 写法① - 饥饿模式

2.1 代码示例

饥饿模式又称为饿汉模式, 指的是JVM在加载类的时候就完成类对象的创建:

/**
 * 饥饿模式: 类加载时就初始化
 */
final class HungrySingleton {
    /** 实例对象 */
    private static HungrySingleton instance = new HungrySingleton();

    /** 禁用构造方法 */
    private HungrySingleton() { }

    /**
     * 获取单例对象, 直接返回已创建的实例
     * @return instance 本类的实例
     */
    public static HungrySingleton getInstance() {
        return instance;
    }
}

2.2 优缺点比较

(1) 优点: JVM层面的线程安全.

JVM在加载这个类的时候就会对它进行初始化, 这里包含对静态变量的初始化;

Java的语义包证了在引用这个字段之前并不会初始化它, 并且访问这个字段的任何线程都将看到初始化这个字段所产生的所有写入操作.

—— 参考自 The "Double-Checked Locking is Broken" Declaration, 原文如下:

If the singleton you are creating is static (i.e., there will only be one Helper created), as opposed to a property of another object (e.g., there will be one Helper for each Foo object, there is a simple and elegant solution.

Just define the singleton as a static field in a separate class. The semantics of Java guarantee that the field will not be initialized until the field is referenced, and that any thread which accesses the field will see all of the writes resulting from initializing that field.

==> 所以这就在JVM层面包证了线程安全.

(2) 缺点: 造成空间的浪费.

饥饿模式是典型的以空间换时间思想的实现: 不用判断就直接创建, 但创建之后如果不使用这个实例, 就造成了空间的浪费. 虽然只是一个类实例, 但如果是体积比较大的类, 这样的消耗也不容忽视.

—— 不过在有些时候, 直接初始化单例的实例对项目的影响也微乎其微, 比如我们在应用启动时就需要加载的配置文件信息, 就可以采取这种方式去保证单例.

3 写法② - 懒惰模式

3.1 代码示例

懒惰模式又称为懒汉模式, 指的是在真正需要的时候再完成类对象的创建:

/**
 * 懒惰模式: 用到时再初始化, 线程不安全, 可以在方法上使用synchronized关键字实现线程安全
 */
final class LazySingleton {
    /** 实例对象 */
    private static LazySingleton instance = null;

    /** 禁用构造方法 */
    private LazySingleton() { }

    /**
     * 线程不安全, 可以在方法上使用synchronized关键字实现线程安全
     * @return instance 本类的实例
     */
    public static LazySingleton getInstance() {
        if (instance == null) {
            instance = new LazySingleton();
        }
        return instance;
    }
}

3.2 优缺点比较

(1) 优点: 节省空间, 用到的时候再创建实例对象.

需要这个实例的时候, 先判断它是否为空, 如果为空, 再创建单例对象.

用到的时候再去创建, 与JVM加载类的思路一致: 都是需要的时候再处理.

(2) 缺点: 线程不安全.

① 在并发获取实例的时候, 线程A调用getInstance(), 在判断singleton == null时得到true的结果, 之后进入if语句, 准备创建instance实例;

② 恰好在这个时候, 另一个线程B来了, CPU将执行权切换给了B —— 此时A还没来得及创建出实例, 所以线程B在判断singleton == null的时候, 结果还是true, 所以线程B也会进入if语句去创建实例;

③ 问题来了: 两个线程都进入了if语句, 结果就是: 创建了2个实例对象.

3.3 线程是否安全的测试

/**
 * 测试懒惰模式的线程安全
 */
public static void main(String[] args) {
    // 同步的Set, 用来保存创建的实例
    Set<String> instanceSet = Collections.synchronizedSet(new HashSet<>());
    
    // 创建100个线程, 将每个线程获得的实例添加到Set中
    for (int i = 0; i < 100; i++) {
        new Thread(() -> {
            instanceSet.add(LazySingleton.getInstance().toString());
        }).start();
    }

    for (String instance : instanceSet) {
        System.out.println(instance);
    }
}

(1) 代码说明: 上述循环中的Lambda表达式的作用, 等同于:

new Thread(new Runnable() {
     @Override
     public void run() {
         instanceSet.add(LazySingleton.getInstance().toString());
     }
 }).start();

(2) 输出结果说明: 由于Set集合能够自动去重, 所以如果输出的结果中有2个或2个以上的对象, 就足以说明在并发访问的过程中出现了线程安全问题. 当然如果没有出现的话, 不妨多运行几次, 或者把循环次数调大一点再试试????

3.4 线程安全的懒惰模式

(1) 通过synchronized关键字对获取实例的方法进行同步限制, 实现了线程安全:

    /**
     * 在获取实例的公共方法上使用synchronized关键字实现线程安全
     * @return instance 本类的实例
     */
    public synchronized static LazySingleton getInstance() {
        if (instance == null) {
            instance = new LazySingleton();
        }
        return instance;
    }

(2) 优缺点比较:

上面的做法是把整个获取实例的方法同步. 这样一来, 当某个线程访问这个方法时, 其它所有的线程都要处于挂起等待状态.

① 优点: 避免了同步访问创建多个实例的问题;
② 缺点: 很明显, 这样的做法对所有线程的访问都会进行同步操作, 有很严重的性能问题.

4 写法③ - 双重检查锁模式

4.1 代码示例

在上述代码中, 我们不难发现, 其实同步操作只需要发生在实例还未创建的时候, 在实例创建以后, 获取实例的方法就没必要再进行同步控制了.

这个思路就是 双重检查锁(Double Checked Locking, 简称DCL)模式 的实现思路, 是在线程安全的懒惰模式的基础上改进得来的. 下面我们通过代码剖析这种模式:

/**
 * 双重检查锁模式: 对线程安全的懒惰模式的改进: 方法上的synchronized在每次调用时都要加锁, 性能太低.
 */
final class DoubleCheckedLockingSingleton {
    /** 实例对象, 这里还没有添加volatile关键字 */
    private static DoubleCheckedLockingSingleton instance = null;

    /** 禁用构造方法 */
    private DoubleCheckedLockingSingleton() { }

    /**
     * 获取对象: 将方法上的synchronized移至内部
     * @return instance 本类的实例
     */
    public static DoubleCheckedLockingSingleton getInstance() {
        // 先判断实例是否存在
        if (instance == null) {
            // 加锁创建实例
            synchronized (DoubleCheckedLockingSingleton.class) {
                // 再次判断, 因为可能出现某个线程拿了锁之后, 还没来得及执行初始化就释放了锁,
                // 而此时其他的线程拿到了锁又执行到此处 ==> 这些线程都会创建一个实例, 从而创建多个实例对象
                if (instance == null) {
                    instance = new DoubleCheckedLockingSingleton();
                }
            }
        }
        return instance;
    }
}

实现过程中需要注意的事项, 都在注视中作了说明.

4.2 DCL存在的问题

你以为到这里, 单例模式就安全了吗? 不是的!

在多处理器的共享内存、或者编译器的优化下, DCL模式并不一定线程 —— 可能 (注意: 只是可能出现) 会发生指令的重排序, 出现半个对象的问题.

(1) JVM在创建实例的时候, 是分为如下步骤创建的:

① 在堆内存中, 为新的实例开辟空间;
② 初始化构造器, 对实例中的成员进行初始化;
③ 把这个实例的引用 (也就是这里的instance) 指向①中空间的起始地址.

==> 也就是说, Java中创建一个对象的过程并不是原子性操作.

(2) 上述过程不是原子性的, 所以就可能出现:

JVM在优化代码的过程中, 可能对①-③这三个过程进行重排序 —— 因为 JVM会对字节码进行优化, 其中就包括了指令的重排序.

如果重排序后变为①③②, 就会出现一些难以捕捉的问题.

(3) 再来说说半个对象:

构造方法中有其他非原子性操作, 创建对象时只是得到了对象的正确引用, 而对象内部的成员变量可能还没有来得及赋值, 这个时候就可能访问到 "不正确(陈旧)" 的成员变量.

对引用类型 (包括对象和数组) 变量的非同步访问, 即使得到该引用的最新值, 也并不能保证能得到其成员变量 (对数组而言就是每个数组中的元素) 的最新值;

4.3 解决方法

在声明对象时通过关键字volatile, 禁止JVM对这个对象涉及到的代码重排序:

private static volatile DoubleCheckedLockingSingleton instance = null;

这里我们用volatile关键字修饰了instance变量, JVM就不会对instance的创建过程进行优化, 只要我们访问这个类的任意一个静态域, 就会创建这个类的对象.

关于volatile关键字的作用:

volatile关键字禁止了JVM的指令重排序, 并且保证线程中对这个变量所做的任何写入操作对其他线程都是即时可见的 (也就是保证了内存的可见性).

需要注意的是, 这两个特性是在JDK 5 之后才支持的.

—— 关于类的加载机制、volitale关键字的详细作用, 后续会有播客输出, 读者盆友们可以先去各大博客、论坛搜索研究下, 也可以查看文末的参考博客链接.

5 写法④ - 静态内部类实现单例

5.1 代码示例

静态内部类也称作Singleton Holder, 也就是单持有者模式, 是线程安全的, 也是懒惰模式的变形.

JVM加载类的时候, 有这么几个步骤:

①加载 -> ②验证 -> ③准备 -> ④解析 -> ⑤初始化

需要注意的是: JVM在加载外部类的过程中, 是不会加载静态内部类的, 只有内部类(SingletonHolder)的属性/方法被调用时才会被加载, 并初始化其静态属性(instance).

/**
 * 静态内部类模式, 也称作Singleton Holder(单持有者)模式: 线程安全, 懒惰模式的一种, 用到时再加载
 */
final class StaticInnerSingleton {
    /** 禁用构造方法 */
    private StaticInnerSingleton() { }

    /**
     * 通过静态内部类获取单例对象, 没有加锁, 线程安全, 并发性能高
     * @return SingletonHolder.instance 内部类的实例
     */
    public static StaticInnerSingleton getInstance() {
        return SingletonHolder.instance;
    }

    /** 静态内部类创建单例对象 */
    private static class SingletonHolder {
        private static StaticInnerSingleton instance = new StaticInnerSingleton();
    }
}

5.2 静态内部类的优势

比较推荐这种方式, 没有加锁, 线程安全, 用到时再加载, 并发行能高.

6 写法⑤ - 枚举类实现单例

6.1 代码示例

JDK 5开始, 提供了枚举(enum), 其实就是一个语法糖: 我们写很少的代码, JVM在编译的时候帮我们添加很多额外的信息.

通过对枚举类的反编译可以知道: 枚举类也是在JVM层面保证的线程安全.

/**
 * 枚举类单例模式
 */
enum EnumSingleton {
    /** 此枚举类的一个实例, 可以直接通过EnumSingleton.INSTANCE来使用 */
    INSTANCE
}

6.2 优缺点比较

(1) 优点: JVM对枚举类的特殊规定决定了:

① 不需要考虑序列化的问题: 枚举序列化是由JVM保证的, 每一个枚举类型和枚举变量在JVM中都是唯一的, 在枚举类型的序列化和反序列化上Java做了特殊的规定: 在序列化时Java仅仅是将枚举对象的name属性输出到结果中, 反序列化时只是通过java.lang.Enum#valueOf()方法来根据名字查找枚举对象 —— 编译器不允许对这种序列化机制进行定制、并且禁用了writeObject、readObject、readObjectNoData、writeReplace、readResolve等方法, 从而保证了枚举实例的唯一性;

② 不需要考虑反射的问题: 在通过反射方法java.lang.reflect.Constructor#newInstance()创建枚举实例时, JDK源码对调用者的类型进行了判断:

// 判断调用者clazz的类型是不是Modifier.ENUM(枚举修饰符), 如果是就抛出参数异常:
if ((clazz.getModifiers() & Modifier.ENUM) != 0)
    throw new IllegalArgumentException("Cannot reflectively create enum objects");

所以, 我们是不能通过反射创建枚举实例的, 也就是说创建枚举实例只有编译器能够做到.

关于JVM对枚举类的处理, 可以参考我的这篇文章: Java中枚举类型的使用 - enum.

(2) 缺点: 所有的属性都必须在创建时指定, 也就意味着不能延迟加载; 并且使用枚举时占用的内存比静态变量的2倍还多, 这在性能要求严苛的应用中是不可忽视的.

7 写法⑥ - 通过ThreadLocal实现单例

还是在 The "Double-Checked Locking is Broken" Declaration 这篇文章中, 发现了通过 ThreadLocal 修正DCL问题的思路: 每个线程都持有一个 ThreadLocal 标志, 用来确定该线程是否已完成所需的同步. 具体代码如下:

/**
 * 通过ThreadLocal实现单例模式, 性能可能比较低
 */
class ThreadLocalSingleton {
    /** 如果 perThreadInstance.get() 返回一个非空值, 说明当前线程已经被同步了: 它要看到instance变量的初始化 */
    private static ThreadLocal perThreadInstance = new ThreadLocal();
    private static ThreadLocalSingleton instance = null;

    public static ThreadLocalSingleton getInstance() {
        if (perThreadInstance.get() == null) {
            createInstance();
        }
        return instance;
    }

    private static final void createInstance() {
        synchronized (ThreadLocalSingleton.class) {
            if (instance == null) {
                instance = new ThreadLocalSingleton();
            }
        }
        // 任何非空的值都可以作为这里的参数
        perThreadInstance.set(perThreadInstance);
    }

    /**
     * 阿里代码规范提示: ThreadLocal变量应该至少调用一次remove()方法, 原因如下:
     * 必须回收自定义的ThreadLocal变量, 尤其在线程池场景下, 因为线程经常会被复用, 
     * 如果不清理自定义的 ThreadLocal变量, 可能会影响后续业务逻辑和造成内存泄露等问题.
     * 尽量在代理中使用try-finally块进行回收.
     */
    public static void remove() {
        perThreadInstance.remove();
    }

}

这种技术的性能在很大程度上取决于的JDK的版本. 在Sun JDK 1.2中, ThreadLocal性能非常慢, 而在1.3中性能明显提升了. 具体的性能对比, 参见下一节.

8 扩展: JDK中的单例 以及 如何破坏单例模式

8.1 JDK中常见的单例模式

(1) java.lang.Runtime类中的getRuntime()方法;

(2) java.awt.Toolkit类中的getDefaultToolkit()方法;

(3) java.awt.Desktop类中的getDesktop()方法;

(4) 另外, RuntimeException也是单例的 —— 因为一个Java应用只有一个Java Runtime Environment.

8.2 破坏单例模式的方法

(1) 除枚举方式外, 其他方法都会通过反射的方式破坏单例, 解决方法:

反射是通过调用构造方法生成新的对象, 可以在构造方法中进行判断 —— 若已有实例, 则阻止生成新的实例, 如:

private Singleton() throws Exception {
    if (instance != null) {
      throw new Exception("Singleton already initialized, 此类是单例类, 不允许生成新对象, 请通过getInstance()获取本类对象");
    }
}

(2) 如果单例类实现了序列化接口Serializable, 就可以通过反序列化破坏单例, 解决方法:

不实现序列化接口, 或者重写反序列化方法readResolve(), 反序列化时直接返回相关单例对象:

// 反序列化时直接返回当前实例
public Object readResolve() {
    return instance;
}

(3) Object#clone()方法也会破坏单例, 即使你没有实现Cloneable接口 —— 因为clone()方法是Object类中的. 解决方法是:

重写clone()方法, 并在其中抛出异常信息“Can not create clone of Singleton class”

9 扩展 - 性能对比

(1) 测试用的代码:

创建100个线程, 每个线程中循环获取10,000次单例对象, 统计各个类所用的时间.

public static void main(String[] args) throws InterruptedException {

    // 创建的线程数
    int threadNum = 100;
    // 循环获取对象的次数
    int objectNum = 10000;
  
    Long beginTime = System.currentTimeMillis();
    for (int i = 0; i < threadNum; i++) {
        new Thread(() -> {
            for (int j = 0; j < objectNum; j++) {
                Object o = HungrySingleton.getInstance();
            }
        }).start();
    }
    Long endTime = System.currentTimeMillis();
    System.out.println("HungrySingleton --- " + (endTime - beginTime) + " ms");

    // 省去一大串其他类的测试代码

    beginTime = System.currentTimeMillis();
    for (int i = 0; i < threadNum; i++) {
        new Thread(() -> {
            for (int j = 0; j < objectNum; j++) {
                Object o = EnumSingleton.INSTANCE;
            }
        }).start();
    }
    endTime = System.currentTimeMillis();
    System.out.println("EnumSingleton --- " + (endTime - beginTime) + " ms");

}

说明:

这个测试代码的重复性太高了, 本来想封装成方法、通过反射进行不同类和方法的调用的, 可考虑到反射的性能损耗, 一时又想不到其他好点的方法, 所以不得已采取了这种. 各位看官请别喷, 有好点的方法可以在留言区交流下????

(2) 测试结果, 单位是毫秒(ms):

不同的模式 第一次 第二次 第三次 平均耗时
饥饿模式 (HungrySingleton) 59 61 62 61
线程安全的懒惰模式 (LazySingleton) 27 10 41 26
双重检查锁模式 (DoubleCheckedLockingSingleton) 12 14 12 13
静态内部类模式 (StaticInnerSingleton) 12 13 22 16
枚举类模式 (EnumSingleton) 8 10 10 9
线程本地变量 (ThreadLocalSingleton) 21 26 24 24

运行多次, 发现结果不太稳定, 暂时未找到原因, 所以就不总结了, 各位看官权当参考, 还请存疑????

呼, Java中的单例模式终于整理完了, 由于个人经验有限, 肯定存在很多疏漏, 如果你在浏览的时候发现问题, 请直接在评论区指出来, 拜谢各位.


参考资料

(一)单例模式详解

volatile关键字到底做了什么?

The "Double-Checked Locking is Broken" Declaration

深入理解Java枚举类型(enum)

版权声明

作者: 马瘦风

出处: 博客园 马瘦风的博客

感谢阅读, 如果文章有帮助或启发到你, 点个[好文要顶????] 或 [推荐????] 吧????

本文版权归博主所有, 欢迎转载, 但 [必须在文章页面明显位置给出原文链接], 否则博主保留追究相关人员法律责任的权利.