你真的懂了单例模式吗?
单例模式应该是面试过程中被问到最多的一个设计模式了,但是面试中通过一个单例模式其实可以考察的知识点非常的多,本文从单例模式出发,力求把沿途的知识点都带上,单例相关的内容看这一篇就足够了。
一、什么是单例模式
首先还是一起再复习一遍单例模式的定义:单例模式是指在内存中只会创建且仅创建一次对象的设计模式。在程序中一次创建,多次使用相同的对象,所有的地方都是共享这一个变量。
知道了定义那么接下来就是要写一写了,不卖官司直接来干货。
常用的单例模式有两种:
- 懒汉模式:在真正需要的时候采取创建单例对象
- 饿汉模式:在类加载的时候就创建单例对象
首先说懒汉模式,就是在程序中第一次被使用的时候,才去创建,这里直接给出最终的实现,因为这个不是本文的重点
public class Singleton { private static volatile Singleton singleton; private Singleton() {} public static Singleton getInstance() { if(null == singleton) { synchronized (Singleton.class) { if(null == singleton) { singleton = new Singleton(); } } } return singleton; } }
懒汉模式的重点有以下几点:
- 私有静态变量要用volatile修饰
- 无参构造方法要私有化
- Double check的synchronized要锁类而非成员变量
接下来是饿汉模式,就是在类被加载的时候直接把单例对象初始化,这样在使用的时候直接获取即可
public class Singleton { private static final Singleton singleton = new Singleton(); private Singleton() {} public static Singleton getInstance() { return singleton; } }
这里单例对象是私有静态的,在类被加载进JVM的时候就已经被初始化了
这两种方式就是最常用的单例写法,面试的时候给出这两种,并且说明缘由,那么第一关就算是OK了。
二、单例模式可以被破坏吗?
写出了单例模式,接下来一般会问。通过单例模式创建的对象,在内存中只可以存在一个吗?正确的回答应该是:不是。
其实这里大家误解了一个概念,就是我们通过上面的方式创建的单例对象一定是唯一的,正常情况下在内存中也一定是只存在一个单例对象被共享使用的。但是这并不代表在Jvm中只能存在一个单例对象。这两个概念一定要区分清楚,那么上面的单例类的构造方法都已经是私有的了,我们怎么在内存中再创建一个单例对象呢?
第一种方式:反射
通过反射的方式我们可以获取到单例类的私有构造方式,通过构造方式可以构造出另外一个单例对象
public class SingletonTest { public static void main(String[] args) throws Exception { Constructor<Singleton> declaredConstructor = Singleton.class.getDeclaredConstructor(); declaredConstructor.setAccessible(true); Singleton singleton2 = declaredConstructor.newInstance(); Singleton singleton1 = Singleton.getInstance(); System.out.println(singleton1 == singleton2); } }
这段程序的运行结果是false,可见两个Singleton对象是不同的对象。
第二中方式:反序列化
通过反序列化的方式获取到的对象,依然是一个全新的单例对象
public class SingletonTest { public static void main(String[] args) throws Exception { ObjectOutputStream os = new ObjectOutputStream(new FileOutputStream("Singleton")); os.writeObject(Singleton.getInstance()); File file = new File("Singleton"); ObjectInputStream in = new ObjectInputStream(new FileInputStream(file)); Singleton singleton2 = (Singleton)in.readObject(); Singleton singleton1 = Singleton.getInstance(); System.out.println(singleton1 == singleton2); } }
这里利用序列化反序列化的方式在内存中创建的另外一个单例对象
所以通过这两个例子我们可以知道,单例模式只能保证通过单粒对象的获取方式,获取到的单例对象是唯一的,但是在内存中是无法保证这个对象一定是唯一的。
三、懒汉模式中的知识点
1. 构造方法私有化,就是通过new的方式无法创建,只能通过getInstance方法来获取对象。
2.私有静态成员变量要用volatile修饰:
volatile在这里其实是有两个作用:禁止指令重排序 和 保证数据可见性
禁止指令重排序:
指令是指示计算执行某种操作的命令,而我们平时写的代码实际在运行中是会被翻译成计算机能够识别的指令。一行代码可能会被翻译成多条指令。然后在Java语言规范中要求Jvm保证一种类似串行的语义,主要程序最终的运行结果与严格按照串行执行的结果相同,那么指令执行的顺序就可以与原逻辑不同,这个过程就叫做指令重排序。
那么计算机为什么要重排序指令呢?按照原来的方式运行有什么问题吗?其实一切都是为了效率,如果相关指令可以重排序,那么就可以充分利用CPU的执行特点,可以并行的运行,提高效率。
知道了指令重排序的含义,我们该回过头来看看懒汉模式中哪里用到了指令重排序
singleton = new Singleton();
在懒汉模式里面是通过这种方式创建的单例对象,但是其实这个过程并不是一个原子操作,实际会分成以下三个步骤:
- 在堆中分配一个内存空间
- 初始化Singleton对象
- 在栈内存里面开辟了空间给引用变量singleton,将堆中的内存地址给引用变量singleton
这是粗略的拆分,具体的过程在饿汉模式中还会聊到,这里可以看到在完成上面三个步骤之后,singleton 这个单例对象才算是创建成功,可以返回被其他方法使用。
那么这里可以将上面三个步骤理解为三个指令,如果我们允许进行指令重排序,那么第二步和第三步就有可能会互换顺序,就是在Singleton对象还没有完成初始化,就已经将内存地址给了引用对象了。那么这时如果有一个线程调用了getInstance方法获取,此时singleton对象就已经不是null了,按照方法的逻辑,就会将这个没有完成初始化的对象返回,那么在使用的过程中就会出现NPE等意外情况。所以这里我们通过volatile关键字,告诉Jvm我的这个代码不要进行指令重排序,要严格按照串行的语义去执行,这样就保证在对象初始化完成之后,才会将单例对象暴露出去,是一种安全的方式。
那么Jvm是怎么判断哪些指令可以进行指令重排序,哪些不可以呢?其实在Java内存模型中定义了一种"偏序关系",称之为Happens-Before。那么如果两个操作,不管是不是在同一个线程中,如果之间没有偏序关系,那么Jvm就可以对其进行指令重排序,有偏序关系的就不能进行指令重排序,上面我们说的volatile就是Happens-Before中的一种,下面一起看看还有哪些吧:
- 程序顺序规则:如果程序中操作A在操作B之前,那么在线程中A操作将会在B操作之前执行
- 监视器锁规则:在监视器锁上的解锁操作必须在同一个监视器锁上的枷锁操作之前执行。
- volatile变量规则:对volatile变量的写入操作必须在对该变量的读操作之前执行。
- 线程启动规则:在线程上对start()方法的调用必须在该线程中执行任何操作之前执行。
- 线程结束规则:线程中任何操作都必须在其他线程检测到该线程已经结束之前执行,或者从Thread.join()中成功返回,或者在调用Thread.isAlive时返回false。
- 中断规则:当一个线程在另一个线程上调用interrrupt时,必须在被中断线程检测到interrupt调用之前执行。
- 终结器规则:对象的构造函数必须在启动该对象终结器之前完成。
- 传递性:如果操作A在操作B之前执行,并且操作B在操作C之前执行,那么操作A就一定在操作C之前执行。
所以在懒汉的单例模式中一定要将私有单例对象通过volatile进行声明,否则会出现返回一个未完成初始化的对象,导致程序中出现异常问题。
保证数据可见性:
volatile除了能够保证指令不被重新排序之外,还有一个作用,就是保证数据的可见行。现在我们的服务器都是多核的,共享同一内存,但是在每个核内都有自己的缓存,定期与主内存进行同步协调。
但是由于不同处理器的架构不同,能够提供的缓存一致性也就不同,所以我们就无法保证在同一时刻每个核心中线程执行时所看到的数据是相同的,所以我们需要在操作系统和编译器中做相应的操作来弥补这种不同处理器上面的差异。
那么对于单例模式中,如果一个线程已经把单例对象成功初始化了,但是因为没有及时的与主内存同步数据,就会导致运行在其他核心上的线程无法看到单例对象已经被成功初始化了,所以可能就会再执行一次初始化的过程,从而破坏了单例模式,所以在单例模式中保证单例对象的数据可见性也是十分的重要。
在Jvm中定义了一个特殊指令,叫做内存栅栏。用于处理共享数据在不同核心看到数据不一致的情况。通过这个指令帮助上层应用不需要关注在不同架构之间的差异。
3.Double Check:
在单例对象没有被初始化的时候,如果多个线程同时执行到第一个判断中,此时单例对象是null的,所有线程的前置条件都通过了,然后进行synchronized 锁的竞争,最终只能存在一个线程获得到锁,然后完成单例对象的初始化工作,此时其他通过了第一个前置条件的线程都被阻塞。
在第一个获取锁的线程完成单例对象的初始化之后,锁被释放,在锁队列中的其他线程继续去竞争获取锁。但是因为这个时刻,单例对象已经不是null了,所以当线程获取了锁之后再次判断单例对象是否为null的时候,就不会满足条件,所以就直接放回之前已经创建好的单例对象,从而保证程序中单例对象只会被创建一次,多次使用。
四、饿汉模式中的知识点
饿汉模式比较简单,都知道静态属性会在类被加载的时候初始化,所以当我们通过方法来获取单例对象的时候,返回的一定是同一个对象,而且这个对象也只会被创建一次。但是这只会是一个引子,通过这个面试官可以考察你对Java内存模型,以及Java类加载机制的了解程度。
1.静态的私有变量是在什么时候被创建的?
2.初始化的对象在内存中放在什么位置?

