设计模式 | 单例模式

创建型-单例模式

如果,有人问我开发中用过哪些设计模式,我脑海里第一个浮现出的是:单例模式

它是 23 种设计模式中,属于创建型模式,最为简单的一种设计模式,也是最常用的一种设计模式。这种模式确保同类只有一个实例存在,并提供应用程序的任何其他部分对这个类进行单点访问。

如果觉得抽象的话,我可以举一个映射生活中的例子:

一所大学里,一般只有一个官方校领导团或一名官方校长。无论组成该学校的领导班子是一位校长还是一个团队,它的头衔都是全球性的标签,具备全球访问性的,用于一个学校的代表者或者负责人。

当国内或者国外校领导访问这个学校,或者这个学校被访问的时候,通常都是这个头衔之间的负责人进行访问和交流。

所以,单例代表了一个全局标识的作用,它作为整个系统完全相同的想法,就像全局变量一样,它允许应用程序在任何位置都可以访问到。

更重要的是,单例将对象的属性封装在一个类中,并保证只有该类的一个实例会被访问,且在任何时间点都可用。

看到这里,小伙伴可能会有疑问,简单吗?常用吗?我咋写了这么多代码都没用过。

如果你是后端的同学,在写业务逻辑需要连接数库查表的时候,我们不可能每一次查询都得要实例化一次数据库实例,这样会极大的消耗性能,如果访问量堆叠起来的时候会是一件很恐怖的事情。

当然你耍赖也可以,我就是面向直接开发,想取数据库我直接实例化取完就释放掉,简单粗暴,写完下班。

如果你身处一个不需要对资源,性能,安全性,稳定性过度考虑的环境,那我觉得你可以瞎搞,这可能更适合你。

假如你换了环境,加入了另一个团队,对资源,性能,安全性,稳定性都需要严谨考虑的时候,你会束手无策,面临一个怎样都想不通的 bug。

你会觉得无辜,动了一个小地方就触动了大动脉,然而你的同事一眼就看出你问题所在的时候,他打心底里怀疑你, 同时也会反省自己和你是怎么合作到一块的。这个时候你如果还想继续走下去,你还是会回来恶补知识。

好,我们回到单例模式,使用访问数据库案例作为一个实例。

如上图左侧图示,创建一个单例数据库实例并重用,它每次访问数据库时都会使用同一个实例,这样做我们不仅保证了更快地访问和检索实例了,而且还减少了这几个在创建实例时遇到的错误或异常的可能性。

因为图右示例中,如果发生大量访问数据库时,不断地创建实例,也有可能发现资源抢占的情况导致创建实例失败。

Singleton 案例

我们可以写一个 demo 进行实操,定义一个 Singleton 类,这个类的属性和构造函数**都是私有,不提供外界更改的可能,保证输出的实例是同一个。

同时我们定义一个提供外界访问的方法 GetInstance(),需要使用的话只需要在单例中创建的公共静态方法来访问它。

kotlin 复制代码
public class Singleton
{
    //单例内属性保持私有,不提供外界更改的可能;
    private static Singleton instance;
    private string data;
    private Singleton(string data)
    {
        this.data = data;
    }
    //提供外界访问的方法,返回同一实例
    public static Singleton GetInstance(string data)
    {
        instance = new Singleton(data);
        return instance;
    }
}

图示代码,当应用程序其他地方调用 GetInstance() 方法的时候,我们无法确保使用的都是同一个实例,因为每调用一次都是一次 new 实例化。

一阶优化

但是我们可以进行语句检查,在静态函数**中判断 instance 是否为 null,也就是确保 instance 是否被创建过,且仅当 null 的时候我们才去重新创建。

csharp 复制代码
//提供外界访问的方法,返回同一实例
public static Singleton GetInstance(string data)
{
    if (instance == null)
    {
        instance = new Singleton(data);
    }
    return instance;
}

改进之后,舒服多了。细心的伙伴会发现,这样的代码不够稳定,因为面对多线程情况是无法阻止多个访问 GetInstance() 静态方法,并且存在返回两个不同 instance 对象。

二阶优化

因此,我们需要给静态实例方法 GetInstance() 增加一个锁,在每次创建 Singleton 实例时,都必须等待轮到它的线程才能进入创建,只能在给定时间内创建实例。

csharp 复制代码
//提供外界访问的方法,返回同一实例
public static Singleton GetInstance(string data)
{
    lock (typeof(Singleton))
    {
        if (instance == null)
        {
            instance = new Singleton(data);
        }
    }
    return instance;
}

三阶优化

解决了线程安全之后,我们发现还是会增加额外不必要的开销。因为我们看到在创建单例实例时,当我们的应用程序的线程尝试访问它时,它首先必须等待轮到我们的类获得锁。并且只有当他们需要离开时,他们才会检查该实例是否可用。

