细读Java单例模式

1,360 阅读11分钟

Java中的单例模式看似是一个很简单的设计模式,但事实上,我们可以整出各种各样的“幺蛾子”。单例模式有着不同的实现方式,也很难找到完美的方式。今天我就来分享一下,单例模式的几种常用实现模式以及存在的问题。

之前我写过文章讲解单例模式,不过那个是最简单的方式,还漏掉了许多的情况,这里我们就来详细地学习学习,这里还是以“一个店只能有一个老板”为例,创建老板类单例。

1,常规实现方式

(1) 饿汉式

这个就是上一篇博客讲的方法,也是最简单的实现方法:

package com.example.singleinstance.eager;

import lombok.Getter;
import lombok.Setter;

/**
 * 饿汉式单例模式
 */
@Getter
@Setter
public class Master {

	/**
	 * 名字
	 */
	private String name;

	/**
	 * 唯一单例
	 */
	private static Master instance = new Master();

	/**
	 * 私有化构造器
	 */
	private Master() {
	}

	/**
	 * 获取老板唯一单例
	 *
	 * @return 老板唯一单例
	 */
	public static Master getInstance() {
		return instance;
	}

}

可见饿汉式单例模式之所以叫饿汉式,是因为这种单例模式在类加载的时候就初始化了唯一单例了

这种方式的优缺点也很明显:

  • 优点:执行效率高,绝对线程安全
  • 缺点:有可能用不着该单例,但是它无论如何都初始化了,可能会“占着茅坑不拉屎”,浪费内存

那么如果要改善性能,我们需要进行一些修改。

(2) 懒汉式

懒汉式单例模式就是当外部访问该单例的时候,才会初始化:

package com.example.singleinstance.lazy;

import lombok.Getter;
import lombok.Setter;

/**
 * 懒汉式单例模式
 */
@Getter
@Setter
public class Master {

	/**
	 * 名字
	 */
	private String name;

	/**
	 * 唯一单例,先不初始化
	 */
	private static Master instance = null;

	/**
	 * 私有化构造器
	 */
	private Master() {
	}

	/**
	 * 获取老板唯一单例
	 *
	 * @return 老板唯一单例
	 */
	public static Master getInstance() {
		// 若没有初始化,则初始化一下
		if (instance == null) {
			instance = new Master();
		}
		return instance;
	}

}

可见我们先不初始化单例,在要调用的时候,判断是否为null,如果是说明是第一次调用,则初始化一下,否则就返回单例。

2,想办法破解单例模式

(1) 多线程破坏单例模式

懒汉式单例模式确实优化了性能,但是并非是线程安全的。假设有n个线程在极短的时间同时访问该单例的getInstance方法,那有可能会有多余一个线程同时判断该单例为null导致最后初始化出多个Master实例。

我们实例化单例的时候就打印输出一下单例的地址,修改getInstance如下:

public static Master getInstance() {
	// 若没有初始化,则初始化一下
	if (instance == null) {
		instance = new Master();
		System.out.println(instance);
	}
	return instance;
}

然后新建两个线程,利用IDEA的线程调试模式,干预线程的执行顺序,来模拟出两个线程同时执行到的情况:

for (int i = 0; i < 2; i++) {
	new Thread(() -> {
		Master master = Master.getInstance();
	}).start();
}

在这里打断点,并右键断点-线程模式:

image.png

image.png

执行调试,可以在调试控制台这里手动切换线程,控制线程运行:

image.png

使用步入按钮(F5),先让0号线程进入if语句,到达实例化这里停下:

image.png

切换到1号线程,也让1号线程进入if语句,到达实例化这里停下:

image.png

最后让两个线程执行完,可以看见控制台输出了两个不同的地址:

image.png

可见,懒汉式也不完全是线程安全的。

解决办法1:直接方法上锁

这时,我们可以给getInstance方法上锁,实现线程安全:

public synchronized static Master getInstance() {
	// 若没有初始化,则初始化一下
	if (instance == null) {
		instance = new Master();
	}
	return instance;
}

这样,确实是线程安全了,但是总归是上了锁,对程序的性能会有一定的影响,那难道就没有好一点的方法了吗?

解决办法2:双重检查锁

在Java中,双检锁(Double-Checked Locking)是一种用于实现懒加载(Lazy Initialization)的线程安全模式。它的目的是在多线程环境下延迟对象的创建,只有在第一次使用对象时才进行初始化。

比起直接给getInstance方法上锁,使用双检锁可以不仅可以保证线程安全,还可以提高性能,我们改造整个Master类如下:

package com.gitee.swsk33.singleinstance.dcl;

import lombok.Data;

/**
 * 懒汉式双检锁单例模式
 */
@Data
public class Master {

	/**
	 * 老板的唯一单例
	 */
	private static volatile Master instance;

	/**
	 * 名字
	 */
	private String name;

	/**
	 * 私有化构造器
	 */
	private Master() {

	}

