一、什么是单例模式?

单例模式(Singleton),保证一个类仅有一个实例,并提供一个访问它的全局访问点。——《大话设计模式》

单例模式是在内存中 仅会创建一次对象 的设计模式
在这里插入图片描述

简单来说单例模式的简单实现就是
成员是 私有的静态的
构造方法是 私有的
对外暴露的获取访问是 公有的静态的

单例模式分类

  • 饿汉式:类加载就会导致该单实例对象被创建

  • 懒汉式:类加载不会导致该单实例对象被创建,而是首次使用该对象时被创建

饿汉式创建单例对象

饿汉式在类加载时已经创建好该对象,在程序调用时直接返回该单例对象即可,即我们在编码时就已经指明了要马上创建这个对象,不需要等到被调用时再去创建。

饿汉式简单实现代码如下:

package org.example;

/**
 * @Author:wjy
 */
 
public class Singleton{

    //在该类中创建一个该类的对象供外界去使用
    private static Singleton instance= new Singleton();

    // 构造方法 private 化
    private Singleton(){

    }

    // 得到 Singleton 的实例(唯一途径)
    public static Singleton getInstance() {
        return instance;
    }
}
懒汉式创建单例对象

懒汉式创建对象的方法是在程序使用对象前,先判断该对象是否已经实例化(判空),若已实例化直接返回该类对象。,否则则先执行实例化操作。

懒汉式简单实现代码如下:

package org.example;

/**
 * @Author:wjy
 */
public class Singleton {

    //在该类中创建一个该类的对象供外界去使用
    private static Singleton instance;

    // 构造方法 private 化
    private Singleton(){ 

    }

    // 得到 Singleton 的实例(唯一途径)
    public static Singleton getInstance(){
        if (instance == null){
            instance = new Singleton();
        }
        return instance;
    }
}

客户端代码:

public class Main {
    public static void main(String[] args) {
        // Singleton s0 = new Singleton(); // 原先的实例化方法
        Singleton s1 = Singleton.getInstance();
        Singleton s2 = Singleton.getInstance();

        if (s1 == s2){
            System.out.println("两个对象是相同的实例");
        }
    }
}

可以看到打印结果是同一对象

在这里插入图片描述

多问一个为什么?

