创建型-单例模式
如果,有人问我开发中用过哪些设计模式,我脑海里第一个浮现出的是:单例模式。
它是 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% 。