本文章采用循序渐进的方式对单例模式进行演化实现。
什么是单例模式,为什么要使用单例模式?
单例模式就是只允许创建一个实例的对象,这有什么实际价值?
实际开发中有些对象我们只需要一个,比方说:线程池、缓存、对话框等等,这类对象只能有一个实例,如果执照出多个实例,就会导致很多问题的产生,比如:程序异常、系统资源使用过量或者运行不一致的结果。
实现单例有两种常见的方法,这两种方法的核心就是要保持构造器为私有的,并导出公有的静态成员。
两种经典的单例实现之一:饿汉式
/**
* @Description: 单例模式:饿汉式
* @Author along
* @Date 2020/3/8 17:20
*/
public class Hungry {
private static final Hungry INSTANCE = new Hungry();
private Hungry(){}
public static Hungry getInstance() {
return INSTANCE;
}
}
恶汉式在代码初始化时就创建一个实例,实现了每次调用getInstance()
得到的对象都是已经存在的唯一的实例,但是也产生了问题,万一这个对象非常耗资源,二程序在这次的执行过程中又一直没有用到它,这样就会白白浪费了资源。出于饿汉式的这个弊端,我们又有了懒汉式的单例实现
两种经典的单例实现之二:懒汉式
/**
* @Description: 单例模式:懒汉式
* @Author along
* @Date 2020/3/8 17:38
*/
public class Lazy {
private static Lazy INSTANCE;
private Lazy() {}
public static Lazy getInstance() {
if (INSTANCE == null) {
INSTANCE = new Lazy();
}
return INSTANCE;
}
}
需要的时候先判断该对象是否存在,不存在再创建,在单线程模式下可以保证拿到的对象一定是唯一的,但是在多线程环境下呢,下面代码对其进行测试
public class Lazy {
private static Lazy INSTANCE;
private Lazy() {}
public static Lazy getInstance() {
if (INSTANCE == null) {
INSTANCE = new Lazy();
}
return INSTANCE;
}
/**
* 多线程环境测试
* @param args
*/
public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
new Thread(() -> {
Lazy instance = Lazy.getInstance();
// 输出每个线程得到的对象的hashCode
System.out.println(instance.hashCode());
}).start();
}
}
}
运行结果如下
多线程环境下出现了不一致的hashCode,证明得到的对象并不是唯一的,单例被破坏。
如何解决多线程下单例被破坏呢,能直接想到的就是加锁,保证不会有两个线程同时进入getInstance()方法即可。如下
public class Lazy {
private static Lazy INSTANCE;
private Lazy() {}
public static synchronized Lazy getInstance() {
if (INSTANCE == null) {
INSTANCE = new Lazy();
}
return INSTANCE;
}
}
使用synchronized
关键字给getInstance()方法加锁,确实解决了多线程下单例被破坏的情况,可是又产生了性能问题,现实情况是,只有在第一次执行getInstance()方法时,才真正需要同步,一旦执行过getInstance()设置好INSTANCE
变量后,就不需要同步这个方法了,要不然之后每次调用这个方法,同步都是一种累赘。
所以为了减少同步带来的额外性能损失,我们需要减小synchronized
的作用范围,并采用双重检测加锁(即DCL),在getInstance()中减少使用同步,代码如下
public class Lazy {
private static Lazy INSTANCE;
private Lazy() {}
public static Lazy getInstance() {
if (INSTANCE == null) { // 第一重判断
synchronized (Lazy.class) {
if (INSTANCE == null) { // 第二层判断
INSTANCE = new Lazy();
}
}
}
return INSTANCE;
}
}
这样是不是就万事大吉了?虽然你在多线程测试中多次测试都得出了正确的结果,但是并不能表明上面的代码在多线程下就是没有问题的。
问题就出在代码INSTANCE = new Lazy();
上,这个创建实例的动作并不是原子性的操作。
java虚拟机创建一个对象并不是一步完成的,而是可以分成以下三个步骤:
1:为对象分配内存空间
2:初始化对象
3:将对象指向分配的内存空间
由于CPU在执行指令的时候存在重排序,上面步骤2和步骤3执行顺序可能调换,变成1=>3=>2。
我们假设有有两个线程A和B,线程A先执行getInstance(),判断INSTANCE为null,于是得到锁,并执行INSTANCE = new Lazy()
,在执行过程中原本的1=>2=>3步骤由于指令重排变成了1=>3=>2,在执行完步骤3时,INSTANCE已经不为null了,这时候线程B进入getInstance(),判断INSTANCE不为null,于是直接返回实例,但是这个实例由于还没有执行步骤2初始化,所以线程B返回的对象是不完整的,单例也就被破坏了。
基于上面的分析,我们有两种方案来解决上面的问题
- 方案1:禁止2和3指令重排
- 方案2:允许2和3指令重排,但是不允许其它线程看到这个重排序
方案1:使用volatile关键字
votatile 可以通过内存屏障的方式禁止指令重排,代码如下
public class Lazy {
// 使用volatile关键字禁止指令重排
private volatile static Lazy INSTANCE;
private Lazy() {}
public static Lazy getInstance() {
if (INSTANCE == null) { // 第一重判断
synchronized (Lazy.class) {
if (INSTANCE == null) { // 第二层判断
INSTANCE = new Lazy();
}
}
}
return INSTANCE;
}
}
上面便是DCL懒汉式单例的完整实现
方案2:内部类实现
public class InnerLazy {
private InnerLazy() {}
private static class SingleHolder {
private static final InnerLazy ins = new InnerLazy();
}
/**
* 内部类方式获取单例
*
* @return
*/
public static InnerLazy getInstance() {
return SingleHolder.ins;
}
}
以上就是对单例模式的完整实现,到这里就完了吗,学无止境,下面对DCL懒汉式的安全性做进一步的探究。
构造器私有就能保证安全吗?或者说构造器私有了就能保证只能通过getInstance()来得到实例吗?这时候反射举起了小手。
反射破坏单例
用下面的代码来测试反射能否破坏单例
public class DCLLazy {
private static DCLLazy INSTANCE;
private DCLLazy() {}
public static DCLLazy getInstance() {
if (INSTANCE == null) { // 第一重判断
synchronized (Lazy.class) {
if (INSTANCE == null) { // 第二层判断
INSTANCE = new DCLLazy();
}
}
}
return INSTANCE;
}
/**
* 反射破坏单例测试
* @param args
*/
public static void main(String[] args) throws NoSuchMethodException, IllegalAccessException,
InvocationTargetException, InstantiationException {
Constructor<DCLLazy> declaredConstructor = DCLLazy.class.getDeclaredConstructor();
DCLLazy instance1 = declaredConstructor.newInstance();
DCLLazy instance2 = declaredConstructor.newInstance();
System.out.println(instance1.hashCode());
System.out.println(instance2.hashCode());
}
}
执行结果如下
结果不同,得到了两个对象,反射被破坏了!
可惜的是目前还没有有效的方式来阻止反射攻击,下面只提供一个解决方案。
我们可以在方法中定义一个变量,在构造方法被调用时检查这个变量,来阻止反射攻击,代码如下
public class DCLLazy {
private static boolean BBB = false;
private static DCLLazy INSTANCE;
private DCLLazy() {
if (!BBB) {
BBB = true;
} else {
// 这一段里面除了加入异常警告,还可以加入病毒之类的代码
throw new RuntimeException("非法操作");
}
}
public static DCLLazy getInstance() {
if (INSTANCE == null) { // 第一重判断
synchronized (Lazy.class) {
if (INSTANCE == null) { // 第二层判断
INSTANCE = new DCLLazy();
}
}
}
return INSTANCE;
}
/**
* 反射破坏单例测试
* @param args
*/
public static void main(String[] args) throws NoSuchMethodException, IllegalAccessException,
InvocationTargetException, InstantiationException {
Constructor<DCLLazy> declaredConstructor = DCLLazy.class.getDeclaredConstructor();
DCLLazy instance1 = declaredConstructor.newInstance();
DCLLazy instance2 = declaredConstructor.newInstance();
System.out.println(instance1.hashCode());
System.out.println(instance2.hashCode());
}
}
执行结果如下
这样就安全了吗?当然没那么简单,这个变量是在源代码里的,只要反编译源代码,就能知道有BBB变量,然后就能用反射拿到并操作这个变量,如下,这里只给出main方法的反射代码
/**
* 反射破坏单例测试
* @param args
*/
public static void main(String[] args) throws NoSuchMethodException, IllegalAccessException,
InvocationTargetException, InstantiationException, NoSuchFieldException {
Constructor<DCLLazy> declaredConstructor = DCLLazy.class.getDeclaredConstructor();
DCLLazy instance1 = declaredConstructor.newInstance();
Field bbb = DCLLazy.class.getDeclaredField("BBB"); // 拿到私有的BBB字段
bbb.setAccessible(true);
bbb.set(instance1, false);
DCLLazy instance2 = declaredConstructor.newInstance();
System.out.println(instance1.hashCode());
System.out.println(instance2.hashCode());
}
运行结果如下
单例又被破坏了,不经得感叹,没有真正安全的代码。
既然DCL单例很难避免反射攻击,那有没有另一种实现可以避免反射攻击呢?当然是有的,枚举举起了小手。
枚举(enum)实现单例(推荐)
实现非常简单,就是声明一个包含单个元素的枚举类型,如下
/**
* @Description: 枚举实现单例
* @Author along
* @Date 2020/3/8 23:50
*/
public enum SingleEnum {
INSTANCE;
public SingleEnum getInstance() {
return INSTANCE;
}
}
这里要引用Effective Java中对用枚举实现单例的一段说明:
这种方法在功能上与公有域实现方法相似,但更加简洁,无偿地提供了序列化机制,绝对防止多次实例化,及时实在面对复杂的序列化或者反射攻击的时候。虽然这种方法还没有广泛采用,但是单元素的枚举类型经常成为实现Singleton的最佳方法。注意。Singleton必须扩展一个超类,而不是扩展Enum的时候,则不宜使用这个方法(虽然可以声明枚举去实现接口)。
为什么枚举实现的单例反射破坏不了呢?
下面是反射获取SingleEnum枚举类的实例的简单代码
SingleEnum.class.getDeclaredConstructor(String.class, int.class).newInstance();
我们看下newInstance()的源码,如下
看红框部分的代码,jdk直接禁止了反射对枚举类的实例获取,感觉好贴心有木有!
到这里你肯定感觉用枚举实现单例就万事大吉了,看起来是这样的,可是世上没有真正安全的代码,JDK既然在源码级别杜绝了反射破坏枚举单例的可能,那么,我能不能反编译jdk源码并修改它,去掉这个限制呢?
序列化破坏单例
再回到DCL单例实现上,有时候我们需要这个单例实现序列化,这时候仅仅在声明中加上implements Serializable
是不够的,还必须声明所有的实例域都是瞬时(transient)的,并提供一个readResolve方法。代码如下
public class DCLLazy implements Serializable {
private static final long serialVersionUID = -2603357495283280292L;
private transient static boolean BBB = false;
private static DCLLazy INSTANCE;
private DCLLazy() {
if (!BBB) {
BBB = true;
} else {
throw new RuntimeException("非法操作");
}
}
public static DCLLazy getInstance() {
if (INSTANCE == null) {
synchronized (Lazy.class) {
if (INSTANCE == null) {
INSTANCE = new DCLLazy();
}
}
}
return INSTANCE;
}
// 防止序列化破坏单例
private Object readResolve() {
return INSTANCE;
}
}
总结
本文详细介绍了单例的各种实现,重点探讨了DCL懒汉式实现的演进过程,以及反射和反序列化对单例的破坏及预防手段。并解释了为什么枚举是单例的最佳实现手段。
能力有限,如有错漏,欢迎交流探讨。