	/**
	 * 获取老板唯一单例,使用双检锁延迟初始化
	 *
	 * @return 老板唯一单例
	 */
	public static Master getInstance() {
		// 第一次判断单例是否为空
		if (instance == null) {
			// 锁住整个类
			synchronized (Master.class) {
				// 第二次判断单例是否为空,确保只有一个线程创建对象
				if (instance == null) {
					// 初始化单例
					instance = new Master();
				}
			}
		}
		// 返回单例
		return instance;
	}

}

这里可见有两个要点:

  • 唯一单例instance前面加上了volatile关键字,这保证了该单例是线程可见的,也就是说能够保证在对象初始化完成之前,其他线程对该变量的读取都是正确的
  • getInstance方法中,首先检查 instance 是否为空,如果为空则进入同步块。在同步块内,再次检查 instance 是否为空,这是为了防止多个线程同时通过第一个检查并创建多个实例,如果通过了第二个检查,就在同步块内创建单例对象

可见比起单独上锁的懒汉式方法中,双检锁在保证了线程安全的情况下,还提升了性能,因为如果直接给getInstance方法上锁,那么每次调用懒加载方法都会对整个方法进行同步,即使单例对象已经被创建。而双重检查锁定机制通过在获取锁之前进行一次检查,可以避免对整个方法进行同步,从而提供更好的性能。它的基本思想是首先检查单例对象是否已经被创建,如果已经创建,则直接返回对象;如果尚未创建,则获取锁,并再次检查单例对象是否已经被创建,然后再创建对象。这样只有在对象尚未创建的情况下才会进行同步,避免了不必要的同步操作。

解决办法3:内部类

我们可以从类的初始化的角度想一下,我们可以借助内部类来解决这些问题。在Java中,内部类是延时加载的,也就是说你用它它就加载,不用就不加载,不受外部类的影响。利用内部类的这个特性,我们是否能够把单例放在内部类里面呢?我们来试一下子:

package com.example.singleinstance.lazy;

import lombok.Getter;
import lombok.Setter;

/**
 * 懒汉式内部类法单例模式
 */
@Getter
@Setter
public class Master {

	/**
	 * 名字
	 */
	private String name;

	/**
	 * 私有化构造器
	 */
	private Master() {
	}

	/**
	 * 获取老板唯一单例,final使得该方法不允许被重写或者重载
	 *
	 * @return 老板唯一单例
	 */
	public static final Master getInstance() {
		// 返回结果之前,会先加载内部类
		return InnerMaster.INSTANCE;
	}

	/**
	 * 老板类的内部类,没有用到它就不会加载
	 */
	private static class InnerMaster {
		private static final Master INSTANCE = new Master();
	}

}

这种方式完美地解决了饿汉式单例模式的内存问题,和上锁的性能问题。内部类一定是会在方法调用之前初始化,并且它永远只会初始化一次(一个类无法被加载多次),因此避免了线程安全问题。

(2) 反射破坏单例模式

构造器确实被私有化了,但是利用Java的反射机制,仍然可以访问其构造器:

// 利用反射获取构造方法,并设定可访问
Constructor constructor = Master.class.getDeclaredConstructor();
constructor.setAccessible(true);
Master master1 = (Master) constructor.newInstance();
Master master2 = (Master) constructor.newInstance();
System.out.println(master1 == master2);

image.png

可见即使是私有化了构造器,我们仍然还是可以把它new个两下,得到两个实例,违背了单例模式的基本原则。

解决办法1:修改构造器

解决这个问题也不难,我们在构造器里面做点功夫即可:

package com.example.singleinstance.lazy;

import lombok.Getter;
import lombok.Setter;

/**
 * 懒汉式内部类法单例模式
 */
@Getter
@Setter
public class Master {

	/**
	 * 名字
	 */
	private String name;

	/**
	 * 私有化构造器
	 */
	private Master() {
		if (InnerMaster.INSTANCE != null) {
			throw new RuntimeException("不允许创建多个实例!");
		}
	}

	/**
	 * 获取老板唯一单例,final使得该方法不允许被重写或者重载
	 *
	 * @return 老板唯一单例
	 */
	public static final Master getInstance() {
		// 返回结果之前,会先加载内部类
		return InnerMaster.INSTANCE;
	}

	/**
	 * 老板类的内部类,没有用到它就不会加载
	 */
	private static class InnerMaster {
		private static final Master INSTANCE = new Master();
	}

}

再次运行上述代码:

image.png

好了,到这里我们也更进一步地明白了:一个类被加载时,其内部类不会被加载;而这个类被使用到时,其内部类才会被加载。

这里注意加载使用的区别。应用程序启动时,每个类都会被加载,而你调用这个类用于实例化或者调用其方法的时候,才叫使用这个类。

(3) 序列化破坏单例模式

有时候我们需要把对象序列化并在网络上传输,然后反序列化。大家都知道,反序列化的对象并非是原有的对象,这也破坏了单例模式的原则。