这种情况不仅出现在单例模式上,只要有线程的场景都会面临多重检查对象是否被创建的问题。

这里或许有些抽象,那我们逐一来分析,首先我们获取锁然后释放锁的过程是这样的:获取锁,执行代码块,离开代码块。

这个生命周期好理解。但是离开代码块环节,线程执行完 lock 或者 Synchronized** 关键字所标记的代码块后,应用程序离开代码块时,并不会直接检查该实例是否可用。

相反,一旦线程执行完 lock 或 Synchronized 块中的代码,它会释放锁。这意味着其他线程可以立即尝试获取相同锁并执行相关的代码块。这样会导致多个线程同时检查实例是否被创建,从而导致重复创建多个实例,进而破坏了单例模式的预期。

为了解决这个问题,我们常用的手法是:双重检查锁定(Double-Checked Locking)惯用法。

csharp 复制代码
//提供外界访问的方法,返回同一实例
public static Singleton GetInstance(string data)
{
    if (instance == null)
    {
        lock (typeof(Singleton))
        {
            if (instance == null)
            {
                instance = new Singleton(data);
            }
        }
    }
    return instance;
}

好吧,代码非常简单,只是在进入锁之前进行一次检查。具体做出如下步骤:

我第一次检查:

在没有获取锁的情况下,检查对象是否已经被创建。如果对象已经存在,那么直接返回该对象。

获取锁:

如果对象不存在,那么就尝试获取同步锁。只有获取了锁的线程才能进入临界区**。这样做是为了确保只有一个线程能够创建对象,而其他线程在等待锁时不会创建多个实例。

第二次检查:

在获取了锁之后,再次检查对象是否已经被创建。这是必要的,因为在等待锁的过程中,其他线程可能已经创建了对象。如果对象已经存在,那么释放锁并返回已创建的对象;如果对象仍未创建,那么创建对象,并在临界区内赋值,然后释放锁。

到了这里,我们的代码好起来已经很完美很安全了。但是思前想后,还是不对劲。

四阶优化

考虑到两个线程 A 和 B 尝试访问 GetInsance() 方法,A 是第一个访问该方法的线程,这个时候 instance 还并未被初始化,因此 A 获取了锁,并开始初始化 instance 的值。

通常会有某些编程语言的语义,使得编译器生成的代码可以更新存储在内存中的共享变量,得以引用部分构造函数的对象。

因此在线程 A 完成整个初始化时,我们的共享变量或者单例实例可以立即在内存中更新,那么应用程序中的其他线程就会视为已经初始化并且使用它。

因此,在这个示例中,我们的 A 正在构造 instance 实例时,B 此时可能正在尝试检索它并且由于我们在外面新增了一个 if 判断,线程 B 并不会等待 A 实例完成初始化,这个时候线程 B 可能会引用内存中的部分构造对象,导致我们的应用程序发生崩溃。

arduino 复制代码
//提供外界访问的方法,返回同一实例
private static volatile Singleton instance;

因此我们常用的解决方法是将实例设置为 volatile 易失性,该关键字确保多个线程能够正确处理单例实例。

到这里,我们已经实现了一个完整有效的单例模式解决方案。但是我们仍然可以做一些微优化来增强性能。

现在该实例被标记为易失性,因此每次访问该变量时,都需要直接从主内存中读取它,因为它无法缓存,即使在代码块里已经初始化了变量,我们也必须获取第一次在 if 检查是否为 null,然后 return 出去。

为了避免这么做,我们通常可以仅访问内存一次,就是在第一次从内存检索实例时将实例存储在局部变量中,然后 if 语句和 return 语句中使用相同的局部变量。

相同的逻辑和思维方式可以在同步块内部扩展和使用,以避免多次直接读取内存。这种局部变量的简单使用可以将方法的整体性能提升至 40%

相关推荐
顽石九变10 分钟前
【SpringBoo3】SpringBoot项目Web拦截器使用
spring boot·后端
梦兮林夕28 分钟前
从零掌握 Gin 参数解析与验证
后端·go·gin
bobz96538 分钟前
IPSec IKE PSK 与扩展支持Xauth账户密码
后端
supermodule38 分钟前
基于flask的一个数据展示网页
后端·python·flask
31535669131 小时前
manus邀请码申请手把手教程
前端·后端·面试
青石路1 小时前
经由同个文件多次压缩的文件MD5都不一样问题排查,感慨AI的强大!
java·后端
RainbowSea1 小时前
5. MySQL 存储引擎(详解说明)
数据库·后端·mysql
RainbowSea1 小时前
130道基础OJ编程题之: 68\~77
java·后端
庄园特聘拆椅狂魔3 小时前
SpringBoot项目中注解使用规范
java·spring boot·后端
架构文摘JGWZ3 小时前
Spring40种注解(下)!!
后端·学习·spring