1. 前言
单例(Singleton)应该是开发者们最熟悉的设计模式了,并且好像也是最容易实现的——基本上每个开发者都能够随手写出——但是,真的是这样吗?
作为一个Java开发者,也许你觉得自己对单例模式的了解已经足够多了。我并不想危言耸听说一定还有你不知道的——毕竟我自己的了解也的确有限,但究竟你自己了解的程度到底怎样呢?往下看,我们一起来聊聊看~
2. 什么是单例?
单例对象的类必须保证只有一个实例存在——这是维基百科上对单例的定义,这也可以作为对意图实现单例模式的代码进行检验的标准。
对单例的实现可以分为两大类——懒汉式和饿汉式,他们的区别在于:
懒汉式:指全局的单例实例在第一次被使用时构建。
饿汉式:指全局的单例实例在类装载时构建。
从它们的区别也能看出来,日常我们使用的较多的应该是懒汉式的单例,毕竟按需加载才能做到资源的最大化利用嘛~
3. 懒汉式单例
先来看一下懒汉式单例的实现方式。
3.1 简单版本
看最简单的写法Version 1:
Java代码
// Version 1
publicclassSingle1 {
privatestaticSingle1 instance;
publicstaticSingle1 getInstance() {
if(instance ==null) {
instance =newSingle1();
}
returninstance;
}
}
或者再进一步,把构造器改为私有的,这样能够防止被外部的类调用。
Java代码
// Version 1.1
publicclassSingle1 {
privatestaticSingle1 instance;
privateSingle1() {}
publicstaticSingle1 getInstance() {
if(instance ==null) {
instance =newSingle1();
}
returninstance;
}
}
我仿佛记得当初学校的教科书就是这么教的?—— 每次获取instance之前先进行判断,如果instance为空就new一个出来,否则就直接返回已存在的instance。
这种写法在大多数的时候也是没问题的。问题在于,当多线程工作的时候,如果有多个线程同时运行到if (instance == null),都判断为null,那么两个线程就各自会创建一个实例——这样一来,就不是单例了。
3.2 synchronized版本
那既然可能会因为多线程导致问题,那么加上一个同步锁吧!
修改后的代码如下,相对于Version1.1,只是在方法签名上多加了一个synchronized:
Java代码
// Version 2
publicclassSingle2 {
privatestaticSingle2 instance;
privateSingle2() {}
publicstaticsynchronizedSingle2 getInstance() {
if(instance ==null) {
instance =newSingle2();
}
returninstance;
}
}
OK,加上synchronized关键字之后,getInstance方法就会锁上了。如果有两个线程(T1、T2)同时执行到这个方法时,会有其中一个线程T1获得同步锁,得以继续执行,而另一个线程T2则需要等待,当第T1执行完毕getInstance之后(完成了null判断、对象创建、获得返回值之后),T2线程才会执行执行。——所以这端代码也就避免了Version1中,可能出现因为多线程导致多个实例的情况。
但是,这种写法也有一个问题:给gitInstance方法加锁,虽然会避免了可能会出现的多个实例问题,但是会强制除T1之外的所有线程等待,实际上会对程序的执行效率造成负面影响。
3.3 双重检查(Double-Check)版本
Version2代码相对于Version1d代码的效率问题,其实是为了解决1%几率的问题,而使用了一个100%出现的防护盾。那有一个优化的思路,就是把100%出现的防护盾,也改为1%的几率出现,使之只出现在可能会导致多个实例出现的地方。
——有没有这样的方法呢?当然是有的,改进后的代码Vsersion3如下:
Java代码
// Version 3
publicclassSingle3 {
privatestaticSingle3 instance;
privateSingle3() {}
publicstaticSingle3 getInstance() {
if(instance ==null) {
synchronized(Single3.class) {
if(instance ==null) {
instance =newSingle3();
}
}
}
returninstance;
}
}
这个版本的代码看起来有点复杂,注意其中有两次if (instance == null)的判断,这个叫做『双重检查 Double-Check』。
第一个if (instance == null),其实是为了解决Version2中的效率问题,只有instance为null的时候,才进入synchronized的代码段——大大减少了几率。
第二个if (instance == null),则是跟Version2一样,是为了防止可能出现多个实例的情况。
—— 这段代码看起来已经完美无瑕了。
……
……
……
—— 当然,只是『看起来』,还是有小概率出现问题的。
这弄清楚为什么这里可能出现问题,首先,我们需要弄清楚几个概念:原子操作、指令重排。
知识点:什么是原子操作?
简单来说,原子操作(atomic)就是不可分割的操作,在计算机中,就是指不会因为线程调度被打断的操作。
比如,简单的赋值是一个原子操作:
Java代码
m =6;// 这是个原子操作
假如m原先的值为0,那么对于这个操作,要么执行成功m变成了6,要么是没执行m还是0,而不会出现诸如m=3这种中间态——即使是在并发的线程中。
而,声明并赋值就不是一个原子操作:
Java代码
intn =6;// 这不是一个原子操作
对于这个语句,至少有两个操作:
①声明一个变量n
②给n赋值为6
——这样就会有一个中间状态:变量n已经被声明了但是还没有被赋值的状态。
——这样,在多线程中,由于线程执行顺序的不确定性,如果两个线程都使用m,就可能会导致不稳定的结果出现。
知识点:什么是指令重排?
简单来说,就是计算机为了提高执行效率,会做的一些优化,在不影响最终结果的情况下,可能会对一些语句的执行顺序进行调整。
比如,这一段代码:
Java代码
inta ;// 语句1
a =8;// 语句2
intb =9;// 语句3
intc = a + b ;// 语句4
正常来说,对于顺序结构,执行的顺序是自上到下,也即1234。
但是,由于指令重排的原因,因为不影响最终的结果,所以,实际执行的顺序可能会变成3124或者1324。
由于语句3和4没有原子性的问题,语句3和语句4也可能会拆分成原子操作,再重排。
——也就是说,对于非原子性的操作,在不影响最终结果的情况下,其拆分成的原子操作可能会被重新排列执行顺序。