前言
关于单例模式,我想大家都不陌生,Spring的核心IOC就是基于单例思想 提出的。我个人认为理解一门技术,首先需要了解它为什么会出现,看下面一种场景:在做后台与数据库进行交互时,需要创建连接类,需要定义一个长连接维护该通道,双方都需要消耗CPU、内存去维护这一个通道。而我们又存在着大量的需要去使用这种连接的业务,这个时候,我们每次都去new 一个connection那可太耗费资源了。还有我们用来做累加器的类 、获取当前时间的类这种都不需要多次创建对象,仅仅只需要一个单例即可。关于单例模式这个很常见的知识点,我觉得还是挺有意思的,因为他需要我们对JVM、JUC等一些知识有一定的基础了解。
今天我详细总结了一下,希望能加深对该知识点的理解。
饿汉式
如其名,饿汉式,一个非常有意思的名字。将该实例想象成一个包子,当一个非常饥饿的大汉过来,想马上吃到该包子,因此该包子必须在有人来之前就得做好。
可写成下面这种写法。
java
public class Singleton{
//1.构造器私有,避免直接被外部new出来
private Singleton(){
}
//2.本类内部创建对象实例
private final static Singleton instance = new Singleton();
//3.对外提供一个公有的方法,返回实例对象
public static Singleton getInstance(){
return instance;
}
}
注意点
- 该模式是线程安全的,因为JVM加载该类之时会确保其线程安全。
- 但是无法做到按需加载,存在浪费资源的可能性。
懒汉式
对于懒汉式而言,需要做到按需加载,这次来的大汉并不饿,所以我们不需要一开始就做好,有人来的时候我们直接现场给他做好就行。
可写成下面这种方式。
java
public class Singleton{
private static Singleton instance;
//构造器私有,避免直接被外部new出来
private Singleton(){
}
//对外提供一个公有的方法,返回实例对象
public static Singleton getInstance(){
if(instance == null){
instance = new Singleton();
}
return instance;
}
}
注意点
这种写法并不是线程安全的,这里补充一个需要理解的点:从Java代码到可执行文件,至少要经过几个步骤,首先将.java文件加载进JVM,JVM按照关键词表格翻译成为可执行的.class文件。而在这些过程中,有些步骤不是原子操作,例如 赋值操作至少需要三步
下一种写法,则是我们应当掌握的!!!
双检锁(Double-Check)
java
public class Singleton{
private static volatile Singleton instance = null;
//构造器私有,避免直接被外部new出来
private Singleton(){
}
//对外提供一个公有的方法,返回实例对象
public static Singleton getInstance(){
if(instance == null){
Synchronized(Singleton.class){
if(instance == null){
instance = new Singleton();
}
}
}
return instance;
}
}
我们进行了两次 if (singleton == null) 检查,这就是"双重检查锁"这个名字的由来。这种写法是可以保证线程安全的,假设有两个线程同时到达 synchronized 语句块,那么实例化代码只会由其中先抢到锁的线程执行一次,而后抢到锁的线程会在第二个 if 判断中发现 singleton 不为 null,所以跳过创建实例的语句。再后面的其他线程再来调用 getInstance 方法时,只需判断第一次的 if (singleton == null) ,然后会跳过整个 if 块语句,直接 return 实例化后的对象。
这种写法的优点是不仅线程安全,而且延迟加载、效率也更高。
为什么需要二次检查,去掉任意的一次行不行?
我们先来看第二次的 check,这时你需要考虑这样一种情况,有两个线程同时调用 getInstance 方法,由于 singleton 是空的 ,因此两个线程都可以通过第一重的 if 判断;然后由于锁机制的存在,会有一个线程先进入同步语句,并进入第二重 if 判断 ,而另外的一个线程就会在外面等待。
不过,当第一个线程执行完 new Singleton() 语句后,就会退出 synchronized 保护的区域,这时如果没有第二重 if (singleton == null) 判断的话,那么第二个线程也会创建一个实例,此时就破坏了单例,这肯定是不行的。
而对于第一个 check 而言,如果去掉它,那么所有线程都会串行执行,效率低下,所以两个 check 都是需要保留的。
volatile的作用是什么?
相信细心的你可能看到了,我们在双重检查锁模式中,给 singleton 这个对象加了 volatile 关键字,那为什么要用 volatile 呢?主要就在于 singleton = new Singleton() ,它并非是一个原子操作,事实上,在 JVM 中上述语句至少做了以下这 3 件事:
csharp
第一步是给 singleton 分配内存空间;
然后第二步开始调用 Singleton 的构造函数等,来初始化 singleton;
最后第三步,将 singleton 对象指向分配的内存空间(执行完这步 singleton 就不是 null 了)。
这里需要留意一下 1-2-3 的顺序,因为存在指令重排序的优化,也就是说第2 步和第 3 步的顺序是不能保证的,最终的执行顺序,可能是 1-2-3,也有可能是 1-3-2。
如果是 1-3-2,那么在第 3 步执行完以后,singleton 就不是 null 了,可是这时第 2 步并没有执行,singleton 对象未完成初始化,它的属性的值可能不是我们所预期的值。假设此时线程 2 进入 getInstance 方法,由于 singleton 已经不是 null 了,所以会通过第一重检查并直接返回,但其实这时的 singleton 并没有完成初始化,所以使用这个实例的时候会报错,详细流程如下图所示:
线程 1 首先执行新建实例的第一步,也就是分配单例对象的内存空间,由于线程 1 被重排序,所以执行了新建实例的第三步,也就是把 singleton 指向之前分配出来的内存地址,在这第三步执行之后,singleton 对象便不再是 null。
这时线程 2 进入 getInstance 方法,判断 singleton 对象不是 null,紧接着线程 2 就返回 singleton 对象并使用,由于没有初始化,所以报错了。最后,线程 1 "姗姗来迟",才开始执行新建实例的第二步------初始化对象,可是这时的初始化已经晚了,因为前面已经报错了。
使用了 volatile 之后,相当于是表明了该字段的更新可能是在其他线程中发生的,因此应确保在读取另一个线程写入的值时,可以顺利执行接下来所需的操作。在 JDK 5 以及后续版本所使用的 JMM 中,在使用了 volatile 后,会一定程度禁止相关语句的重排序,从而避免了上述由于重排序所导致的读取到不完整对象的问题的发生。
到这里关于"为什么要用 volatile" 的问题就讲完了,使用 volatile 的意义主要在于它可以防止避免拿到没完成初始化的对象,从而保证了线程安全。
静态内部类
java
public class Singleton{
private static volatile Singleton instance;
//避免外部创建实例
private Singleton(){
}
//静态内部类
private static class SingletonInstance{
private static final Singleton INSTANCE = new Singleton();
}
public static Singleton getInstance(){
return SingletonInstance.INSTANCE;
}
jvm底层类加载机制保证线程安全,外部类加载不会导致内部类加载 在调用getInstance方法时,才会去加载内部类,内部类一加载,就会创建实例。
枚举类
java
enum Singleton{
INSTANCE;
}
枚举类是Java提供的又一单例模式,我们常常用它来作为常量枚举类。
破坏单例模式的方式
反射
反射,这个大名鼎鼎的技术。各大框架底层源码大量使用着他,说它是框架的基石、Java出名的原因都不为过。通过反射,我们可以获取到一个类的所有信息,方法、类、方法上的注解,方法中的参数、字段等等信息。
java
public class Singleton {
public static void main(String[] args) {
Singleton singleton = Singleton.getSingleton();
try {
Class<Singleton> singleClass = (Class<Singleton>)Class.forName("Singleton");
Constructor<Singleton> constructor = singleClass.getDeclaredConstructor(null);
constructor.setAccessible(true);
Singleton singletonByReflect = constructor.newInstance();
System.out.println(singleton == singletonByReflect);
} catch (Exception e) {
e.printStackTrace();
}
}
}
输出结果如下:
java
false
如何避免反射破坏单例
我们可以在单例类的构造函数中增加下面一段代码, 如下方式,我们在Singleton增加如下代码:
java
private Singleton() {
if (singleton != null) {
throw new RuntimeException("Singleton constructor is called... ");
}
}
这样,在通过反射调用构造方法的时候,就会抛出异常:
java
Caused by: java.lang.RuntimeException: Singleton constructor is called...
序列化与反序列化
序列化与反序列化通常使用在实体类在网络中进行传输。
我们可以通过下面这种方式,先将单例对象序列化后保存到临时文件中,然后再从临时文件中反序列化出来,这样便得到了一个新的实例而不是通过getInstance
方法获得。
java
public class Singleton {
public static void main(String[] args) {
Singleton singleton = Singleton.getSingleton();
//Write Obj to file
ObjectOutputStream oos = null;
try {
oos = new ObjectOutputStream(new FileOutputStream("temp"));
oos.writeObject(singleton);
//Read Obj from file
File file = new File("temp");
ObjectInputStream ois = new ObjectInputStream(new FileInputStream(file));
Singleton singletonBySerialize = (Singleton)ois.readObject();
//判断是否是同一个对象
System.out.println((singleton == singletonBySerialize);
} catch (Exception e) {
e.printStackTrace();
}
}
}
输出结果如下:
java
false
如上,通过先序列化再反序列化的方式,可获取到一个新的单例对象,这就破坏了单例。这就并不符合单例的定义了。 因为在对象反序列化的过程中,序列化会通过反射调用无参数的构造方法创建一个新的对象,所以,通过反序列化也能破坏单例。
如何避免序列化与反序列化破坏单例?
只要修改下反序列化策略就好了。我们可以去修改一下反序列化的策略,在Sinleton中增加readResolve方法,并在该方法中指定要返回的对象的生成策略就可以了。即序列化在Singleton类中增加以下代码即可:
java
private Object readResolve() {
return getSingleton();
}
为什么增加了readResolve就可以解决序列化与反序列化破坏了单例的问题了?
因为反序列化过程中,在反序列化执行过程中会执行到ObjectInputStream#readOrdinaryObject方法,这个方法会判断对象是否包含readResolve方法,如果包含的话会直接调用这个方法获得对象实例。
总结
本觉得单例模式非常的简单,可是一总结下来发现涉及的知识点比较多,也有很多值得深入探究的地方。
-
单例模式我们需要掌握最终要的一种写法:双检锁写法。双检锁的两次检查都不能去掉。
-
在Java代码转换为可执行代码时,CPU或JVM会对其进行指令重排,因此会造成赋值乱序问题,因此需要利用
volatile关键字
刷新共享内存确保所有线程读到的实例是最新的以及利用Synchronized
关键字来确保只有一个线程可以拿到该类并去初始化,避免拿到初始化但未赋值的实例变量。 -
破坏单例的模式也有几种,反射与序列化技术,不过序列化技术底层还是依赖着反射,所以我们可以加上特定的代码,来阻止这两种方式去破坏单例模式。