设计模式 | 单例模式

创建型-单例模式

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

它是 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%

相关推荐
小码编匠1 小时前
一款 C# 编写的神经网络计算图框架
后端·神经网络·c#
AskHarries1 小时前
Java字节码增强库ByteBuddy
java·后端
佳佳_1 小时前
Spring Boot 应用启动时打印配置类信息
spring boot·后端
许野平3 小时前
Rust: 利用 chrono 库实现日期和字符串互相转换
开发语言·后端·rust·字符串·转换·日期·chrono
58沈剑4 小时前
80后聊架构:架构设计中两个重要指标,延时与吞吐量(Latency vs Throughput) | 架构师之路...
架构
BiteCode_咬一口代码4 小时前
信息泄露!默认密码的危害,记一次网络安全研究
后端
齐 飞4 小时前
MongoDB笔记01-概念与安装
前端·数据库·笔记·后端·mongodb
LunarCod4 小时前
WorkFlow源码剖析——Communicator之TCPServer(中)
后端·workflow·c/c++·网络框架·源码剖析·高性能高并发
码农派大星。5 小时前
Spring Boot 配置文件
java·spring boot·后端
杜杜的man6 小时前
【go从零单排】go中的结构体struct和method
开发语言·后端·golang