此处,我们可能在潜意识中就这样让代码过去,也许是因为认为这就是规定或者认为是很基础的内容而无需解释过多,在许多文章中,也并未更加详细的解释这个问题.
但是,如果是作为一个可能基础不那么稳固的初学者(比如本人T-T),在学习的时候,为加深理解,我们不妨多问一个为什么?为什么是这样设计呢?

  1. 成员变量 instance 为什么是私有的?
    最直接的原因就是 防止外部直接访问,如果不声明为 private ,其他类将能直接访问和修改它,而这就违反了单例模式的原则,因为单例模式要求类只有一个实例化
  2. 成员变量 instance 为什么是静态的?
    使用 static 修饰后,意味着该变量是属于类的,而不是类的实例
    你可以通过 类名.变量名 的方式直接访问
    同时,将只存在一个 instance
    你可以像
    Singleton s1 = Singleton.getInstance();
    Singleton s2 = Singleton.getInstance();
    这样创建两个实例化对象,但他们都共享一个 instance 对象,确保只有一个单例
  3. 构造方法 Singleton() 为什么是私有的?
    与成员变量相似,是为了防止外部实例化 ,防止其他类通过 new 关键字直接创建该类的实例(new 关键字本质是调用了类的构造方法),而应该通过特定途径(通常是类提供的静态方法,也就是 getInstance()
  4. getInstance() 方法为什么是公有的?
    很显然,这是我们暴露给其他类调用来创建实例的方法,因此必须是公有的,如果是私有那不就是成黑盒搁这里圈地自萌了~
  5. getInstance() 方法为什么是静态的?
    与成员变量相似,因为构造方法被私有化了,我们无法通过 new 关键字来实例化对象,而通过 类名.方法名 的方式可以直接访问

好的,我们已经明白了一个简单的单例模式的基本实现,了解了单例模式是什么,而在我们继续深入单例模式的各种实现之前,我们加入一个小插曲,你也许会有这样的疑问,我们为什么要有单例模式呢?单例模式应用场景有哪些?

二、为什么要有单例模式?

使用单例模式的原因

  1. 资源控制:单例模式可以用来控制系统中的资源,例如数据库连接池或线程池,确保这些关键资源不会被过度使用。

  2. 内存节省:当需要一个对象进行全局访问,但创建多个实例会造成资源浪费时,单例模式可以确保只创建一个实例,节省内存

  3. 共享:单例模式允许状态或配置信息在系统的不同部分之间共享,而不需要传递实例。

  4. 延迟初始化:单例模式支持延迟初始化,即实例在首次使用时才创建,而不是在类加载时。

  5. 一致的接口:单例模式为客户端提供了一个统一的接口来获取类的实例,使得客户端代码更简洁。

  6. 易于维护:单例模式使得代码更易于维护,因为所有的实例都使用相同的实例,便于跟踪和修改变更。

单例模式的应用场景

  1. 配置管理器:在应用程序中,配置信息通常只需要读取一次,并全局使用。单例模式用于确保配置管理器只被实例化一次。

  2. 日志记录器:一个系统中通常只需要一个日志记录器来记录所有的日志信息,使用单例模式可以避免日志文件的重复写入。

  3. 数据库连接池:数据库连接是一种有限的资源,使用单例模式可以确保数据库连接池的唯一性,并且能够重用连接,减少连接创建和销毁的开销。

  4. 线程池:类似于数据库连接池,线程池也是有限的资源,使用单例模式可以避免创建过多的线程,提高应用程序的并发性能。

  5. 任务调度器:在需要全局调度和管理的场景下,如定时任务调度器,单例模式提供了一个集中的管理方式。

  6. 网站的计数器:一般也是采用单例模式实现,否则难以同步。

值得注意的是,在许多框架中,单例模式也有广泛的应用,比如Spring,可以看看这篇文章

说一说Spring中的单例模式

总之,单例模式在确保资源有效管理、减少不必要的开销、以及提供全局访问控制方面有着广泛的应用。当然,滥用单例模式也可能导致代码的灵活性和可测试性降低,因此应当在适当的场景下谨慎使用。


好的我们在知晓单例模式的强大之处后,继续深入探讨一下单例模式的实现问题吧!

三、多线程下的单例模式

让我们编写一个测试,让三个线程去尝试实例化:

package org.example;

/**
 * @Author:wjy
 */
public class Main1 {
    public static void main(String[] args) {
        // 创建两个线程,尝试创建两个 Singleton 实例
        Thread t1 = new Thread(() -> {
            Singleton s = Singleton.getInstance();
            System.out.println("Thread 1 created instance: " + s);
        });

        Thread t2 = new Thread(() -> {
            Singleton s = Singleton.getInstance();
            System.out.println("Thread 2 created instance: " + s);
        });

        Thread t3 = new Thread(() -> {
            Singleton s = Singleton.getInstance();
            System.out.println("Thread 3 created instance: " + s);
        });

        // 启动线程
        t1.start();
        t2.start();
        t3.start();


        // 等待线程结束
        try {
            t1.join();
            t2.join();
            t3.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

    }
}

饿汉式

因为饿汉式单例模式下,单例实例在类加载的时候就已经创建,并在静态化容器中完成初始化,所以他通常是线程安全的

在这里插入图片描述

可以看到,三个线程获取到的实例是相同的

懒汉式

接着是懒汉式单例模式
首先,让我们回顾一下核心的 getInstance 方法实现:

    // 得到 Singleton 的实例(唯一途径)
    public static Singleton getInstance(){
        if (instance == null){
            instance = new Singleton();
        }
        return instance;
    }

测试一下,会发现出现问题,创建了三个实例!!

在这里插入图片描述

这是因为,如果三个线程在调用 getInstance() 方法时
同时判断 instance 为空,那么它们都会去实例化一个 instance 对象,这就变成三例了。如图所示:

在这里插入图片描述
在这里插入图片描述
所以,我们要解决的是线程安全问题。

懒汉加锁

当然,我们最容易想到的呢就是在方法上加锁,或者对类对象进行加锁


public static synchronized Singleton getInstance() {
    if (instance== null) {
        instance= new Singleton();
    }
    return singleton;
}

// 或者

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

通过 synchronized 关键字,可以让每个线程在进入方法之前,都要等到别的线程都离开此方法,不会有两个线程同时进入此方法

在这里插入图片描述

可以看到,此时三个线程获取到的实例都是同样的了

但是这带来了新的问题:每次去获取对象都需要先获取锁,并发性能非常地差

加锁优化(双重检验加锁)

接下来要做的就是优化性能,目标是:

  • 如果已经实例化了,则不需要加锁
  • 如果没有实例化对象则加锁,然后再判断一次有没有实例化
    • 如果实例化了就直接返回
    • 如果没有实例化就再次加锁

在这里插入图片描述

所以直接在方法上加锁的方式就被废掉了,因为这种方式无论如何都需要先获取锁

所以优化的关键点其实是在于 加锁的时机

    public static Singleton getInstance(){
        if(instance == null){ // 检查实例是否存在,不存在则进入下一步
            synchronized (Singleton.class){  // 防止多个线程同时进入创建实例
                if(instance == null){
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }

上面的代码已经完美地解决了 并发安全+性能低效 问题:

  • 第2行代码,如果 instance 不为空,则直接返回对象,不需要获取锁;而如果多个线程发现 instance 为空,则进入分支;
  • 第3行代码,多个线程尝试争抢同一个锁,只有一个线程争抢成功,第一个获取到锁的线程会再次判断 instance 是否为空,因为 instance 有可能已经被之前的线程实例化
  • 其它之后获取到锁的线程在执行到第4行校验代码,发现 instance 已经不为空了,则不会再 new 一个对象,直接返回对象即可
  • 之后所有进入该方法的线程都不会去获取锁,在第一次判断 instance 对象时已经不为空了

也就是说,我们通过 多加一次初始化检验判空,避免了不管有没有初始化,每次都要加锁的并发性能差的问题

一图胜千言,如图,假设这是初次实例化

  1. 三个线程都进行实例化判断,发现实例不存在,进入三线程的三国争霸(抢锁)!!

在这里插入图片描述

  1. 线程 C 抢赢了,获取到了锁,然后进行判断实例是否存在,发现不存在,直接 new 一个 —— 该单例就被线程 C 创建成功!
    在这里插入图片描述

  2. 线程 C 退出了,然后归还了锁,此时在锁外面双眼红光,饥渴难耐的线程 B 立马就抢了锁,在他正准备大干一场创建实例时,判断发现已经创建过了,实例 instance 存在,大败而归!

在这里插入图片描述

  1. 线程A 同理,接着线程 ABC就都退出了本次的 getInstance() 实例争霸赛

这里再着重讲述一下,为什么需要在加锁后再次进行实例化的判断呢?
根据上述步骤3,可以发现如果不进行判断,刚拿到锁的线程 B 会再次创建一个实例,此时就会创建两个实例,违背了单例模式的要求

因为需要两次判空,且对类对象加锁,该懒汉式写法也被称为:Double Check(双重校验) + Lock(加锁)

使用volatile防止指令重排

创建一个对象,在JVM中会经过三步:

(1)为 instance 分配内存空间

(2)初始化 instance 对象

(3)将 instance 指向分配好的内存空间

而指令重排序是指:JVM在保证最终结果正确的情况下,可以不按照程序编码的顺序执行语句,尽可能提高程序的性能

通俗点来讲,就是先给 instance 找到住的地方,然后把 instance 安排入住,最后给 instance 贴上门牌号
而指令重排序也就是为了提高效率,可能在为 instance 找到住的地方后,先不让他住进去,而是先贴门牌号再让他住进去

在这三步中,第2、3步有可能会发生指令重排现象,创建对象的顺序变为1-3-2,会导致多个线程获取对象时,有可能线程A创建对象的过程中,执行了1、3步骤时,

线程 B 判断 instance 已经不为空,获取到还未初始化的 instance 对象并返回,这就会报 NPE 异常,也就是空指针异常。

注意在 if(instance == null)== 操作符判断的是该对象的内存地址,也就是步骤 3
还是门牌号的例子,== 就相当于检查门牌号,然后线程 B 发现你家有门牌号,就默认你房间里有人了,就把你房费账单提交了,但其实这时候你甚至还没入住!!

文字较为晦涩,可以看流程图:

在这里插入图片描述
使用 volatile 关键字可以防止指令重排序,​其原理较为复杂,这篇博客不打算展开,可以这样理解:使用 volatile 关键字修饰的变量,可以保证其指令执行的顺序与程序指明的顺序一致,不会发生顺序变换,这样在多线程环境下就不会发生NPE异常了。

最终的代码如下:

public class Singleton {

    private static volatile Singleton instance;

    // 构造方法 private 化
    private Singleton(){

    }

    // 得到 Singleton 的实例(唯一途径)
    public static Singleton getInstance(){
        if(instance == null){ // 检查实例是否存在,不存在则进入下一步
            synchronized (Singleton.class){  // 防止多个线程同时进入创建实例
                if(instance == null){
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

四、破坏懒汉式单例与饿汉式单例

无论是完美的懒汉式还是饿汉式,终究敌不过反射和序列化,它们俩都可以把单例对象破坏掉(产生多个对象)。

反射破坏

  1. 演示利用反射破坏单例模式
  public static void main(String[] args) throws Exception {
        //获取 Singletion 类的字节码;
        Class<Singleton> singletonClass = Singleton.class;
        //获取无参构造方法,用来创建对象。
        Constructor con = singletonClass.getDeclaredConstructor();
        //由于是private修饰,所以使用暴力破解;
        con.setAccessible(true);
        //创建对象
        Singleton s1 = (Singleton) con.newInstance();
        Singleton s2 = (Singleton) con.newInstance();
        System.out.println(s1 == s2); // 返回 false
    }

上述的代码一针见血了:利用反射,强制访问类的私有构造器,去创建另一个对象

序列化与反序列化破坏

  1. 利用序列化与反序列化破坏单例模式
public static void main(String[] args) {
    // 创建输出流
    ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("Singleton.file"));
    // 将单例对象写到文件中
    oos.writeObject(Singleton.getInstance());
    // 从文件中读取单例对象
    File file = new File("Singleton.file");
    ObjectInputStream ois =  new ObjectInputStream(new FileInputStream(file));
    Singleton newInstance = (Singleton) ois.readObject();
    // 判断是否是同一个对象
    System.out.println(newInstance == Singleton.getInstance()); // false
}

两个对象地址不相等的原因是:readObject() 方法读入对象时,它必定会返回一个新的对象实例,必然指向新的内存地址。

原因分析

实际上对于序列化与反序列化破坏单例模式的问题,主要是通过readObject()方法,出现了破坏单例模式的现象,主要是因为这个方法最后会通过反射调用无参数的构造方法创建一个新的对象,从而每次返回的对象都不一致。

对于反射破坏单例模式是因为单例模式通过 setAccessible(true) 指示反射的对象在使用时,取消了 Java 语言访问检查,使得私有的构造函数能够被访问,

而单例模式的设计在于只保留一个公有静态函数来获取唯一的实例,其他方法(构造函数)或字段为私有,外界不能访问。

而反射破坏了这一原则,它突破了构造函数私有的限制,可以获取单例类的私有构造函数并使用其创建多个对象。

问题解决

● 序列化、反序列方式破坏单例模式的解决方法
readObject()方法的调用栈的底层方法中有这么两个方法:
hasReadResolveMethod:
表示如果实现了 serializable 或者 externalizable 接口的类中包含 readResolve 则返回 true

invokeReadResolve :通过反射的方式调用要被反序列化的类的readResolve方法。

详细讲解可以看这篇文章:
设计模式|序列化、反序列化对单例的破坏、原因分析、解决方案及解析

所以,原理也就清楚了,主要在Singleton中定义readResolve方法,并在该方法中指定要返回的对象的生成策略,就可以防止单例被破坏。

public class Singleton implements Serializable {
    private static volatile Singleton singleton;

    private Singleton() {
    }

    public static Singleton getSingleton() {
        if (singleton == null) {
            synchronized (Singleton.class) {
                if (singleton == null) {
                    singleton = new Singleton();
                }
            }
        }
        return singleton;
    }

	// 防止序列化
    private Object readResolve() {
        return singleton;
    }

}

● 反射方式破解单例的解决方法
因为反射是一种暴力获取对象实例的方法,因为他可以直接访问private修饰的构造函数,所以在对于反射方式破坏单例模式的问题上我们只能采取被动的防御,

既然你能访问我的构造函数,我就在我的构造函数中建立防御机制,不让你通过我的构造函数创建多个实例对象。

在构造方法来添加一些限制,如果存在实例,那么就抛出异常!

public class Singleton{
    private static volatile Singleton singleton;

    private Singleton() {
        if (Singleton.singleton != null) {
            throw new RuntimeException("不允许创建多个单例!");
        }
    }

    public static Singleton getSingleton() {
        if (singleton == null) {
            synchronized (Singleton.class) {
                if (singleton == null) {
                    singleton = new Singleton();
                }
            }
        }
        return singleton;
    }

}

五、枚举实现

我们已经掌握了懒汉式与饿汉式的常见写法了,在《图解设计模式》以及《大话设计模式》中,并没有再做更多的涉及。但是,追求极致的我们,怎么能够止步于此,在《Effective Java》书中,给出了终极解决方法。

在 JDK1.5 后,使用 Java 语言实现单例模式的方式又多了一种:枚举

我们先来看看枚举如何实现单例模式的,如下代码:

public enum Singleton {
     INSTANCE;
     public void businessMethod() {
          System.out.println("我是一个单例!");
     }
}

枚举类实现单例模式是极力推荐的单例实现模式,因为枚举类型是线程安全的,并且只会装载一次,设计者充分的利用了枚举的这个特性来实现单例模式,枚举的写法非常简单,而且枚举类型是所用单例实现中唯一一种不会被破坏的单例实现模式。

我们可以简单地理解枚举实现单例的过程:在程序启动时,会调用Singleton的空参构造器,实例化好一个Singleton对象赋给INSTANCE,之后再也不会实例化

  1. 防止反射攻击
  • 枚举类型的实例在编译后会被编译成静态常量,并且在运行时通过Enum.valueOf()方法来获取实例。这个方法在内部会检查传递的名称是否与枚举定义中的名称匹配,并且直接返回对应的枚举实例,而不允许创建新的枚举实例。
  • 因此,即使调用者尝试使用反射创建新的枚举实例,也会因为名称不匹配而失败。例如,Enum.valueOf(MyEnum.class, "NEW_VALUE")将会抛出IllegalArgumentException
  1. 防止序列化/反序列化攻击
  • 枚举实例在序列化时会被转换为其名称,而不是实例本身。这意味着,即使反序列化时提供了枚举实例的字节流,也会根据名称来查找对应的枚举实例,而不是创建一个新的实例。
  • 由于枚举实例是静态的,反序列化时并不会创建新的实例,而是恢复之前序列化时的那个实例。这样,即使通过序列化/反序列化机制,也无法创建新的枚举实例。
  1. 防止直接实例化
  • 枚举类型默认有一个私有构造器,这意味着不能直接实例化枚举类型,只能通过枚举的静态方法来获取实例。
  • 例如,对于public enum MyEnum { INSTANCE },你不能写代码MyEnum myEnum = new MyEnum();,因为这会编译错误,因为MyEnum的构造器是私有的。

通过这些机制,枚举类有效地阻止了通过反射、序列化和反序列化机制来破坏单例的行为。这使得枚举类成为实现单例模式的一种非常安全和简洁的方法。


六、总结

(1)单例模式常见的写法有两种:懒汉式、饿汉式

(2)懒汉式:在需要用到对象时才实例化对象,正确的实现方式是:Double Check + Lock,解决了并发安全和性能低下问题

(3)饿汉式:在类加载时已经创建好该单例对象,在获取单例对象时直接返回对象即可,不会存在并发安全和性能问题。

(4)在开发中如果对内存要求非常高,那么使用懒汉式写法,可以在特定时候才创建该对象;

(5)如果对内存要求不高使用饿汉式写法,因为简单不易出错,且没有任何并发安全和性能问题

(6)为了防止多线程环境下,因为指令重排序导致变量报NPE,需要在单例对象上添加volatile关键字防止指令重排序

(7)最优雅的实现方式是使用枚举,其代码精简,没有线程安全问题,且 Enum 类内部防止反射和反序列化时破坏单例

参考文章:
我给面试官讲解了单例模式后,他对我竖起了大拇指!
设计模式之单例模式(七种方法超详细)
设计模式|序列化、反序列化对单例的破坏、原因分析、解决方案及解析

Logo

开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!

更多推荐