前言
这一篇来源我的公众号,如果你没看过,正好直接看看,如果看过了也可以再看看,我稍微修改了一些内容,今天讲解的内容如下:
一、什么是单例模式
【单例模式】,英文名称:Singleton Pattern,这个模式很简单,一个类型只需要一个实例,他是属于创建类型的一种常用的软件设计模式。通过单例模式的方法创建的类在当前进程中只有一个实例(根据需要,也有可能一个线程中属于单例,如:仅线程上下文内使用同一个实例)。
(推荐视频:java视频教程)
单例类只能有一个实例。
单例类必须自己创建自己的唯一实例。
单例类必须给所有其他对象提供这一实例。
那咱们大概知道了,其实说白了,就是我们整个项目周期内,只会有一个实例,当项目停止的时候,实例销毁,当重新启动的时候,我们的实例又会产品。
上文中说到了一个名词【创建类型】的设计模式,那什么是创建类型的设计模式呢?
创建型(Creational)模式:负责对象创建,我们使用这个模式,就是为了创建我们需要的对象实例的。
那除了创建型还有其他两种类型的模式:
结构型(Structural)模式:处理类与对象间的组合
行为型(Behavioral)模式:类与对象交互中的职责分
这两种设计模式,以后会慢慢说到,这里先按下不表。
咱们就重点从0开始分析分析如何创建一个单例模式的对象实例。
二、如何创建单例模式
实现单例模式有很多方法:从“懒汉式”到“饿汉式”,最后“双检锁”模式,这里咱们就慢慢的,从一步一步的开始讲解如何创建单例。
1、正常的思考逻辑顺序
既然要创建单一的实例,那我们首先需要学会如何去创建一个实例,这个很简单,相信每个人都会创建实例,就比如说这样的:
///
/// 定义一个天气类
///
public class WeatherForecast
{
public WeatherForecast()
{
Date = DateTime.Now;
}
public DateTime Date { get; set; }
public int TemperatureC { get; set; }
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
public string Summary { get; set; }
}
[HttpGet]
public WeatherForecast Get()
{
// 实例化一个对象实例
WeatherForecast weather = new WeatherForecast();
return weather;
}
我们每次访问的时候,时间都是会变化,所以我们的实例也是一直在创建,在变化:
相信每个人都能看到这个代码是什么意思,不多说,直接往下走,我们知道,单例模式的核心目的就是:
必须保证这个实例在整个系统的运行周期内是唯一的,这样可以保证中间不会出现问题。
那好,我们改进改进,不是说要唯一一个么,好说!我直接返回不就行了:
///
/// 定义一个天气类
///
public class WeatherForecast
{
// 定义一个静态变量来保存类的唯一实例
private static WeatherForecast uniqueInstance;
// 定义私有构造函数,使外界不能创建该类实例
private WeatherForecast()
{
Date = DateTime.Now;
}
///
/// 静态方法,来返回唯一实例
/// 如果存在,则返回
///
///
public static WeatherForecast GetInstance()
{
// 如果类的实例不存在则创建,否则直接返回
// 其实严格意义上来说,这个不属于【单例】
if (uniqueInstance == null)
{
uniqueInstance = new WeatherForecast();
}
return uniqueInstance;
}
public DateTime Date { get; set; }public int TemperatureC { get; set; }
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
public string Summary { get; set; }
}
然后我们修改一下调用方法,因为我们的默认构造函数已经私有化了,不允许再创建实例了,所以我们直接这么调用:
[HttpGet]
public WeatherForecast Get()
{
// 实例化一个对象实例
WeatherForecast weather = WeatherForecast.GetInstance();
return weather;
}
最后来看看效果:
这个时候,我们可以看到,时间已经不发生变化了,也就是说我们的实例是唯一的了,大功告成!是不是很开心!
但是,别着急,问题来了,我们目前是单线程的,所以只有一个,那如果多线程呢,如果多个线程同时访问,会不会也会正常呢?
这里我们做一个测试,我们在项目启动的时候,用多线程去调用:
[HttpGet]
public WeatherForecast Get()
{
// 实例化一个对象实例
//WeatherForecast weather = WeatherForecast.GetInstance();
// 多线程去调用
for (int i = 0; i < 3; i++)
{
var th = new Thread(
new ParameterizedThreadStart((state) =>
{
WeatherForecast.GetInstance();
})
);
th.Start(i);
}
return null;
}
然后我们看看效果是怎样的,按照我们的思路,应该是只会走一遍构造函数,其实不是:
3个线程在第一次访问GetInstance方法时,同时判断(uniqueInstance ==null)这个条件时都返回真,然后都去创建了实例,这个肯定是不对的。那怎么办呢,只要让GetInstance方法只运行一个线程运行就好了,我们可以加一个锁来控制他,代码如下:
public class WeatherForecast
{
// 定义一个静态变量来保存类的唯一实例
private static WeatherForecast uniqueInstance;
// 定义一个锁,防止多线程
private static readonly object locker = new object();
// 定义私有构造函数,使外界不能创建该类实例
private WeatherForecast()
{
Date = DateTime.Now;
}
///
/// 静态方法,来返回唯一实例
/// 如果存在,则返回
///
///
public static WeatherForecast GetInstance()
{
// 当第一个线程执行的时候,会对locker对象 "加锁",
// 当其他线程执行的时候,会等待 locker 执行完解锁
lock (locker)
{
// 如果类的实例不存在则创建,否则直接返回
if (uniqueInstance == null)
{
uniqueInstance = new WeatherForecast();
}
}
return uniqueInstance;
}
public DateTime Date { get; set; }
public int TemperatureC { get; set; }
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
public string Summary { get; set; }
}
这个时候,我们再并发测试,发现已经都一样了,这样就达到了我们想要的效果,但是这样真的是最完美的么,其实不是的,因为我们加锁,只是第一次判断是否为空,如果创建好了以后,以后就不用去管这个 lock 锁了,我们只关心的是 uniqueInstance 是否为空,那我们再完善一下:
///
/// 定义一个天气类
///
public class WeatherForecast
{
// 定义一个静态变量来保存类的唯一实例
private static WeatherForecast uniqueInstance;
// 定义一个锁,防止多线程
private static readonly object locker = new object();
// 定义私有构造函数,使外界不能创建该类实例
private WeatherForecast()
{
Date = DateTime.Now;
}
///
/// 静态方法,来返回唯一实例
/// 如果存在,则返回
///
///
public static WeatherForecast GetInstance()
{
// 当第一个线程执行的时候,会对locker对象 "加锁",
// 当其他线程执行的时候,会等待 locker 执行完解锁
if (uniqueInstance == null)
{
lock (locker)
{
// 如果类的实例不存在则创建,否则直接返回
if (uniqueInstance == null)
{
uniqueInstance = new WeatherForecast();
}
}
}
return uniqueInstance;
}
public DateTime Date { get; set; }
public int TemperatureC { get; set; }
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
public string Summary { get; set; }
}
这样才最终的完美实现我们的单例模式!搞定。
2、幽灵事件:指令重排
当然,如果你看完了上边的那四步已经可以出师了,平时我们就是这么使用的,也是这么想的,但是真的就是万无一失么,有一个 JAVA 的朋友提出了这个问题,C# 中我没有听说过,是我孤陋寡闻了么:
单例模式的幽灵事件,时令重排会偶尔导致单例模式失效。
是不是听起来感觉很高大上,而不知所云,没关系,咱们平时用不到,但是可以了解了解:
为何要指令重排?
指令重排是指的 volatile,现在的CPU一般采用流水线来执行指令。一个指令的执行被分成:取指、译码、访存、执行、写回、等若干个阶段。然后,多条指令可以同时存在于流水线中,同时被执行。
指令流水线并不是串行的,并不会因为一个耗时很长的指令在“执行”阶段呆很长时间,而导致后续的指令都卡在“执行”之前的阶段上。
相反,流水线是并行的,多个指令可以同时处于同一个阶段,只要CPU内部相应的处理部件未被占满即可。比如说CPU有一个加法器和一个除法器,那么一条加法指令和一条除法指令就可能同时处于“执行”阶段, 而两条加法指令在“执行”阶段就只能串行工作。
相比于串行+阻塞的方式,流水线像这样并行的工作,效率是非常高的。
然而,这样一来,乱序可能就产生了。比如一条加法指令原本出现在一条除法指令的后面,但是由于除法的执行时间很长,在它执行完之前,加法可能先执行完了。再比如两条访存指令,可能由于第二条指令命中了cache而导致它先于第一条指令完成。
一般情况下,指令乱序并不是CPU在执行指令之前刻意去调整顺序。CPU总是顺序的去内存里面取指令,然后将其顺序的放入指令流水线。但是指令执行时的各种条件,指令与指令之间的相互影响,可能导致顺序放入流水线的指令,最终乱序执行完成。这就是所谓的“顺序流入,乱序流出”。
这个是从网上摘录的,大概意思看看就行,理解双检锁失效原因有两个重点
编译器的写操作重排问题.
例 : B b = new B();
上面这一句并不是原子性的操作,一部分是new一个B对象,一部分是将new出来的对象赋值给b.
直觉来说我们可能认为是先构造对象再赋值.但是很遗憾,这个顺序并不是固定的.再编译器的重排作用下,可能会出现先赋值再构造对象的情况.
结合上下文,结合使用情景.
理解了1中的写操作重排以后,我卡住了一下.因为我真不知道这种重排到底会带来什么影响.实际上是因为我看代码看的不够仔细,没有意识到使用场景.双检锁的一种常见使用场景就是在单例模式下初始化一个单例并返回,然后调用初始化方法的方法体内使用初始化完成的单例对象.
三、Singleton = 单例 ?
上边我们说了很多,也介绍了很多单例的原理和步骤,那这里问题来了,我们在学习依赖注入的时候,用到的 Singleton 的单例注入,是不是和上边说的一回事儿呢,这里咱们直接多多线程测试一下就行:
///
/// 定义一个心情类
///
public class Feeling
{
public Feeling()
{
Date = DateTime.Now;
}
public DateTime Date { get; set; }
}
// 单例注册到容器内
services.AddSingleton();
这里重点表扬下评论区的@我是你帅哥 小伙伴,及时的发现了我文章的漏洞,笔芯!
紧接着我们就控制器注入服务,然后多线程测试:
private readonly ILogger _logger;
private readonly Feeling _feeling;
public WeatherForecastController(ILogger logger, Feeling feeling)
{
_logger = logger;
_feeling = feeling;
}
[HttpGet]
public WeatherForecast Get()
{
// 实例化一个对象实例
//WeatherForecast weather = WeatherForecast.GetInstance();
// 多线程去调用
for (int i = 0; i < 3; i++)
{
var th = new Thread(
new ParameterizedThreadStart((state) =>
{
//WeatherForecast.GetInstance();
// 此刻的心情
Console.WriteLine(_feeling.Date);
})
);
th.Start(i);
}
return null;
}
测试的结果,情理之中,只在我们项目初始化服务的时候,进入了一次构造函数:
和我们上边说的是一样的, Singleton是一种单例,而且还是双检锁那种, 因为结论可以看出,我们使用单例模式,直接可以使用依赖注入 Sigleton 就能满足的,很方便。
四、单例模式的优缺点
【优】、单例模式的优点:
(1)、保证唯一性:防止其他对象实例化,保证实例的唯一性;
(2)、全局性:定义好数据后,可以再整个项目种的任何地方使用当前实例,以及数据;
【劣】、单例模式的缺点:
(1)、内存常驻:因为单例的生命周期最长,存在整个开发系统内,如果一直添加数据,或者是常驻的话,会造成一定的内存消耗。
以下内容来自百度百科:
优点
一、实例控制
单例模式会阻止其他对象实例化其自己的单例对象的副本,从而确保所有对象都访问唯一实例。
二、灵活性
因为类控制了实例化过程,所以类可以灵活更改实例化过程。
缺点
一、开销
虽然数量很少,但如果每次对象请求引用时都要检查是否存在类的实例,将仍然需要一些开销。可以通过使用静态初始化解决此问题。
二、可能的开发混淆
使用单例对象(尤其在类库中定义的对象)时,开发人员必须记住自己不能使用new关键字实例化对象。因为可能无法访问库源代码,因此应用程序开发人员可能会意外发现自己无法直接实例化此类。
三、对象生存期
不能解决删除单个对象的问题。在提供内存管理的语言中(例如基于.NET Framework的语言),只有单例类能够导致实例被取消分配,因为它包含对该实例的私有引用。在某些语言中(如 C++),其他类可以删除对象实例,但这样会导致单例类中出现悬浮引用。
本文来自编程界网,java教程栏目,欢迎学习!