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); } }
测试代码及结果如上,一切看着毫无违和感。
那自然而然,我们考虑一下多线程如何呢。
我们来创建一个线程类
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 自带的断点测试来测试多线程下的问题,我们在
LazySingleton
如下位置打上断点。(设置断点的 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,因为在单线程的运行下,此类重排序是不会影响最终结果的。
上一个图来说明一下指令重排序引起的问题吧
此时便会发生:线程0中对象未初始化完成,线程1就访问了对象。
那问题来了,也就该处理了。
针对以上问题,我们处理思路其实有两种:
不允许步骤 2、3 进行重排序。
允许步骤 2、3 进行重排序,但是这个重排序过程不能让其他线程看到。
不允许步骤 2、3 进行重排序
只需要对象添加
volatile
关键字即可。
private volatile static LazyDoubleCheckSingleton lazyDoubleCheckSingleton;
具体其中的原理,会在其他内容中进行分析,不是此次的重点。
允许步骤 2、3 进行重排序,但是这个重排序过程不能让其他线程看到。
基于静态内部类的解决方案
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
了一个新的对象,导致读出的对象与写入的对象不是同一个对象。
那你一定想问我,那怎么处理呢,别着急啊,接着往下看。
这个变量的初始化,可以直接通过查找看到。
这不就清楚了嘛,有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 线程获取到的对象是不同的。https://wenku.baidu.com/view/6bb581fdae45b307e87101f69e3143323868f5eb
如果此时容器单例不使用 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."); } }
结果
我们今天的讨论到现在就结束了。今天主要讨论了入如下内容。
基本的单例模式的实现:懒汉式和饿汉式。
针对多线程下的单例模式线程安全的讨论。
序列化和反序列化对单例模式的破坏。
反射对单例模式的破坏。
Enum 枚举单例。
单例容器。
ThreadLocal 线程单例。