这是Java8之前的Java虚拟机运行时区域的图例,从图中可以看到分为五个区域,每个区域我们简单的回顾一下:
- 程序计数器:Java代码在Jvm中被执行之前,会被先转换为一条条的指令,程序计数器就是记录当前被执行的指令的行数,现在的Java多线程其实被CPU轮流进行处理,表面上给我们感觉是一起并行的,那么一个线程从被执行,到暂停,再到被切换到执行状态,要保证是从上次停止的位置继续执行,否则会出现问题的。这个保证就是通过程序计数器来完成的,所以每个线程都会存在一个程序计数器,它的声明周期是随着线程而变化的。这块区域是在Jvm中唯一一块没有OOM的区域。
- Java虚拟机栈:与程序计数器一样,虚拟机栈也是线程私有的。虚拟机栈描述的是Java方法执行的内存模型,每个方法在执行的同时都会创建一个栈帧,用于存储局部变量,操作栈数等信息,每个方法的执行是一个入栈的操作,执行完成是一个出栈的操作。在这个区域中会出现两种异常情况:首先是StackOverflowError异常,这个是如果线程请求的栈深度大于虚拟机允许的最大深度时就会出现,比如我们进行不断的递归调用出现了死循环等等。还有一种异常就是OutOfMemoryError,现在的虚拟机栈一般都是可以动态扩展的,但是如果在扩展过程中依然无法申请到内存了,那么就会出现此异常。
- 本地方法栈:与Java虚拟机栈功能相同,只是在这里执行的是Native方法。
- Java堆:这是Jvm内存中区域最大的一块区域了,这里是所有线程共享的一块区域,这里唯一的目的是存放实例对象,几乎所有对象的实例都是在这里分配的。在这里会进行垃圾回收。
- 方法区:与堆一样,这里也是所有线程共享的一块区域,在这里存储了已经被虚拟机加载的类信息,常量,静态变量,即时编译器编译后的代码等数据。方法区有些人也称为永久代,但是其实它与堆中的永久代并不是一个概念,只是在Jvm的垃圾回收时采用永久代的回收机制来对待方法区而已,不过也可以选择不进行垃圾回收。
回顾完Jvm内存区域之后我们来解决上面提到的两个问题:
Java的源文件从磁盘上被加载到Jvm中被我们使用是一个动态的过程,可以在程序运行过程中根据需要去加载。那么一个类从被加载到虚拟机内存,到被卸载出内存会经历如下7个阶段,而我们要的答案就在这里面。

