前言
单例模式因其可以保证资源的重用从能提高性能,所以在 Java EE 中被广泛使用,比如Spring框架中的ApplicationContext类就被设计成单例模式。下面来看看单例模式在Java中的几种实现形式,以及他们的优劣对比。
1.懒加载单例模式
class Singleton{
private static Singleton instance;
public static Singleton getInstance(){
if(instance == null){
instance = new Singleton();
}
return instance;
}
private Singleton(){
}
}
这种实现存在严重的线程安全问题:
该段代码在单线程下运行良好,但当该代码运行在多线程环境下,会出现多个线程运行到上图红箭头处,切换了时间片这种情况,导致这些线程的 Singleton 实例并不相同,从而违背了单例模式的设计初衷。
1.1懒加载单例模式改进
为了单例模式能运行在多线程环境下,首先想到的是加互斥锁。
class Singleton{
private static Singleton instance;
public static Singleton getInstance(){
synchronized(Singleton.class){
// 每次进入该代码段,都会申请互斥锁
if(instance == null){
instance = new Singleton();
}
}
return instance;
}
private Singleton(){
}
}
显然,每次调用该段代码,都会引发加锁和释放锁等操作,而这些操作是比较耗费性能,所以经过改进有了称为 双重检查锁(DCL:Double Checked Locking)的写法
class Singleton{
private static Singleton instance;
public static Singleton getInstance(){
if(instance == null){
// 外面这个未加锁的额外判断,能有效减少申请互斥锁的操作
synchronized(Singleton.class){
// 尝试进入这段代码,才会申请互斥锁
if(instance == null){
instance = new Singleton();
}
}
}
return instance;
}
private Singleton(){
}
}
上面这段代码,看上去已经比较完美了,但其实有一个小细节很容易被忽视:由于更好的利用支持多线程的CPU,Java编译器在编译代码时,可能进行 指令重排,即存在这种情况,某个线程已经获得了互斥锁,并通过new关键字已经构造了对象,但当该线程释放互斥锁时,并没有返回该对象在堆中的地址,所以其它线程在后面获得互斥锁后,就会构造另一个新对象,这种单例模式设计也产生了多个不同的单例实例,从而也违背了单例模式的初衷。显然,解决这一问题的关键是禁止指令重排序,在Java中可以通过 volatile 关键字实现这一功能。所以就有了下面完整的 双重检查锁单例模式写法
class Singleton{
// volatile 关键字的使用防止了指令重排
private static volatile Singleton instance;
public static Singleton getInstance(){
if(instance == null){
synchronized(Singleton.class){
if(instance == null){
instance = new Singleton();
}
}
}
return instance;
}
private Singleton(){
}
2.饿汉单例模式
class Singleton{
// 类加载过程中,就已经构造好了单例对象
private static Singleton instance = new Singleton();
public static Singleton getInstance(){
return instance;
}
private Singleton(){
}
}
由于JVM保证了类加载阶段的静态变量初始化的线程安全,所以上面的代码也能安全地运行在多线程环境中。
上面 完整版的DCL单例模式 和 饿汉版单例模式 都能正确地运行在多线程环境下,但他们还存在一个致命的缺陷:通过反序列化生成的实例与现已存在的实例不同,从而也违背了单例模式的设计初衷。下面通过代码进行演示这一缺陷。
import java.io.FileInputStream;
import java.io.ObjectInputStream;
import java.io.FileOutputStream;
import java.io.ObjectOutputStream;
import java.io.Serializable;
class Singleton implements Serializable{
// 实现 Serializable 接口才能实现序列化和反序列化
private static final long serialVersionUID = 4552249085643899887L;
private static Singleton instance = new Singleton();
public static Singleton getInstance(){
return instance;
}
private Singleton(){
}
}
public class Test{
public static void main(String[] args) throws Exception{
Singleton s1 = Singleton.getInstance();
// 序列化
ObjectOutputStream oos = null;
try {
oos = new ObjectOutputStream(new FileOutputStream("singleton.ser"));
oos.writeObject(s1);
} finally {
if (oos != null)
oos.close();
}
// 反序列化
ObjectInputStream ois = null;
Singleton s2;
try {
ois = new ObjectInputStream(new FileInputStream("singleton.ser"));
s2 = (Singleton) ois.readObject();
} finally {
if (ois != null)
ois.close();
}
System.out.println(s1 == s2);
}
}
上面代码的运行结果为:
这一问题可以通过下面的枚举版单例模式得到解决
3.枚举版单例模式
enum Singleton{
INSTACE;
public static Singleton getInstance(){
return INSTACE;
}
private Singleton(){
}
}
我们在《Java中的枚举》中知道了一个枚举类型其实质是一个有固定实例的特殊Java类,且其实例是被public static final 修饰的,通过上面的 饿汉单例模式 我们也知道了JVM保证了静态变量成员初始化时的线程安全,所以我们也能得出枚举版的单例模式也是线程安全的。那么它是否可以预防反序列化机制下的单例模式冲突问题呢?下面我们来看一段代码的运行情况:
import java.io.FileInputStream;
import java.io.ObjectInputStream;
import java.io.FileOutputStream;
import java.io.ObjectOutputStream;
enum Singleton{
// 枚举类型即使没有实现 Serializable接口也能实现序列化和反序列化
INSTACE;
public static Singleton getInstance(){
return INSTACE;
}
private Singleton(){
}
}
public class Test{
public static void main(String[] args) throws Exception{
Singleton s1 = Singleton.getInstance();
// 序列化
ObjectOutputStream oos = null;
try {
oos = new ObjectOutputStream(new FileOutputStream("singleton.ser"));
oos.writeObject(s1);
} finally {
if (oos != null)
oos.close();
}
// 反序列化
ObjectInputStream ois = null;
Singleton s2;
try {
ois = new ObjectInputStream(new FileInputStream("singleton.ser"));
s2 = (Singleton) ois.readObject();
} finally {
if (ois != null)
ois.close();
}
System.out.println(s1 == s2);
}
}
其运行结果为:
我们可以看到了在枚举类型版的单例模式中,即使通过反序列化机制构造的实例也与已经在内存中存在的实例相同。那么枚举是怎么做到这一步的呢?通过查询 Java Object Serialization Specification 中对枚举类型的序列化的规定
中,我们知道了枚举的序列化与反序列化只与它的name属性相关,即序列化时只保存了枚举实例化名,反序列化时只是将枚举实例化名传给了 java.lang.Enum.valueOf 方法,我们再来看下 java.lang.Enum.valueOf 的源码:
从代码中我们可以看出,valueOf 方法实质就是通过枚举实例名获取保存在HashMap中的枚举实例,所以反序列化枚举类型得到的实例与内存中已经存在的枚举类型实例是一致的。
总结
在Java中,使用枚举实现单例模式是最佳选择,但如果不考虑反射情况,使用饿汉版单例模式也是一个不错的选择,如果一定要使用延迟加载版的单例模式,一定不要忘了互斥锁以及指令重排问题。