单例的用处
如果你看过设计模式,肯定会知道 单例模式
,实际上这是我能默写出代码的第一个设计模式,虽然很长一段时间我并不清楚单例具体是做什么用的。
这里简单提一下单例的用处。作为 java 程序员,你应该知道 spring
框架,而其中最核心的 IOC
,在默认情况下注入的 Bean 就是 单例的。有什么好处?那些 Service、Dao 等只创建一次,不必每次都通过 new 方式创建,也就不用每次都开辟空间、垃圾回收等等,会省不少资源。
version 1: 饿汉式
那么如何写一个单例呢?我想很多朋友都能搞定:
public class Singleton {private static final Singleton singletonInstance = new Singleton(); // A - 急不可待的成员变量赋值,static 和 final 修饰
private Singleton (){} // B - 私有化的构造器,避免随意 new
public static Singleton getInstance(){ // C - 暴露给外部的获取方法
return singletonInstance;
}
}
Ok,拥有 A、B、C 三大特点(注释部分),就构成了著名的 饿汉式单例
。好处在于简单粗暴,易于理解(只要你真正通晓final
和static
的作用)。
但有豪放派,就有婉约派。后来大家都觉得,我 还没有使用这个类,你就直接把对象构建出来扔 java 堆里了,是不是有点不那么含蓄?
于是大家快速迭代出 懒汉式单例
。
version 2: 懒汉式
class Singleton {
private static Singleton singletonInstance; // A - 温婉到只有变量声明
private Singleton (){} // B
public static Singleton getInstance(){ // C
if(singletonInstance==null){singletonInstance = new Singleton(); // D - 成员变量的创建赋值延后至此
}
return singletonInstance;
}
}
变化发生于 A、D 两步,总得来说,就是 把成员变量 singletonInstance
的创建和赋值延后了。基本的要求达到了,在没调用 getInstance()方法之前,对象无创建,不再麻烦 java 堆大大。一切看起来都很美好,但 仅限于单线程情况下 。
好,看看大家喜闻乐见的并发场景下,这种简易的写法会出现什么问题——两个线程 T-1
和T-2
同时访问 getInstance()
,它们都觉得singletonInstance==null
判断成立,分别执行了 步骤 D
,成功创建出 singletonInstance
对象!但是,我们通篇都在聊单例啊,T-1
和 T-2
的玩法无疑很不单例!
问题分析出来了,而解决上并不复杂——让线程同步就好。
version 2.1: 简易解决并发的懒汉式
class Singleton {
private static Singleton singletonInstance; // A
private Singleton (){} // B
public static synchronized Singleton getInstance(){ // C - 用 synchronized 关键字修饰
if(singletonInstance==null){singletonInstance = new Singleton(); // D
}
return singletonInstance;
}
}
唯一的变化在于 步骤 C
,加入了 synchronized
关键字,让线程同步执行此方法。现在问题解决了,不管线程 T-1
还是 T-2
,在getInstance()
面前都要小朋友们排排坐——一个个执行,这样即使是线程 T-100
甚至 T-500
过来也要排队执行,哈哈哈哈哈哈……呜呜呜……
既是解决方案,也是问题所在,这种方式效率太差了!
我们知道,synchronized
有另一种使用方式就是 锁代码块,可以减少锁粒度。
class Singleton {
private static Singleton singletonInstance; // A
private Singleton (){} // B
public static Singleton getInstance(){synchronized (Singleton.class){ // C - 改成 synchronized 锁代码块
if(singletonInstance==null){singletonInstance = new Singleton();
}
}
return singletonInstance;
}
}
但在这个例子中,该方式看上去似乎没什么提升(该方法主要逻辑只有 singletonInstance = new Singleton()
一行)。好在有聪明人,研究出了Double-check
。
version 2.2: Double-check(有问题版)
class Singleton {
private static Singleton singletonInstance; // A
private Singleton (){} // B
public static Singleton getInstance(){if(singletonInstance==null){ // C1 - synchronized 之前,第一次判断
synchronized (Singleton.class){if(singletonInstance==null){ // C2 - synchronized 之后,第二次判断
singletonInstance = new Singleton();}
}
}
return singletonInstance;
}
}
我一直觉得这种方式很巧妙。C1
的判断用于非并发环境,阻拦对象创建后的大部分访问;C2
的判断,解决首次创建对象时的并发问题。
很长一段时间,我觉得这就是最终方案了,世界再次变得美好,没想到还是图样图森破(too young, too simple!)。其实不止是单例,jdk1.5 之前很多问题都被一个关键字耽搁了——volatile
,而它相关的问题 深深隐藏在 Java 内存模型层面,且听我缓缓道来……
version 2.3: volatile 解决有序性
算了,照顾下没耐性的开发兄弟,先给出修改方案:
class Singleton {
private static volatile Singleton singletonInstance; // A - 用 volatile 修饰
private Singleton (){} // B
public static Singleton getInstance(){if(singletonInstance==null){ // C1
synchronized (Singleton.class){if(singletonInstance==null){ // C2
singletonInstance = new Singleton();}
}
}
return singletonInstance;
}
}
可以看到,唯一的变化在于 A 位置
加入了 volatile 关键字
,用于 解决有序性问题。(volatile
涉及的 原子性 和可见性 这里不作讨论)
有序性
什么是有序性?举个“栗子”:
int x=2;// 语句 1
int y=0;// 语句 2
boolean flag=true;// 语句 3
x=4;// 语句 4
y=-1;// 语句 5
对于上面的代码来说,书写语句按顺序 1 至 5,但执行上很可能不是这样。有可能是 1 -4-3-2-5,或者 1 -3-2-5-4,其实只要保证 1 在 4 前并且 2 在 5 前,剩下的顺序可以随意变化。这要感谢 内存模型 同志,它 天然允许编译器和处理器对指令进行重排序。动机是好的——可以默默的帮你做些优化,但在并发场景下,就有好心办坏事的嫌疑。
看下另一个例子:
Context context = null;
boolean inited = false;
// 线程 -1:
public void methodA(){context=loadContext(); // 语句 1
inited=true; // 语句 2
}
// 线程 -2:
public void methodB(){while(!inited){sleep(1) // 语句 3
}
doSomethingwithconfig(context); // 语句 4
}
并发场景下,很可能出现如下情况:
-
线程 -2
在语句 3
位置无忧无虑的休眠 -
语句 2
和语句 1
发生指令重排,线程 -1
进入 methodA()时 先执行 了语句 2
- 恰逢
线程 -2
觉醒,执行语句 4
,此时 context 还是 null(语句 1
的context 初始化还没执行),灾难产生
而volatile
,是个“挡板”,能保证执行顺序。为什么称之为“挡板”?还以之前的“栗子”说明:
int x=2;// 语句 1
int y=0;// 语句 2
volatile boolean flag=true; // 语句 3 - 用 volatile 修饰
x=4;// 语句 4
y=-1;// 语句 5
在 语句 3
的 boolean 变量 用 volatile 修饰后,重排只能分别发生在 1、2 之间或语句 4、5 之间。即语句 1、2 不能跨过语句 3,语句 4、5 也不能跨过语句 3 。
我们还需知道,对于 java 的某些操作,比如 ++
,虽然看上去是一行代码,但实质上这个操作本身并不是原子的。以i++
为例,该操作实际包含 i
的当前值获取,i+1
计算,以及 i=
的赋值操作三兄弟。
同样的,singletonInstance = new Singleton()
也非原子指令,包含:
- 对象内存分配
- 初始化 LazySingleton 对象属性
- 将 singleton 引用指向内存空间
如果不用 volatile 修饰,万恶的指令重排可能发生在 步骤 2
和 步骤 3
之间,产生如下状况(此处有盗图嫌疑,罪过):
以上图的情况,线程 B
获取到了尚未初始化完全的 LazySingleton 对象,使得在后续的使用中出现异常! 用 volatile 修饰 singleton 变量后,指令重排技能被禁用,singletonInstance = new Singleton()
只能按步骤 1、2、3 顺序执行,问题就此解决。
值得一提的是,其实存在更好的 volatile
修饰版本。
version 2.4:推荐的 volatile + Double-check 版
class Singleton {
private static volatile Singleton singletonInstance; // A
private Singleton (){} // B
public static Singleton getInstance(){
tempInstance = singletonInstance; // C - 开启了临时变量
if(tempInstance==null){synchronized (Singleton.class){if(tempInstance==null){singletonInstance = tempInstance = new Singleton();
}
}
}
return tempInstance ;
}
}
这种写法差别在于在 代码 C
位置,声明了 变量 tempInstance
临时变量,之后的逻辑都使用 tempInstance
代替 singletonInstance
。为什么要这样做?wiki 上 准原文 是这么说的:
Note the local variable "tempInstance", which seems unnecessary. The effect of this is that in cases where singletonInstance is already initialized (i.e., most of the time), the volatile field is only accessed once (due to "return tempInstance ;" instead of "return singletonInstance;"), which can improve the method's overall performance by as much as 25 percent.
翻译一下就是:singletonInstance
对象大部分时候是已完成初始化的,用 tempInstance
临时变量之后能减少 volatile 属性
(singletonInstance)的访问,这么做大概能提升25%
的性能!
后续
哇,一不小心写了这么多,而且还没结束,留待下一篇吧。(主要是 volatile
部分比较罗嗦了,这个关键字各位需好好看下,借以窥探内存模型,原子性和可见性没做分析都已经占了这么大的篇幅)
下一篇文章会包含 静态内部类实现单例
、final+ 泛型实现单例
、java9 VarHandler 单例
等,敬请期待!(会有人期待吗 ::>_<::)
参考资料
- https://en.wikipedia.org/wiki…
- https://www.cs.umd.edu/~pugh/…
- https://www.jianshu.com/p/cf5…