图中加载,验证,准备,初始化和卸载这5个步骤的相对顺序是确定的,而解析则不一定是按照上面的顺序进行的,可能是在初始化之后进行,这是为了支持Java语言的动态绑定。
下面简单的解释一下每一个过程都是做了什么:
- 加载:类加载器通过类的全限定名将一个类的二进制流读取到Jvm中,并存储在方法区中,然后将其转换为一个与目标类型对应的Class对象
- 验证:验证Class类是否符合Java的语言规范,方法的重写和继承是否符合要求
- 准备:为类中所有的静态变量分配内存空间,并设置初始值,被final修饰的变量会被直接赋值,是一个变量类型的初始值而非类中的初始值
- 解析:将常量池中的符号引用改为直接引用
- 初始化:首先为静态变量赋值,然后执行static静态代码块
上面的流程是针对一个类的初始化,但是在一个全局的角度来看,类之间存在父子关系,所以类加载的顺序也是按照先加载父类后加载子类,初始化的过程也是一样的。
在Java编译阶段,如果final的静态变量类型是基本类型或者是string类型的话,会为其生成ConstantValue属性,然后会为其初始化实际的值,因为基本类型和string对象是直接在方法区的常量池里面初始化对象。但是在懒汉模式中,定义了static final的单例对象不是基本类型或string类型,所以无法在准备阶段通过ConstantValue为其赋值,所以真正的赋值过程是在初始化阶段。
在初始化阶段会执行一个叫做<clinit>()的方法,这个方式是编译器自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并产生,比那一起收集的顺序由语句在源文件中出现的顺序决定,静态代码块只能访问到定义在其之前的变量,定义在它之后的变量不能访问,但是可以为其赋值。
所以通过这些了解我们就知道了,懒汉模式中的单例对象是在准备阶段被创建,在初始化阶段被初始化,然后对象是存放在堆中的。
五、让你与众不同的单例
通过上面的了解,一般的面试就已经没问题了,但是这并不能让我们在面试中脱颖而出,那怎么才能与众不同呢?就是本小节要聊的内容。
前面我们聊了两种常见的单例模式的写法以及相关的知识点,但是我们知道上面的例子中单例模式其实是可以被破坏的,也就是通过反射和反序列化其实在Jvm中是可以创建多个单例对象的,这样的话看起来就不是那么的完美了。
下面我们来看另外一种单例模式:
public enum Singleton { INSTANCE; }
这就是通过枚举创建的单例模式,我们在使用的时候可以直接通过Singleton.INSTANCE来获取单例对象,相比于上面两种方式,简单了非常多。然后枚举的创建在是在JDK的底层控制的,是线程安全的,所以我们不需要通过DCL来控制其对象的创建过程。
接下来我们看看通过反射的方式能不能破坏枚举单例:
public class ETest {
public static void main(String[] args) throws Exception{
Constructor<SingletonE> declaredConstructor = SingletonE.class.getDeclaredConstructor();
declaredConstructor.setAccessible(true);
SingletonE singletonE = declaredConstructor.newInstance();
}
}
运行的结果就是:
Exception in thread "main" java.lang.NoSuchMethodException: com.example.CleanCode.Stream.SingletonE.<init>() at java.lang.Class.getConstructor0(Class.java:3082) at java.lang.Class.getDeclaredConstructor(Class.java:2178) at com.example.CleanCode.Stream.ETest.main(ETest.java:8) Process finished with exit code 1
这是因为所有的枚举类都是隐式的继承了Enum抽象类,而Enum抽象类根本没有无参的构造方法,只有一个带参数的构造方法:
protected Enum(String name, int ordinal) { this.name = name; this.ordinal = ordinal; }
那我们调整一下,看看通过指定这个有参的构造方法行不行:
public class ETest { public static void main(String[] args) throws Exception{ Constructor<SingletonE> declaredConstructor = SingletonE.class.getDeclaredConstructor(String.class,int.class); declaredConstructor.setAccessible(true); SingletonE singletonE = declaredConstructor.newInstance(); } }
运行之后还是报错:
Exception in thread "main" java.lang.IllegalArgumentException: Cannot reflectively create enum objects at java.lang.reflect.Constructor.newInstance(Constructor.java:417) at com.example.CleanCode.Stream.ETest.main(ETest.java:10)
我们看看Constructor.java:417里面是什么逻辑:
if ((clazz.getModifiers() & Modifier.ENUM) != 0) throw new IllegalArgumentException("Cannot reflectively create enum objects");
可见,JDK反射机制内部完全禁止了用反射创建枚举实例的可能性,所以如果是枚举的单例是可以防止反射的破坏的。
那么接下来我们来看看反序列化的攻击会是什么样的呢?
public class ETest { public static void main(String[] args) throws Exception{ ObjectOutputStream os = new ObjectOutputStream(new FileOutputStream("Singleton")); os.writeObject(SingletonE.INSTANCE); File file = new File("Singleton"); ObjectInputStream in = new ObjectInputStream(new FileInputStream(file)); SingletonE singleton2 = (SingletonE)in.readObject(); SingletonE singleton1 = SingletonE.INSTANCE; System.out.println(singleton1 == singleton2); } }
通过这段代码的测试可以看到运行结果是:true,也就是说通过反序列化返回的也之前创建的对象,没有再创建新的。
通过追寻源码发现在readObject()犯法里面,有针对枚举的特殊处理:
case TC_ENUM: return checkResolve(readEnum(unshared));
继续看一下readEnum方法:
private Enum<?> readEnum(boolean unshared) throws IOException { if (bin.readByte() != TC_ENUM) { throw new InternalError(); } ObjectStreamClass desc = readClassDesc(false); if (!desc.isEnum()) { throw new InvalidClassException("non-enum class: " + desc); } int enumHandle = handles.assign(unshared ? unsharedMarker : null); ClassNotFoundException resolveEx = desc.getResolveException(); if (resolveEx != null) { handles.markException(enumHandle, resolveEx); } String name = readString(false); Enum<?> result = null; Class<?> cl = desc.forClass(); if (cl != null) { try { @SuppressWarnings("unchecked") Enum<?> en = Enum.valueOf((Class)cl, name); result = en; } catch (IllegalArgumentException ex) { throw (IOException) new InvalidObjectException( "enum constant " + name + " does not exist in " + cl).initCause(ex); } if (!unshared) { handles.setObject(enumHandle, result); } } handles.finish(enumHandle); passHandle = enumHandle; return result; }
通过源码可以看到首先通过类限定名的方式获取了枚举对象,然后调用其valueOf方法,直接返回了枚举对象。
所以说也是在JDK底层对反序列化进行了控制。了解了这两种方式的原理之后,其实通过枚举的方式创建单例对象是更加简单更加安全的方式。
如果在面试中能够说出枚举的单例模式,并且能够说明白其中的原理,相信能够给你的面试加分不少。

浙公网安备 33010602011771号