概述
“你知道茴香豆的‘茴’字有几种写法吗?”
纠结单例模式有几种写法有用吗?有点用,面试中经常选择其中一种或几种写法作为话头,考查设计模式和coding style的同时,还很容易扩展到其他问题。这里讲解几种猴子常用的写法,但切忌生搬硬套,去记“茴香豆的写法”。编程最大的乐趣在于“know everything, control everything”。
面试中单例模式有几种写法?
大体可分为4类,下面分别介绍他们的基本形式、变种及特点。
饱汉模式
基础的饱汉(单线程写法)
饱汉,即已经吃饱,不着急再吃,饿的时候再吃。所以他就先不初始化单例,等第一次使用的时候再初始化,即“懒加载”
。
写法:由私有构造器和一个公有静态工厂方法构成,在工厂方法中对singleton进行null判断,如果是null就new一个出来,最后返回singleton对象。
注意:这种方法可以实现延时加载,但是有一个致命弱点:线程不安全。如果有两条线程同时调用getInstance()方法,就有很大可能导致重复创建对象。
1 | // 饱汉 |
饱汉模式的核心就是懒加载。好处是启动速度快、节省资源,一直到实例被第一次访问,才需要初始化单例;小坏处是写起来麻烦,大坏处是线程不安全,if语句存在竞态条件。
这种写法写起来麻烦不是大问题,但是可读性好啊。因此,单线程环境下,基础饱汉是我们最喜欢的写法。但多线程环境下,基础饱汉就彻底不可用了。下面的几种变种都在试图解决基础饱汉线程不安全的问题。
饱汉 - 变种 1
最粗暴的犯法是用synchronized关键字修饰getInstance()方法,这样能达到绝对的线程安全。
1 | // 饱汉 |
变种1的好处是写起来简单,且绝对线程安全;坏处是并发性能极差,事实上完全退化到了串行。单例只需要初始化一次,但就算初始化以后,synchronized的锁也无法避开,从而getInstance()完全变成了串行操作。性能不敏感的场景建议使用。
饱汉 - 变种 2
变种2是“臭名昭著”的DCL 1.0
。
针对变种1中单例初始化后锁仍然无法避开的问题,变种2在变种1的外层又套了一层check,加上synchronized内层的check,即所谓“双重检查锁”(Double Check Lock,简称DCL)。
1 | // 饱汉 |
变种2的核心是DCL,看起来变种2似乎已经达到了理想的效果:懒加载+线程安全。可惜的是,正如注释中所说,DCL仍然是线程不安全的,由于指令重排序,你可能会得到“半个对象”,即”部分初始化
“问题。
饱汉 - 变种 3(兼顾线程安全和效率的写法)
变种3专门针对变种2,可谓DCL 2.0
。
针对变种3的“半个对象”问题,变种3在实例上增加了volatile关键字。
1 | // 饱汉 |
这种写法被称为”双重检查锁”,顾名思义,就是在getInstance()方法中,进行两次null检查。
看似多此一举,但实际上却极大提升了并发度,进而提升了性能。
为什么可以提高并发度呢?就像上文说的,在单例中new的情况非常少,绝大多数都是可以并行的读操作。因此在加锁前多进行一次null检查就可以减少绝大多数的加锁操作,执行效率提高的目的也就达到了。
多线程环境下,变种3更适用于性能敏感的场景。但后面我们将了解到,就算是线程安全的,还有一些办法能破坏单例。
注意:那么,这种写法是不是绝对安全呢?前面说了,从语义角度来看,并没有什么问题。但是其实还是有坑,说这个坑之前我们要先来看看volatile这个关键字。其实这个关键字有两层语义。第一层语义相信大家都比较熟悉,就是可见性。可见性指的是在一个线程中对该变量的修改会马上由工作内存(Work Memory)写回主内存(Main Memory),所以会马上反应在其它线程的读取操作中。顺便一提,工作内存和主内存可以近似理解为实际电脑中的高速缓存和主存,工作内存是线程独享的,主存是线程共享的。volatile的第二层语义是禁止指令重排序优化。大家知道我们写的代码(尤其是多线程代码),由于编译器优化,在实际执行的时候可能与我们编写的顺序不同。编译器只保证程序执行结果与源代码相同,却不保证实际指令的顺序与源代码相同。这在单线程看起来没什么问题,然而一旦引入多线程,这种乱序就可能导致严重问题。
volatile关键字就可以从语义上解决这个问题。虽然从语义上讲是没有问题的,但是很不幸,禁止指令重排优化这条语义直到jdk1.5以后才能正确工作。此前的JDK中即使将变量声明为volatile也无法完全避免重排序所导致的问题。所以,在jdk1.5版本前,双重检查锁形式的单例模式是无法保证线程安全的。
饿汉模式
顾名思义,饿汉法就是在第一次引用该类的时候就创建对象实例,而不管实际是否需要创建。
与饱汉相对,饿汉很饿,只想着尽早吃到。所以他就在最早的时机,即类加载时初始化单例,以后访问时直接返回即可。
1 | // 饿汉 |
饿汉的好处是天生的线程安全(得益于类加载机制),写起来超级简单,使用时没有延迟;坏处是有可能造成资源浪费(如果类加载后就一直不使用单例的话)。
值得注意的时,单线程环境下,饿汉与饱汉在性能上没什么差别;但多线程环境下,由于饱汉需要加锁,饿汉的性能反而更优。
Holder模式(静态内部类法)
我们既希望利用饿汉模式中静态变量的方便和线程安全,又希望通过懒加载规避资源浪费。
Holder模式满足了这两点要求:
核心仍然是静态变量,足够方便和线程安全;通过静态的Holder类持有真正实例,间接实现了懒加载。
1 | // Holder模式 |
相对于饿汉模式,Holder模式仅增加了一个静态内部类的成本,与饱汉的变种3效果相当(略优),都是比较受欢迎的实现方式,同样建议考虑。
注意
上面提到的所有实现方式都有两个共同的缺点:
- 都需要额外的工作(Serializable、transient、readResolve())来实现序列化,否则每次反序列化一个序列化的对象实例时都会创建一个新的实例。
- 可能会有人使用反射强行调用我们的私有构造器(如果要避免这种情况,可以修改构造器,让它在创建第二个实例的时候抛异常)。
枚举模式
还有一种更加优雅的方法来实现单例模式,那就是枚举写法。
通过反射或序列化,我们仍然能够访问到私有构造器,创建新的实例破坏单例模式。此时,只有枚举模式能天然防范这一问题。
用枚举实现单例模式,相当好用,但可读性是不存在的。
基础的枚举
将枚举的静态成员变量作为单例的实例:
1 | // 枚举 |
代码量比饿汉模式更少,但用户只能直接访问实例Singleton4.SINGLETON
——事实上,这样的访问方式作为单例使用也是恰当的,只是牺牲了静态工厂方法的优点,如无法实现懒加载。
使用枚举除了线程安全和防止反射强行调用构造器之外,还提供了自动序列化机制,防止反序列化的时候创建新的对象。
丑陋但好用的语法糖
Java的枚举是一个“丑陋但好用的语法糖”。
通过反编译打开语法糖,就看到了枚举类型的本质,简化如下:
1 | // 枚举 |
本质上和饿汉模式相同,区别仅在于公有的静态成员变量。
用枚举实现一些trick
这一部分与单例没什么关系,可以跳过。如果选择阅读也请认清这样的事实:虽然枚举相当灵活,但如何恰当的使用枚举有一定难度。一个足够简单的典型例子是TimeUnit类,建议有时间耐心阅读。
上面已经看到,枚举型单例的本质仍然是一个普通的类。实际上,我们可以在枚举型型单例上增加任何普通类可以完成的功能。要点在于枚举实例的初始化,可以理解为实例化了一个匿名内部类。为了更明显,我们在Singleton4_1
中定义一个普通的私有成员变量,一个普通的公有成员方法,和一个公有的抽象成员方法,如下:
1 | // 枚举 |
这样,枚举类Singleton4_1
中的每一个枚举实例不仅继承了父类Singleton4_1
的成员方法print()
,还必须实现父类Singleton4_1
的抽象成员方法testAbsMethod()
。
总结
实现方式 | 关键点 | 资源浪费 | 线程安全 | 多线程环境的性能足够优化 |
---|---|---|---|---|
基础饱汉 | 懒加载 | 否 | 否 | - |
饱汉变种1 | 懒加载、同步 | 否 | 是 | 否 |
饱汉变种2 | 懒加载、DCL | 否 | 否 | - |
饱汉变种3 | 懒加载、DCL、volatile | 否 | 是 | 是 |
饿汉 | 静态变量初始化 | 是 | 是 | 是 |
Holder | 静态变量初始化、holder | 否 | 是 | 是 |
枚举 | 枚举本质、静态变量初始化 | 否 | 是 | 是 |
最后,不管采取何种方案,请时刻牢记单例的三大要点:
- 线程安全
- 延迟加载
- 序列化与反序列化安全
参考: