摘要:
本文首先概述了单例模式产生动机,揭示了单例模式的本质和应用场景。紧接着,我们给出了单例模式在单线程环境下的两种经典实现:饿汉式 和懒汉式,但是饿汉式是线程安全的,而懒汉式是非线程安全的。在多线程环境下,我们特别介绍了五种方式来在多线程环境下创建线程安全的单例,即分别使用synchronized方法、synchronized块、静态内部类、双重检查模式 和ThreadLocal 来实现懒汉式单例,并总结出实现效率高且线程安全的懒汉式单例所需要注意的事项。
一. 单例模式概述
单例模式(Singleton),也叫单子模式,是一种常用的设计模式。在应用这个模式时,单例对象的类必须保证只有一个实例存在。许多时候,整个系统只需要拥有一个的全局对象,这样有利于我们协调系统整体的行为。比如在某个服务器程序中,该服务器的配置信息存放在一个文件中,这些配置数据由一个单例对象统一读取,然后服务进程中的其他对象再通过这个单例对象获取这些配置信息,显然,这种方式简化了在复杂环境下的配置管理。
特别地,在计算机系统中,线程池、缓存、日志对象、对话框、打印机、显卡的驱动程序对象常被设计成单例。事实上,这些应用都或多或少具有资源管理器的功能。例如,每台计算机可以有若干个打印机,但只能有一个 Printer Spooler(单例) ,以避免两个打印作业同时输出到打印机中。再比如,每台计算机可以有若干通信端口,系统应当集中 (单例)管理这些通信端口,以避免一个通信端口同时被两个请求同时调用。总之,选择单例模式就是为了避免不一致状态,避免政出多头。
综上所述,单例模式就是为确保一个类只有一个实例,并为整个系统提供一个全局访问点的一种方法。
二. 单例模式及其单线程环境下的经典实现
单例模式应该是23种设计模式中最简单的一种模式了,下面我们从单例模式的定义、类型、结构和使用要素四个方面来介绍它。
1、单例模式理论基础
定义: 确保一个类只有一个实例,并为整个系统提供一个全局访问点 (向整个系统提供这个实例)。
类型: 创建型模式
结构:
特别地,为了更好地理解上面的类图,我们以此为契机,介绍一下类图的几个知识点:
- 类图分为三部分,依次是类名、属性、方法;
- 以<<开头和以>>结尾的为注释信息;
- 修饰符+代表public,-代表private,#代表protected,什么都没有代表包可见;
- 带下划线的属性或方法代表是静态的。
三要素:
- 私有的构造方法;
- 指向自己实例的私有静态引用;
- 以自己实例为返回值的静态的公有方法。
2、单线程环境下的两种经典实现
在介绍单线程环境中单例模式的两种经典实现之前,我们有必要先解释一下 立即加载 和延迟加载 两个概念。
- 立即加载 : 在类加载初始化的时候就主动创建实例;
- 延迟加载 : 等到真正使用的时候才去创建实例,不用时不去主动创建。在单线程环境下,单例模式根据实例化对象时机的不同,有两种经典的实现:一种是 饿汉式单例(立即加载),一种是 懒汉式单例(延迟加载)。饿汉式单例在单例类被加载时候,就实例化一个对象并交给自己的引用;而懒汉式单例只有在真正使用的时候才会实例化一个对象并交给自己的引用。代码示例分别如下:
饿汉式单例:
// 饿汉式单例
public class Singleton1 {
// 指向自己实例的私有静态引用,主动创建
private static Singleton1 singleton1 = new Singleton1();
// 私有的构造方法
private Singleton1(){}
// 以自己实例为返回值的静态的公有方法,静态工厂方法
public static Singleton1 getSingleton1(){
return singleton1;
}
- 我们知道,类加载的方式是按需加载,且加载一次。。因此,在上述单例类被加载时,就会实例化一个对象并交给自己的引用,供系统使用;而且,由于这个类在整个生命周期中只会被加载一次,因此只会创建一个实例,即能够充分保证单例。
懒汉式单例:
// 懒汉式单例
public class Singleton2 {
// 指向自己实例的私有静态引用
private static Singleton2 singleton2;
// 私有的构造方法
private Singleton2(){}
// 以自己实例为返回值的静态的公有方法,静态工厂方法
public static Singleton2 getSingleton2(){
// 被动创建,在真正需要使用时才去创建
if (singleton2 == null) {
singleton2 = new Singleton2();
}
return singleton2;
}
}
- 我们从懒汉式单例可以看到,单例实例被延迟加载,即只有在真正使用的时候才会实例化一个对象并交给自己的引用。
总之,从速度和反应时间角度来讲,饿汉式(又称立即加载)要好一些;从资源利用效率上说,懒汉式(又称延迟加载)要好一些。
3、单例模式的优点
我们从单例模式的定义和实现,可以知道单例模式具有以下几个优点:
- 在内存中只有一个对象,节省内存空间;
- 避免频繁的创建销毁对象,可以提高性能;
- 避免对共享资源的多重占用,简化访问;
- 为整个系统提供一个全局访问点。
4、单例模式的使用场景
由于单例模式具有以上优点,并且形式上比较简单,所以是日常开发中用的比较多的一种设计模式,其核心在于为整个系统提供一个唯一的实例,其应用场景包括但不仅限于以下几种:
- 有状态的工具类对象;
- 频繁访问数据库或文件的对象;
5、单例模式的注意事项
在使用单例模式时,我们必须使用单例类提供的公有工厂方法得到单例对象,而不应该使用反射来创建,否则将会实例化一个新对象。此外,在多线程环境下使用单例模式时,应特别注意线程安全问题,我在下文会重点讲到这一点。
三. 多线程环境下单例模式的实现
在单线程环境下,无论是饿汉式单例还是懒汉式单例,它们都能够正常工作。但是,在多线程环境下,情形就发生了变化:由于饿汉式单例天生就是线程安全的,可以直接用于多线程而不会出现问题;但懒汉式单例本身是非线程安全的,因此就会出现多个实例的情况,与单例模式的初衷是相背离的。下面我重点阐述以下几个问题:
- 为什么说饿汉式单例天生就是线程安全的?
- 传统的懒汉式单例为什么是非线程安全的?
- 怎么修改传统的懒汉式单例,使其线程变得安全?
- 线程安全的单例的实现还有哪些,怎么实现?
- 双重检查模式、Volatile关键字 在单例模式中的应用
- ThreadLocal 在单例模式中的应用
特别地,为了能够更好的观察到单例模式的实现是否是线程安全的,我们提供了一个简单的测试程序来验证。该示例程序的判断原理是:
开启多个线程来分别获取单例,然后打印它们所获取到的单例的hashCode值。若它们获取的单例是相同的(该单例模式的实现是线程安全的),那么它们的hashCode值一定完全一致;若它们的hashCode值不完全一致,那么获取的单例必定不是同一个,即该单例模式的实现不是线程安全的,是多例的。注意,相应输出结果附在每个单例模式实现示例后。
public class Test {
public static void main(String[] args) {
Thread[] threads = new Thread[10];
for (int i = 0; i < threads.length; i++) {
threads[i] = new TestThread();
}
for (int i = 0; i < threads.length; i++) {
threads[i].start();
}
}
}
class TestThread extends Thread {
@Override
public void run() {
// 对于不同单例模式的实现,只需更改相应的单例类名及其公有静态工厂方法名即可
int hash = Singleton5.getSingleton5().hashCode();
System.out.println(hash);
}
}
1、为什么说饿汉式单例天生就是线程安全的?
// 饿汉式单例
public class Singleton1 {
// 指向自己实例的私有静态引用,主动创建
private static Singleton1 singleton1 = new Singleton1();
// 私有的构造方法
private Singleton1(){}
// 以自己实例为返回值的静态的公有方法,静态工厂方法
public static Singleton1 getSingleton1(){
return singleton1;
}
}/* Output(完全一致):
1028355155
1028355155
1028355155
1028355155
1028355155
1028355155
1028355155
1028355155
1028355155
1028355155
*/
我们已经在上面提到,类加载的方式是按需加载,且只加载一次。因此,在上述单例类被加载时,就会实例化一个对象并交给自己的引用,供系统使用。换句话说,在线程访问单例对象之前就已经创建好了。再加上,由于一个类在整个生命周期中只会被加载一次,因此该单例类只会创建一个实例,也就是说,线程每次都只能也必定只可以拿到这个唯一的对象。因此就说,饿汉式单例天生就是线程安全的。
2、传统的懒汉式单例为什么是非线程安全的?
// 传统懒汉式单例
public class Singleton2 {
// 指向自己实例的私有静态引用
private static Singleton2 singleton2;
// 私有的构造方法
private Singleton2(){}
// 以自己实例为返回值的静态的公有方法,静态工厂方法
public static Singleton2 getSingleton2(){
// 被动创建,在真正需要使用时才去创建
if (singleton2 == null) {
singleton2 = new Singleton2();
}
return singleton2;
}
}/* Output(不完全一致):
1084284121
2136955031
2136955031
1104499981
298825033
298825033
2136955031
482535999
298825033
2136955031
*///:
上面发生非线程安全的一个显著原因是,会有多个线程同时进入 if (singleton2 == null) {…} 语句块的情形发生。当这种这种情形发生后,该单例类就会创建出多个实例,违背单例模式的初衷。因此,传统的懒汉式单例是非线程安全的。
3、实现线程安全的懒汉式单例的几种正确姿势
1)、同步延迟加载 — synchronized方法
// 线程安全的懒汉式单例
public class Singleton2 {
private static Singleton2 singleton2;
private Singleton2(){}
// 使用 synchronized 修饰,临界资源的同步互斥访问
public static synchronized Singleton2 getSingleton2(){
if (singleton2 == null) {
singleton2 = new Singleton2();
}
return singleton2;
}
}/* Output(完全一致):
1104499981
1104499981
1104499981
1104499981
1104499981
1104499981
1104499981
1104499981
1104499981
1104499981
*///:~
该实现与上面传统懒汉式单例的实现唯一的差别就在于:是否使用 synchronized 修饰 getSingleton2()方法。若使用,就保证了对临界资源的同步互斥访问,也就保证了单例。
从执行结果上来看,问题已经解决了,但是这种实现方式的运行效率会很低,因为同步块的作用域有点大,而且锁的粒度有点粗。同步方法效率低,那我们考虑使用同步代码块来实现。
2)、同步延迟加载 — synchronized块
// 线程安全的懒汉式单例
public class Singleton2 {
private static Singleton2 singleton2;
private Singleton2(){}
public static Singleton2 getSingleton2(){
synchronized(Singleton2.class){ // 使用 synchronized 块,临界资源的同步互斥访问
if (singleton2 == null) {
singleton2 = new Singleton2();
}
}
return singleton2;
}
}/* Output(完全一致):
16993205
16993205
16993205
16993205
16993205
16993205
16993205
16993205
16993205
16993205
*///:
该实现与上面synchronized方法版本实现类似,此不赘述。从执行结果上来看,问题已经解决了,但是这种实现方式的运行效率仍然比较低,事实上,和使用synchronized方法的版本相比,基本没有任何效率上的提高。
3)、同步延迟加载 — 使用内部类实现延迟加载
// 线程安全的懒汉式单例
public class Singleton5 {
// 私有内部类,按需加载,用时加载,也就是延迟加载
private static class Holder {
private static Singleton5 singleton5 = new Singleton5();
}
private Singleton5() {
}
public static Singleton5 getSingleton5() {
return Holder.singleton5;
}
}
/* Output(完全一致):
482535999
482535999
482535999
482535999
482535999
482535999
482535999
482535999
482535999
482535999
*///:
- 如上述代码所示,我们可以使用内部类实现线程安全的懒汉式单例,这种方式也是一种效率比较高的做法。至于其为什么是线程安全的,其与问题 “为什么说饿汉式单例天生就是线程安全的?” 相类似,此不赘述。
四. 单例模式与双重检查(Double-Check idiom)
使用双重检测同步延迟加载去创建单例的做法是一个非常优秀的做法,其不但保证了单例,而且切实提高了程序运行效率。对应的代码清单如下:
// 线程安全的懒汉式单例
public class Singleton3 {
//使用volatile关键字防止重排序,因为 new Instance()是一个非原子操作,可能创建一个不完整的实例
private static volatile Singleton3 singleton3;
private Singleton3() {
}
public static Singleton3 getSingleton3() {
// Double-Check idiom
if (singleton3 == null) {
synchronized (Singleton3.class) { // 1
// 只需在第一次创建实例时才同步
if (singleton3 == null) { // 2
singleton3 = new Singleton3(); // 3
}
}
}
return singleton3;
}
}/* Output(完全一致):
1104499981
1104499981
1104499981
1104499981
1104499981
1104499981
1104499981
1104499981
1104499981
1104499981
*///:
如上述代码所示,为了在保证单例的前提下提高运行效率,我们需要对 singleton3 进行第二次检查,目的是避开过多的同步(因为这里的同步只需在第一次创建实例时才同步,一旦创建成功,以后获取实例时就不需要同步获取锁了)。这种做法无疑是优秀的,但是我们必须注意一点:
必须使用volatile关键字修饰单例引用。
那么,如果上述的实现没有使用 volatile 修饰 singleton3,会导致什么情形发生呢? 为解释该问题,我们分两步来阐述:
(1)、当我们写了 new 操作,JVM 到底会发生什么?
首先,我们要明白的是: new Singleton3() 是一个非原子操作。代码行singleton3 = new Singleton3(); 的执行过程可以形象地用如下3行伪代码来表示:
memory = allocate(); //1:分配对象的内存空间
ctorInstance(memory); //2:初始化对象
singleton3 = memory; //3:使singleton3指向刚分配的内存地址
但实际上,这个过程可能发生无序写入(指令重排序),也就是说上面的3行指令可能会被重排序导致先执行第3行后执行第2行,也就是说其真实执行顺序可能是下面这种:
memory = allocate(); //1:分配对象的内存空间
singleton3 = memory; //3:使singleton3指向刚分配的内存地址
ctorInstance(memory); //2:初始化对象
这段伪代码演示的情况不仅是可能的,而且是一些 JIT 编译器上真实发生的现象。
(2)、重排序情景再现
了解 new 操作是非原子的并且可能发生重排序这一事实后,我们回过头看使用 Double-Check idiom 的同步延迟加载的实现:
我们需要重新考察上述清单中的 //3 行。此行代码创建了一个 Singleton 对象并初始化变量 singleton3 来引用此对象。这行代码存在的问题是,在 Singleton 构造函数体执行之前,变量 singleton3 可能提前成为非 null 的,即赋值语句在对象实例化之前调用,此时别的线程将得到的是一个不完整(未初始化)的对象,会导致系统崩溃。下面是程序可能的一组执行步骤:
1、线程 1 进入 getSingleton3() 方法;
2、由于 singleton3 为 null,线程 1 在 //1 处进入 synchronized 块;
3、同样由于 singleton3 为 null,线程 1 直接前进到 //3 处,但在构造函数执行之前,使实例成为非 null,并且该实例是未初始化的;
4、线程 1 被线程 2 预占;
5、线程 2 检查实例是否为 null。因为实例不为 null,线程 2 得到一个不完整(未初始化)的 Singleton 对象;
6、线程 2 被线程 1 预占。
7、线程 1 通过运行 Singleton3 对象的构造函数来完成对该对象的初始化。
显然,一旦我们的程序在执行过程中发生了上述情形,就会造成灾难性的后果,而这种安全隐患正是由于指令重排序的问题所导致的。让人兴奋地是,volatile 关键字正好可以完美解决了这个问题。也就是说,我们只需使用volatile关键字修饰单例引用就可以避免上述灾难。
五. 单例模式 与 ThreadLocal
借助于 ThreadLocal,我们可以实现双重检查模式的变体。我们将临界资源线程局部化,具体到本例就是将双重检测的第一层检测条件 if (instance == null) 转换为 线程局部范围内的操作 。这里的 ThreadLocal 也只是用作标识而已,用来标识每个线程是否已访问过:如果访问过,则不再需要走同步块,这样就提高了一定的效率。对应的代码清单如下:
// 线程安全的懒汉式单例
public class Singleton4 {
// ThreadLocal 线程局部变量
private static ThreadLocal<Singleton4> threadLocal = new ThreadLocal<Singleton4>();
private static Singleton4 singleton4 = null;
private Singleton4(){}
public static Singleton4 getSingleton4(){
if (threadLocal.get() == null) { // 第一次检查:该线程是否第一次访问
createSingleton4();
}
return singleton4;
}
public static void createSingleton4(){
synchronized (Singleton4.class) {
if (singleton4 == null) { // 第二次检查:该单例是否被创建
singleton4 = new Singleton4(); // 只执行一次
}
}
threadLocal.set(singleton4); // 将单例放入当前线程的局部变量中
}
}/* Output(完全一致):
1028355155
1028355155
1028355155
1028355155
1028355155
1028355155
1028355155
1028355155
1028355155
1028355155
*///
借助于 ThreadLocal,我们也可以实现线程安全的懒汉式单例。但与直接双重检查模式使用,使用ThreadLocal的实现在效率上还不如双重检查锁定。
六. 小结
本文首先介绍了单例模式的定义和结构,并给出了其在单线程和多线程环境下的几种经典实现。特别地,我们知道,传统的饿汉式单例无论在单线程还是多线程环境下都是线程安全的,但是传统的懒汉式单例在多线程环境下是非线程安全的。为此,我们特别介绍了五种方式来在多线程环境下创建线程安全的单例,包括:
- 使用synchronized方法实现懒汉式单例;
- 使用synchronized块实现懒汉式单例;
- 使用静态内部类实现懒汉式单例;
- 使用双重检查模式实现懒汉式单例;
- 使用ThreadLocal实现懒汉式单例;