很多人上来肯定一脸懵逼,因为在你的印象中,单例模式实现起来还是很简单的。不要着急,慢慢往下看,你就知道为什么我说它最难了。
1. 基本概念
- 单例模式是一种常用的创建型设计模式。单例模式保证类仅有一个实例,并提供一个全局访问点。
2. 适用场景
-
想确保任何情况下都绝对只有一个实例。
-
典型的场景有:windows 的任务管理器、windows 的回收站、线程池的设计等。
3. 单例模式的优缺点
优点
- 内存中只有一个实例,减少了内存开销。
- 可以避免对资源的多重占用。
- 设置全局访问点,严格控制访问。
缺点
- 没有接口,扩展困难。
4. 常见的实现模式
- 懒汉式
- 饿汉式
5. 先搞一个懒汉式的玩一玩
public class LazySingleton {
// 1\. 私有对象
private static LazySingleton lazySingleton = null;
// 2\. 构造方法私有化
private LazySingleton() {}
// 3\. 设置全局访问点
public static LazySingleton getInstance() {
if (lazySingleton == null) {
lazySingleton = new LazySingleton();
}
return lazySingleton;
}
}
接下来,我们单线程测试
public class MainTest {
public static void main(String[] args) {
LazySingleton instance = LazySingleton.getInstance();
LazySingleton instance2 = LazySingleton.getInstance();
System.out.println(instance == instance2);
}
}
img
- 测试代码及结果如上,一切看着毫无违和感。
那自然而然,我们考虑一下多线程如何呢。
- 我们来创建一个线程类
public class MyThread implements Runnable {
@Override
public void run() {
LazySingleton instance = LazySingleton.getInstance();
System.out.println(Thread.currentThread().getName() + " " + instance);
}
}
- 然后修改我们的测试代码
public class MainTest {
public static void main(String[] args) {
Thread t1 = new Thread(new MyThread());
Thread t2 = new Thread(new MyThread());
t1.start();
t2.start();
System.out.println("program end.");
}
}
-
我们通过 IDEA 自带的断点测试来测试多线程下的问题,我们在
imgLazySingleton
如下位置打上断点。(设置断点的 Suspend 为 Thread) -
我们通过 debug 方式启动测试代码,然后通过 IDEA 的工具窗口切换线程进行查看。(具体的 IDEA 调试多线程代码的方法可以通过各种途径学习,当然,也可以找我,我教你。虽然我也是略知皮毛。)
-
此时会看到有 Thread-0 和 Thread-1 两个线程,此时两个线程都判断了
lazySingleton
为空,此时两个线程都会创建对象。 -
将代码执行完,此时可以看到控制台打印的消息。
- 很明显地,两个线程拿到的是不同的对象。也就说明了,我们如上的懒汉式代码不是线程安全的,在多线程下可能会创建出多个对象。
那接下来,我们就应该想办法处理这种情况了。
- 通过在全局访问点添加
synchronized
关键字处理
// 3\. 设置全局访问点
public synchronized static LazySingleton getInstance() {
if (lazySingleton == null) {
lazySingleton = new LazySingleton();
}
return lazySingleton;
}
如上问题是处理了,但是出现了新的问题,该方法访问时会加锁,导致访问效率降低,但是只要是判断和创建对象的时候加锁即可,大概率情况下,该对象已经创建出来,并发访问也是没有什么问题的。为了实现这个目的,我们又提出了“Double Check 双重检查方案”
- 废话不多说,上代码。
public class LazyDoubleCheckSingleton {
private static LazyDoubleCheckSingleton lazyDoubleCheckSingleton;
private LazyDoubleCheckSingleton() {}
public static LazyDoubleCheckSingleton getInstance() {
if (lazyDoubleCheckSingleton == null) {
synchronized (LazyDoubleCheckSingleton.class) {
if (lazyDoubleCheckSingleton == null) {
lazyDoubleCheckSingleton = new LazyDoubleCheckSingleton();
}
}
}
return lazyDoubleCheckSingleton;
}
}
- 此代码便实现了在大概率情况下,
lazyDoubleCheckSingleton
已经不为空,也就不需要获取到锁,可以实现多线程并发访问。
但是如上代码还是有一些问题的,因为问题很难复现,也就不做演示。问题是由大名鼎鼎的“指令重排序”引起的。
-
来大概说明一下原理,可能不是很准确,但是主要以理解这个问题为目的。
-
其实创建对象(
new LazyDoubleCheckSingleton()
)这个操作在底层我们可以看作三个步骤:- memory = allocate(); // 1:分配对象的内存空间
- ctorInstance(memory); // 2:初始化对象
- lazyDoubleCheckSingleton = memory; // 3:设置 lazyDoubleCheckSingleton 指向刚分配的内存地址
-
针对这个问题,Java 语言规范中是有要求的,就是必须遵守 intra-thread semantics (线程内语义),保证重排序不会改变单线程内的程序执行结果。
-
但是在上述例子中,2、3步骤可能会出现重排序,也就是可能出现,先指向内存地址,再初始化对象,此时,lazyDoubleCheckSingleton 不为空,但是对象还未初始化完成。问题也就出现了。并且此时重排序操作并不会违反 intra-thread semantics,因为在单线程的运行下,此类重排序是不会影响最终结果的。
上一个图来说明一下指令重排序引起的问题吧
img- 此时便会发生:线程0中对象未初始化完成,线程1就访问了对象。
那问题来了,也就该处理了。
- 针对以上问题,我们处理思路其实有两种:
- 不允许步骤 2、3 进行重排序。
- 允许步骤 2、3 进行重排序,但是这个重排序过程不能让其他线程看到。
不允许步骤 2、3 进行重排序
- 只需要对象添加
volatile
关键字即可。
private volatile static LazyDoubleCheckSingleton lazyDoubleCheckSingleton;
- 具体其中的原理,会在其他内容中进行分析,不是此次的重点。
允许步骤 2、3 进行重排序,但是这个重排序过程不能让其他线程看到。
img基于静态内部类的解决方案
public class StaticInnerClassSingleton {
// InnerClass 对象锁
private static class InnerClass {
private static StaticInnerClassSingleton staticInnerClassSingleton = new StaticInnerClassSingleton();
}
private StaticInnerClassSingleton() {}
public static StaticInnerClassSingleton getInstance() {
return InnerClass.staticInnerClassSingleton;
}
}
到此为止,咱们的懒汉式先告一段落啊。。。丧心病狂呀,有木有。。。
6. 那咱们就再来玩玩饿汉式
public class HungrySingleton {
private final static HungrySingleton hungrySingleton;
static {
hungrySingleton = new HungrySingleton();
}
private HungrySingleton() {}
public static HungrySingleton getInstance() {
return hungrySingleton;
}
}
- 这个东西在多线程下就好点了,因为饿汉式是在类初始化的时候便把对象创建好了,所以也不需要判断对象是不是空,当然,在多线程下也就没那么多需要我们考虑的了。
7. 然后,然后,咱们再来看看序列化和反序列化的情况下,单例模式有没有什么问题呢。
- 因序列化问题与懒汉式还是饿汉式实现无关,以下便以饿汉式代码为例展示。
饿汉式
- 首先,我们的单例类实现序列化
public class HungrySingleton implements Serializable {
// ...
}
- 然后我们来写一个测试代码
public class MainTest {
public static void main(String[] args) throws IOException, ClassNotFoundException {
HungrySingleton instance = HungrySingleton.getInstance();
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("test.txt"));
oos.writeObject(instance);
File file = new File("test.txt");
ObjectInputStream ois = new ObjectInputStream(new FileInputStream(file));
HungrySingleton newInstance = (HungrySingleton) ois.readObject();
System.out.println(instance);
System.out.println(newInstance);
System.out.println(instance == newInstance);
}
}
- 我们来看一下执行结果
- 哈哈哈哈,瞬间窒息了,有木有。。。
针对上面问题,咱们来看一看源码,找一找原因啊。
- 如下是跟踪源码的过程,我只做简单截图,有兴趣可自行研究(哈哈哈,或者你可以找我呀,我们一起研究)。
看到这儿,我感觉你应该也就知道了,desc.isInstantiable()
方法返回了true
,所以通过反射new
了一个新的对象,导致读出的对象与写入的对象不是同一个对象。
那你一定想问我,那怎么处理呢,别着急啊,接着往下看。
img img- 这个变量的初始化,可以直接通过查找看到。
这不就清楚了嘛,有readResolve()
方法的时候,直接通过调用该方法返回了单例对象,那我们处理起来也就简单了,为我们的单例类添加一个方法即可。
private Object readResolve() {
return hungrySingleton;
}
- 然后重新直接测试代码,会出现如下结果。
8. 序列化和序列化的问题说完了,咱们再来看看反射的问题吧,毕竟反射我们用的还是很多的,通过反射去创建一个对象也是常用的操作。
该问题针对两种方式是不一样的,我们先来看看饿汉式的表现。
- 我们来写个测试代码
public class MainTest {
public static void main(String[] args) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {
Class objectClass = HungrySingleton.class;
Constructor constructor = objectClass.getDeclaredConstructor();
constructor.setAccessible(true);
HungrySingleton instance = HungrySingleton.getInstance();
HungrySingleton newInstance = (HungrySingleton) constructor.newInstance();
System.out.println(instance);
System.out.println(newInstance);
System.out.println(instance == newInstance);
}
}
- 看看运行结果
有一种五雷轰顶的感觉了没,别着急,别着急,咱们慢慢搞啊,虽然花点时间,但是能搞到很多东西的。
既然问题出来了,那怎么处理呢?其实处理也简单,因为反射是讲私有构造方法权限进行了开放,那我们在私有构造中添加判断即可。
private HungrySingleton() {
if (hungrySingleton != null) {
throw new RuntimeException("单例构造器禁止反射调用!");
}
}
- 再来运行我们的测试代码,可以看到会抛出以下异常。
接下来我们分析分析懒汉式
- 与饿汉式添加同样的操作,也是避免不了反射的。
- 假如先使用getInstance()方法获取对象,然后使用反射创建对象,是可以抛出异常的。
- 但是当先使用反射创建对象,再通过getInstance()方法获取对象时,便可以获取到两个不同的对象,还是避免不了对单例模式的破坏。
最终的结论,懒汉式是无法防止反射攻击的。
9. 然后估计你就快晕了,你肯定想问,难道以后做一个单例都要考虑这么多问题嘛,也太墨迹了点吧。那咱们接下来就看看用枚举来实现单例的方法吧。
- 该方法为Effective Java书中推荐的用法。
- 该方法完美解决了序列化及反射对单例模式的破坏。
上代码
public enum EnumInstance {
INSTANCE;
private Object data;
public Object getData() {
return data;
}
public void setData(Object data) {
this.data = data;
}
public static EnumInstance getInstance() {
return INSTANCE;
}
}
上面既然说了,完美解决了序列化及反射对单例模式的破坏,那咱们接下来就看看是如何解决的。
解决序列化对单例模式的破坏
- 我们还是来看
ObjectInputStream.readObject()
方法
- 可以看出是使用名称通过反射去获取到
Enum
,并没有创建新的对象,所以获取到的是同一个对象。
解决反射对单例模式的破坏
- 来写一个测试代码
public class MainTest {
public static void main(String[] args) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {
Class objectClass = EnumInstance.class;
Constructor constructor = objectClass.getDeclaredConstructor();
constructor.setAccessible(true);
EnumInstance instance = EnumInstance.getInstance();
EnumInstance newInstance = (EnumInstance) constructor.newInstance();
System.out.println(instance);
System.out.println(newInstance);
System.out.println(instance == newInstance);
}
}
- 结果
- 来看一下
java.lang.Enum
类,我们可以看到只有一个构造方法,且需要两个参数。
- 那我们就来传入两个参数试一下。
- 最终的结果
- 我们来看一下原因啊,请看
constructor.newInstance()
方法
-
发现其对 Enum 类型进行了处理,不允许通过反射创建 Enum 对象。
-
至此我们也就明白了,为什么 Enum 单例可以完美防止序列化及反射对单例模式的破坏了。
OK 了,我们再来搞两个相关的东西
10. 我们来聊聊容器单例
- 为了方便,使用 HashMap 来实现一个容器单例
直接走代码
public class ContainerSingleton {
private static Map<String, Object> singletonMap = new HashMap<>();
private ContainerSingleton() {}
public static void putInstance(String key, Object instance) {
if (key != null && !"".equals(key) && instance != null) {
if (!singletonMap.containsKey(key)) {
singletonMap.put(key, instance);
}
}
}
public static Object getInstance(String key) {
return singletonMap.get(key);
}
}
针对上述代码的说明
-
因其key 相同,所以最终获取到的是同一个对象。
-
但是上述代码是线程不安全的。在多线程情况下,如果两个线程同时判断 if 条件成立,此时 t1 线程 put,t1 线程 get;然后 t2 线程 put ,t2 线程 get 时,t1 线程与 t2 线程获取到的对象是不同的。
-
如果此时容器单例不使用 HashMap,而使用 HashTable 是可以实现线程安全的,但是从性能考虑,假如 get 请求多的情况下,HashTable 效率会非常低下。
11. 最后一个,我们来看看 ThreadLocal 线程单例怎么实现
定义一个线程单例类
public class ThreadLocalInstance {
private static final ThreadLocal<ThreadLocalInstance> threadLocalInstanceThreadLocal =
new ThreadLocal<ThreadLocalInstance>() {
@Override
protected ThreadLocalInstance initialValue() {
return new ThreadLocalInstance();
}
};
private ThreadLocalInstance() {}
public static ThreadLocalInstance getInstance() {
return threadLocalInstanceThreadLocal.get();
}
}
实现一个线程类做测试
public class MyThread implements Runnable {
@Override
public void run() {
ThreadLocalInstance instance = ThreadLocalInstance.getInstance();
System.out.println(Thread.currentThread().getName() + " " + instance);
}
}
写一个测试代码来测试一下
public class MainTest {
public static void main(String[] args) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {
System.out.println(Thread.currentThread().getName() + " " + ThreadLocalInstance.getInstance());
System.out.println(Thread.currentThread().getName() + " " + ThreadLocalInstance.getInstance());
System.out.println(Thread.currentThread().getName() + " " + ThreadLocalInstance.getInstance());
Thread t1 = new Thread(new MyThread());
Thread t2 = new Thread(new MyThread());
t1.start();
t2.start();
System.out.println("program end.");
}
}
结果
img我们今天的讨论到现在就结束了。今天主要讨论了入如下内容。
- 基本的单例模式的实现:懒汉式和饿汉式。
- 针对多线程下的单例模式线程安全的讨论。
- 序列化和反序列化对单例模式的破坏。
- 反射对单例模式的破坏。
- Enum 枚举单例。
- 单例容器。
- ThreadLocal 线程单例。