首先让Master类使用Serializable接口,然后作如下测试:

// 序列化
ByteArrayOutputStream bos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(bos);
oos.writeObject(Master.getInstance());
// 再反序列化
ByteArrayInputStream bis = new ByteArrayInputStream(bos.toByteArray());
ObjectInputStream ois = new ObjectInputStream(bis);
Master master = (Master) ois.readObject();
System.out.println(master == Master.getInstance());

image.png

可见,利用序列化法破坏了单例。

解决办法1:添加readResolve方法

其实,我们只需要在Master类中增加一个readResolve方法即可:

package com.example.singleinstance.lazy;

import lombok.Getter;
import lombok.Setter;

import java.io.Serializable;

/**
 * 懒汉式内部类法单例模式
 */
@Getter
@Setter
public class Master implements Serializable {

	/**
	 * 名字
	 */
	private String name;

	/**
	 * 私有化构造器
	 */
	private Master() {
		if (InnerMaster.INSTANCE != null) {
			throw new RuntimeException("不允许创建多个实例!");
		}
	}

	/**
	 * 获取老板唯一单例,final使得该方法不允许被重写或者重载
	 *
	 * @return 老板唯一单例
	 */
	public static final Master getInstance() {
		// 返回结果之前,会先加载内部类
		return InnerMaster.INSTANCE;
	}

	private Object readResolve() {
		return InnerMaster.INSTANCE;
	}

	/**
	 * 老板类的内部类,没有用到它就不会加载
	 */
	private static class InnerMaster {
		private static final Master INSTANCE = new Master();
	}

}

再次运行:

image.png

这看起来非常神奇:为什么加这个方法就可以了呢?事实上这和ObjectInputStream类的执行逻辑有关。大家可以去研究一下JDK源码就知道了,再次不再过多赘述。

但事实上,这种方法确实保证只返回了一个单例,但是内存中其实还是有多个单例。

当然,肯定有更好的方法。

3,注册式单例模式

顾名思义,注册式单例模式就是把实例先注册到一个地方,获取的时候根据标识符获取。

通常有下列两种方式实现。

(1) 【推荐】枚举式单例模式

利用枚举实现单例模式,也就是把单例类写成枚举类,我们修改Master类如下:

package com.example.singleinstance.enumerate;

import lombok.Getter;
import lombok.Setter;

@Getter
public enum Master {

	/**
	 * 老板类唯一单例
	 */
	INSTANCE;

	/**
	 * 名字
	 */
	@Setter
	private String name;

	/**
	 * 获取老板类唯一实例
	 *
	 * @return 老板类唯一实例
	 */
	public static Master getInstance() {
		return INSTANCE;
	}

}

大家都知道:枚举类中的每一个枚举相当于就是这个枚举类的实例,并且枚举类中也可以写成员变量和方法

那枚举类中的枚举是不是单例呢?我们来试一下子。

1. 尝试使用反射破坏

Constructor constructor = Master.class.getDeclaredConstructor();
constructor.setAccessible(true);
Master master = (Master) constructor.newInstance();

结果:

image.png

可见反射机制找不到枚举类的构造器,这是因为枚举类的构造方法是protected的:

image.png

2. 尝试使用序列化破坏

// 序列化
ByteArrayOutputStream bos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(bos);
oos.writeObject(Master.getInstance());
// 再反序列化
ByteArrayInputStream bis = new ByteArrayInputStream(bos.toByteArray());
ObjectInputStream ois = new ObjectInputStream(bis);
Master master = (Master) ois.readObject();
System.out.println(master == Master.getInstance());

结果:

image.png

这也是利用JDK的反序列化机制,也就是说枚举类型其实是通过类名和类对象找到一个唯一的对象,不会被类加载器加载多次

这也可见:枚举值天生就是单例的,非常契合单例模式思想。

(2) 容器式单例

我们还可以使用Map专门做一个单例容器,把实例都放进去:

package com.example.singleinstance;

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

/**
 * 单例容器
 */
public class SingleContainer {

	private SingleContainer() {
	}

	// 存放所有单例的容器,键为类的全限定名,值为对应单实例
	private static Map<String, Object> container = new ConcurrentHashMap<>();

	/**
	 * 获取对应类的单实例,不存在则创建
	 *
	 * @param className 类的全限定名
	 * @return 单实例
	 */
	public synchronized static Object getInstance(String className) throws Exception {
		if (!container.containsKey(className)) {
			Object instance = Class.forName(className).getConstructor().newInstance();
			container.put(className, instance);
			return instance;
		}
		return container.get(className);
	}

}

这种方式看起来也很高级,适合多个单例类的统一管理,不过也会产生线程问题。

4,总结

可见单例模式看起来简单,事实上要想写一个严谨、滴水不漏的单例模式还是很难的。

日常开发,推荐使用基于双检锁的懒汉式单例模式或者是枚举式单例模式

示例仓库地址