一、前言
本文旨在通过由浅入深的方式带大家深入的了解各种单例模式,接下来我会先简单介绍一下单例模式,给出相应单例类的代码,然后通过一些问题来介绍各个单例模式需要注意的地方,还会给出相应的测试代码,希望各位读者看完能有所收获,有任何问题都可以在评论区提出或私信我,由于本人水平有限,所以可能存在错漏之处,望指正。
二、什么是单例模式?
单例模式(Singleton Pattern)属于创建型设计模式,单例指的就是单实例,我们通常都是使用 new 来创建对象的,直接 new 的话创建出来的是不同的对象,如果我们想要确保一个类只能有一个对象,那么我们就可以采用单例模式的方式来获取对象。
三、单例模式的核心特点
- 单例类只能有一个实例,即每次获取该类的对象都是同一个对象
- 单例类必须自己创建自己的唯一实例,即 new 操作必须在单例类中实现,不能在外部 new 单例类
- 单例类必须提供获取该唯一实例的方法,如Singleton.getInstance();
四、单例模式的多种实现方式
1、饿汉式
简单来说就是不管用不用得上,在项目启动时就先 new 出该实例对象,这种方法比较简单,但可能会造成资源浪费,不过一般我们创建了这个类肯定就会使用,所以其实项目中直接用这种方式也是可以的
/**
* 饿汉式单例模式 - 静态变量方式
*/
public class Singleton {
private final static Singleton INSTANCE = new Singleton();
private Singleton() {
}
public static Singleton getInstance() {
return INSTANCE;
}
}
接下来请先思考以下几个问题,请务必认真思考后再看我的讲解
- 问题一:为什么要私有化构造方法?
- 问题二:为什么实例对象
INSTANCE
要加 static ? - 问题三:为什么实例对象
INSTANCE
要加 final ? - 问题四:除了以上这种写法,你能否给出与它功能一致的写法?
答案如下:
- 解答一:根据单例模式的特点,单例类必须自己创建自己的唯一实例,所以只能私有化构造方法,防止用户从外部 new 出新实例对象
- 解答二:首先我们要知道我们的目的是获取该单例类的实例对象,由于不能 new 出该实例对象,所以我们只能通过类名直接调用类中的静态方法来获取该实例对象,即
Singleton.getInstance()
,而静态方法是不能访问非静态成员变量的(因为非静态成员变量是随着对象的创建而被实例化的,而调用静态方法时,可能还没有实例化好对象,所以是无法访问非静态成员变量的),因此实例变量也必须是静态的,静态实例变量会在类的初次加载时初始化 - 解答三:首先 final 保证了实例初始化过程的顺序性(这个我也不是很了解,感兴趣的可以去查阅 final 的相关资料),还有就是禁止通过反射方式改变实例对象,通过反射方式修改实例对象的方法请看我下面给出的
SingletonTest 中的 testNotFinal()
方法 - 解答四:上面我们是通过静态变量的方式实现饿汉式单例模式,还有一种方式的通过静态代码块的方式实现,具体看下面的
Singleton1
,其实和上面这种方式没啥区别,主要就是把类实例化的过程放在了静态代码块中
public class SingletonTest {
/**
* 参考文章:https://www.jianshu.com/p/e22ca93024f3
* Singleton 单例类不加 final 的时候可以通过反射方式修改实例对象,加上 final 则会抛出异常
*/
private static void testNotFinal() throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {
Singleton firstInstance = Singleton.getInstance();
System.out.println("第一次拿到单例模式创建的对象:" + firstInstance);
// 1.获得 Singleton 类
Class<Singleton> clazz = Singleton.class;
// 2.获得 Singleton 类的私有无参构造方法,并通过 setAccessible(true) 允许我们通过反射的方式调用该私有构造方法
Constructor<Singleton> constructor = clazz.getDeclaredConstructor();
constructor.setAccessible(true);
// 3.创建新的实例对象
Singleton clazzSingleton = constructor.newInstance();
System.out.println("反射创建出来的对象: " + clazzSingleton);
// 4.获取 Singleton 类中所有声明的字段,即包括public、private和 protected,但是不包括父类的字段,目前只有 INSTANCE
Field[] fields = clazz.getDeclaredFields();
for (Field field : fields) {
// 设置 true:允许通过反射访问该字段
field.setAccessible(true);
// 向 Singleton 对象(firstInstance)的这个 Field 属性(即:INSTANCE)设置新值 clazzSingleton
field.set(firstInstance, clazzSingleton);
Singleton secondInstance = Singleton.getInstance();
System.out.println("第二次拿到单例模式创建的对象: " + secondInstance);
}
}
/**
* 简单测试通过单例类(静态变量方式)获取的是否是同个对象
*/
public static void testSingleton() {
Singleton singletonOne = Singleton.getInstance();
Singleton singletonTwo = Singleton.getInstance();
// 输出结果: true
System.out.println("两次获取的都是同一个对象:" + (singletonOne == singletonTwo));
}
public static void main(String[] args) throws InvocationTargetException, NoSuchMethodException, InstantiationException, IllegalAccessException {
// 测试 Singleton 获取的是否是同个对象
// testSingleton();
// 测试不加 final 是否能成功修改 Singleton 类的实例对象
testNotFinal();
}
}
/**
* 饿汉式单例模式 - 静态代码块方式
*/
public class Singleton1 {
// 注意:这里同样需要加 final
private final static Singleton1 INSTANCE;
static {
INSTANCE = new Singleton1();
}
private Singleton1() {
}
public static Singleton1 getInstance() {
return INSTANCE;
}
}
2、懒汉式
这里我们采用双重校验锁(DCL)的方式来实现懒汉式单例模式,这里看不懂没有关系,往下看,下面会给出一些问题以及懒汉式的其他错误写法,相信看完之后你就知道为什么要用DCL来实现懒汉式了
/**
* 懒汉式单例模式:双重校验锁(DCL,double-checked locking)方式
*/
public class Singleton {
private static volatile Singleton INSTANCE;
private Singleton() {
}
public static Singleton getInstance() {
if (INSTANCE == null) {
synchronized (Singleton.class) {
if (INSTANCE == null) {
INSTANCE = new Singleton();
}
}
}
return INSTANCE;
}
}
接下来我们先分析几种错误的懒汉式写法,通过以下这些分析和问题,相信读者就能明白上述代码
- 第一种错误的懒汉式单例模式
public class SingletonErrorOne {
private static SingletonErrorOne INSTANCE;
private SingletonErrorOne() {
}
// 第一种错误的懒汉式单例模式,多线程下线程不安全,会产生多个实例
public static SingletonErrorOne getInstance() {
if (INSTANCE == null) {
System.out.println(Thread.currentThread().getName() + ":开始生成新实例");
INSTANCE = new SingletonErrorOne();
}
return INSTANCE;
}
}
这种是我们最容易想到的懒汉式方式,即调用 getInstance()
方法的时候才创建实例对象,然后通过 if 判断让该实例对象只创建一次,但是只能在单线程下使用,在多线程下就会创建多个实例对象出来,这里举个例子说明一下:假设我们有线程一和线程二同时调用 getInstance()
方法,这时候两个线程的 INSTANCE 可能都是为 null 的,所以一定会生成新实例,我们可以通过下面代码测试一下
public class SingletonTest {
public static void main(String[] args) {
// 通过模拟多线程环境,我们可以看到有多个线程在生成新实例
for (int i = 0; i < 100; i++) {
new Thread(() -> {
System.out.println(SingletonErrorOne.getInstance().hashCode());
}).start();
}
}
}
- 第二种错误的懒汉式单例模式
第一种错误写法的问题是线程不安全,所以我们自然会想到通过加锁(synchronized )的方式来进行线程同步,防止产生多实例对象,代码如下:
public class SingletonErrorTwo {
private static SingletonErrorTwo INSTANCE;
private SingletonErrorTwo() {
}
// 这种方式虽然线程安全了但是性能太差了,已经退化为单线程,而且整个实例方法都被阻塞了
// 如果该实例方法中存在耗时代码,将会大大降低接口性能,这时候我们可以降低锁粒度,只锁定部分代码
public static synchronized SingletonErrorTwo getInstance() {
try {
// 耗时代码
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
if (INSTANCE == null) {
System.out.println(Thread.currentThread().getName() + ":开始生成新实例");
INSTANCE = new SingletonErrorTwo();
}
return INSTANCE;
}
}
这种方式其实不算错误,只是我们不推荐使用,因为效率太低了,每次调用 getInstance 方法都会加锁,我们在实例方法里面加入一段耗时代码,这时候调用下面的测试方法,你将会发现效率特别的低下,这种就是锁粒度太大了,我们可以降低一下锁粒度,具体看第三种错误的懒汉式单例模式
public class SingletonTest {
public static void main(String[] args) {
// 通过模拟多线程环境,我们可以看到获取的都是同一个实例,但是执行效率特别的低下
for (int i = 0; i < 100; i++) {
new Thread(() -> {
System.out.println(SingletonErrorTwo.getInstance().hashCode());
}).start();
}
}
}
- 第三种错误的懒汉式单例模式
我们通过只锁定生成实例对象的这部分代码,让其他耗时代码并行执行,效率提高了,但是会产生多个实例对象,造成这种现象的原因是我们多个线程可能都通过了 if 判断,然后开始阻塞等待持有锁的线程释放锁,第一个持有锁的线程生成实例对象后释放锁,此时其他线程获得锁仍会继续生成新实例对象,因为已经通过了 if 判断,改进方法就是通过双重校验锁(DCL)来避免这种问题,我们获得锁之后可以再判断一次 INSTANCE 是否为空,这时候如果线程一是第一个获得锁的线程,那么线程一就会生成实例对象,如果线程二是第二个获得锁的线程,那么此时线程一已经生成完对象了,线程二就不会继续生成新对象,需要特别注意的是必须加上 volatile 字段,我们在下面进行讲解
注:这里由于我们加了生成新实例的打印输出,释放锁的速度比较慢,所以导致基本每个线程都会创建新实例,你可以去掉打印输出,你会发现虽然大部分输出的都是同个对象,但是还是会产生一些新对象,
public class SingletonErrorThree {
private static SingletonErrorThree INSTANCE;
private SingletonErrorThree() {
}
public static SingletonErrorThree getInstance() {
try {
// 耗时代码
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
if (INSTANCE == null) {
synchronized (SingletonErrorThree.class) {
System.out.println(Thread.currentThread().getName() + ":开始生成新实例");
INSTANCE = new SingletonErrorThree();
}
}
return INSTANCE;
}
}
public class SingletonTest {
public static void main(String[] args) {
// 通过模拟多线程环境,我们可以看到执行效率大幅提升了,但是有多个线程在生成新实例
for (int i = 0; i < 100; i++) {
new Thread(() -> {
System.out.println(SingletonErrorThree.getInstance().hashCode());
}).start();
}
}
}
这里再给出双重校验锁实现懒汉式单例模式的代码,看完代码看下面给出的问题,看是否都能答得上来
/**
* 懒汉式单例模式:双重校验锁(DCL,double-checked locking)方式
*/
public class Singleton {
private static volatile Singleton INSTANCE;
private Singleton() {
}
public static Singleton getInstance() {
if (INSTANCE == null) {
synchronized (Singleton.class) {
if (INSTANCE == null) {
INSTANCE = new Singleton();
}
}
}
return INSTANCE;
}
}
- 问题一:为什么要私有化构造方法?
- 问题二:为什么实例对象
INSTANCE
要加 static ? - 问题三:为什么要加 synchronized ?
- 问题四:为什么要有第一个 if 判断 ?
- 问题五:为什么要有第二个 if 判断 ?
- 问题六:为什么实例对象
INSTANCE
要加 volatile ?
答案如下:
-
解答一:同饿汉式
-
解答二:同饿汉式
-
解答三:为了让线程同步,每次创建实例前先加锁,防止产生多实例
-
解答四:为了提高效率,如果我们不加第一个 if 判断的话,那么每个线程都要加锁释放锁,但是其实我们只要第一个线程创建了实例对象后,后面的线程直接返回对象就好了,不需要再进行加锁操作
-
解答五:没有第二个 if 判断会产生多实例的情况,具体看第三种错误的懒汉式单例模式的讲解
-
解答六:我们知道 volatile 关键字有两大特性:可见性(变量修改后,所有线程都能立即实时地看到它的最新值)和禁止指令重排序;这里一开始我理解错了,我以为用到了可见性和指令重排序,可见性是因为第一个持有锁的线程创建完对象后,必须让其他线程知道,这样第二个 if 判断才会不为 null ,但是其实 synchronized 已经帮我们实现了线程的可见性,所以这里的 volatile 主要是起到禁止指令重排序的作用。
下面我们来详细讲解为什么要禁止指令重排序
我们要知道对象的创建其实不是一步到位的,它是分三步进行的,分别是
①、在堆中给对象分配内存空间
②、初始化赋值
③、建立关联(将引用指向分配的内存空间)
现在我们的单例类中有个成员变量 a,代码如下:
/** * 懒汉式单例模式:双重校验锁(DCL,double-checked locking)方式 */ public class Singleton { private static volatile Singleton INSTANCE; int a = 1; private Singleton() { } public static Singleton getInstance() { ... } }
当我们第一个持有锁的线程执行到
INSTANCE = new Singleton();
的时候正常是先分配内存空间,然后初始化给 a 赋值为 1,接着建立关联,让 INSTANCE 指向刚分配的内存空间,如果这个期间发生指令重排序,比如二三步骤调换顺序,这时候是先分配内存空间,然后就直接建立关联了,此时 a 还是默认值 0,其他线程会直接通过第二个 if 判断,因为此时已经建立关联了,所以 INSTANCE 已经不为空了,只是还没赋值为 1,这时候其他线程拿到的 a 可能为0,然后第一个持有锁的线程才将 a 赋值为1,所以我们必须禁止指令重排序,避免其他线程拿到半初始化状态的实例对象
3、静态内部类
这种方式的效果其实和饿汉式有点类似,都采用类加载机制确保创建的是单实例;但是饿汉式没有延迟初始化的功能,简单来说就是饿汉式不管你有没有调用,对象在类加载时就已经初始化了,而静态内部类方式只有调用
getInstance
时才会初始化静态内部类,进而初始化实例对象
public class Singleton {
private Singleton() {
}
private static class SingletonInstance {
private final static Singleton INSTANCE = new Singleton();
}
public static Singleton getInstance() {
return SingletonInstance.INSTANCE;
}
}
4、枚举
这种实现方式我没用过,这里就不做过多解释了,网上说这是实现单例模式的最佳方法,因为它更简洁,自动支持序列化机制,绝对防止多次实例化,不过,由于 JDK1.5 之后才加入 enum 特性,用这种方式写不免让人感觉生疏,在实际工作中,也很少用。
public enum Singleton {
INSTANCE;
public void add(int x, int y) {
System.out.println(x + y);
}
// 其他各种方法
// ...
}
测试方法如下:
public class SingletonTest {
public static void main(String[] args) {
for (int i = 0; i < 100; i++) {
new Thread(() -> {
System.out.println(Singleton.INSTANCE.hashCode());
}).start();
}
}
}
五、如何解决序列化反序列化导致单例模式失效问题
首先我们先实现 Serializable 接口,让单例类的对象可以序列化,代码如下:
public class Singleton implements Serializable {
private final static Singleton INSTANCE = new Singleton();
private Singleton() {
}
public static Singleton getInstance() {
return INSTANCE;
}
}
然后通过以下代码对实例对象序列化再反序列化,我们可以发现此时已经不是同个对象了
public class SingletonTest {
public static void main(String[] args) throws IOException, ClassNotFoundException {
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("tempFile"));
oos.writeObject(Singleton.getInstance());
File file = new File("tempFile");
ObjectInputStream ois = new ObjectInputStream(new FileInputStream(file));
Singleton singleton = (Singleton) ois.readObject();
System.out.println(singleton);
System.out.println(Singleton.getInstance());
// 结果为:false
System.out.println(Singleton.getInstance() == singleton);
}
}
解决方法如下:在单例对象代码中添加public Object readResolve()方法
public class Singleton implements Serializable {
private final static Singleton INSTANCE = new Singleton();
private Singleton() {
}
public static Singleton getInstance() {
return INSTANCE;
}
public Object readResolve() {
return getInstance();
}
}