单例模式怎么写?这篇文章给你讲透。
大家可能经常在面试中被问到单例模式的相关问题,如果面试官考察你对单例模式的理解程度,那么大概率会被要求手写单例模式。单例模式看起来简单,但往深了挖,又能考察到面试者对于并发、序列化、类加载等基础知识的掌握程度。而且单例模式有很多种写法,大家可能想知道那种写法更好,我也总结了几种写法,一并呈现给大家。
首先我们需要知道什么是单例模式?单例模式指的是保证一个类只有一个实例,并且提供一个全局可以访问的入口。
接下来我们看一下常见的写法又有哪些?我认为有这么 5 种。饿汉式、懒汉式、双重检查式、静态内部类式以及枚举式,我们按照写法的难易程度来逐层递进。首先来看一看相对简单的饿汉式的写法具体是什么样的?
public class Singleton {
private static Singleton singleton = new Singleton();
private Singleton() {} // 构造函数私有,外部类不能构造。
public static Singleton getInstance() {
return singleton;
}
}
用 static 修饰我们的实例,并且把构造函数用 private 修饰,这种写法比较简单,在类装载的时候就完成了实例化,避免了线程同步的问题。缺点在于类装载的时候就完成了实例化,而没有达到懒加载的效果,所以如果从始至终都没有使用过这个实例,就可能会造成内存的浪费。
接下来我们来看一下第 2 种写法,懒汉式这种写法,在 getInstance 方法被调用的时候才去实例化,我们的对象起到了懒加载的效果,但是只能在单线程下使用。如果在多线程环境下一个线程进入了 if(singleton == null) 判断语句块还没来得及往下执行,另一个线程也通过了判断语句,这时就会多次创建实例,但是这里需要注意在多线程环境下,不能使用这种方式,这是错误的写法。
/**
* 懒汉式写法,延迟加载。
* 有线程不安全的问题
*/
public class Singleton {
private static Singleton singleton;
private Singleton() {
}
public static Singleton getInstance() {
if(singleton == null) {
singleton = new Singleton();
}
return singleton;
}
}
为了解决线程不安全的问题,我们需要使用 synchronized 关键字来保证线程安全。
public class Singleton {
private static Singleton singleton;
private Singleton() {}
public static Singleton getInstance() {
if(singleton == null) {
synchronized (Singleton.class) {
singleton = new Singleton();
}
}
return singleton;
}
}
这种写法保证线程安全,但是也会创建多个实例。有两个线程通过了 第一个 if 判断语句的时候,我们继续往下面执行。第一个线程初始化了一个实例,释放锁。第二个线程还会再次初始化一个实例。我们必须进行 double check(即双重检查模式)。
// 双重检查模式
public class Singleton {
private static volatile Singleton singleton;
private Singleton(){}
public static Singleton getInstance() {
if(singleton == null) { // 第一次检查
synchronized (Singleton.class) {
if(singleton == null) { // 第二次检查
singleton = new Singleton();
}
}
}
return singleton;
}
}
去掉第一个 check,效率低下,程序串行执行。
去掉第二个 check,会创建多份实例。
为什么要使用 volatile 关键字?
singleton = new Singleton(); 这句话至少做了三件事情。
1. 给 singleton 分配内存空间。
2. 调用构造函数来初始化 singleton。
3. 将 singleton 对象指向分配的内存空间。(singleton 不再是 null);
假如有两个线程,不使用 volatile 关键词时存在着重排序,我们的执行顺序是 1-3-2。
singleton = new Singleton(); 线程一执行到这个语句时刚好完成步骤 3,然而步骤 2 没有执行。但是此时线程 2 被调度执行,进入第一个 check 的时候,发现 singleton 不再是 null,所以直接返回一个未初始化的 singleton,这样我们使用未初始化的 singletn 就会报错。
下面我们再来看静态内部类的写法:
/**
* 静态内部类的写法,也能保证线程安全性,并且延迟加载。
* 但是和 double check 一样,不能保证被反序列化生成多个实例。
*/
public class Singleton {
private Singleton(){}
private static class SingletonInstance {
private static final Singleton singleton = new Singleton();
}
public static Singleton getInstance() {
return SingletonInstance.singleton;
}
}
可以看出静态内部类和双重检查这两种写法都是不错的写法,但是他们都不能防止被反序列化,生成多个实例,那么有没有更好的写法呢?最后我们就来看一看枚举方式的写法。借助 jdk 1.5 中添加的枚举类来实现单例模式,这不仅能够避免线程同步的问题,而且还能防止反序列化和反射创建新的对象来破坏单例的情况的出现。
public enum Singleton {
INSTANCE;
public void whateverMethod() {
System.out.println("执行了单例类的方法,例如返回环境变量信息。");
}
}
// 测试类
public class Test {
public static void main(String[] args) {
Singleton.INSTANCE.whateverMethod();
}
}
前面我们讲了饿汉式、懒汉式、双重检查式、静态内部类式和枚举类这5种写法,有了这么多方法可以实现单例,此时你可能会问了,我应该怎么选择,用哪种单例去实现?
我认为最好的方式就是利用枚举类!Joshua Bloch 在 Effective Java一书中明确表达过一个观点,就是使用枚举去实现单例的方法,虽然还没有被广泛采用,但是单元素的枚举类型已经成为了实现 Singleton 的最佳方法!为什么他会更加推崇枚举模式的单例,这就不得不回到枚举写法的优点上来说了,枚举写法的优点有这么几个,首先就是写法简单,枚举的写法不需要我们自己去考虑,懒加载、线程安全等问题,同时代码也比较短小精悍,比其他任何的写法都更简洁、更优雅。
第2个优点是线程安全有保障,通过反编译一个枚举类,我们可以发现枚举中的各个枚举项是通过 static 代码块来定义和初始化的,他们会在类被加载时完成初始化,而 Java 类的加载由 JVM 保证线程安全,所以创建一个 Enum 类型的枚举是线程安全的。
前面几种实现单例的方式,其实是存在问题的,可能被反序列化破坏,反序列化生成的新的对象,从而产生了多个实例。接下来要说的枚举类的第3个优点,它恰恰解决了这些问题。而对于序列化这件事情, Java 专门对枚举的序列化做了规定,在序列化时仅仅是将枚举对象的 name 属性输出到结果中,在反序列化时,就是通过 java.lang.Enum 的valueOf 来根据名字查找对象,而不是新建一个新的对象,所以这就防止了反序列化导致的单例破坏问题的出现。
对于通过反射破坏单例而言,枚举类同样有防御措施。反射在通过newInstance 方法创建对象时,会检查这个类是否是枚举类,如果是的话就抛出异常,这样的异常反射创建对象失败。可以看出枚举这种方式能够防止序列化和反射破坏单例,在这一点上与其他的实现方式相比有很大的优势。安全问题不容小视,一旦生成了多个实例,单例模式就彻底没用了。所以结合讲到的这三个优点,写法简单,线程安全以及防止反序列化和反射破坏单例,枚举写法最终胜出。
最后我来总结一下,今天我讲解了单例模式是什么?它的作用、用途以及5种经典写法,其中包含了饿汉式、懒汉式、双重检查式、静态内部类式和枚举的方式。最后我们还经过了对比,看到了枚举方式在写法上线程安全以及避免序列化反射攻击上都有优势。这里也跟大家强调一下,如果使用线程不安全的错误的写法,在并发的情况下可能会产生多个事例,那么不仅会影响到性能,更可能会造成数据错误等严重的后果。
如果是在面试中遇到这个问题,那么你可以从一开始的饿汉式、懒汉式说起,一步一步的分析每一种的优缺点,并且对写法进行演进。然后重点要讲一下双重检查模式,为什么需要两次检查,以及为什么需要 volatile 关键字,最后再说枚举类的写法的优点和背后的原理,相信这一定会为你的面试加分。另外在工作中要是遇到了全局信息类、无状态工具类等场景的时候,推荐使用枚举的写法来实现单例模式。希望我的分享可以帮助到你。
本文内容来自极客时间每日一课,我做的整理。