一单例模式
目录结构
前言
接下来的系列文章我们会谈设计模式,设计模式不仅仅存在Java开发语言中,而是遍及软件领域且至关重要,是前辈开发总结的经验,一种设计思想,一种架构;在软件开发中,唯一不变的就是需求的变化,开发人员不仅要满足当下的功能需求,还要考虑对后续可能的变化,设计的系统就应有良好的拓展性。在公司接手上一任的代码,继续开发新功能,如果设计的拓展性不好的话,后期开发会很困难,费时费力,还可能对之前的功能有影响,心里也是忐忑不安,同时也给测试人员添加负担,改动点增多,测试范围增大等等,可见设计模式的重要性。
本文讲述较为简单的单例模式,单例模式要保证系统中对象唯一,这不是获取对象方的责任,是对象提供方保证这个对象在系统中就只能存在一个。如何保证对象的唯一性,就要从创建对象的角度,创建对象可以通过构造方法,Clone对象,反序列化时创建对象,反射四种方式,那么就需要让类内部创建唯一对象,不让外部直接创建,只提供一个方法供外部获取对象。所以单例模式中第一步构造方法私有,不让外部new 对象,其次实现单例模式的类不会实现Cloneable接口,则不支持Clone对象;前2种方式都能避免,主要是反序列化和反射机制容易破坏单例。以下我们来分别讨论单例模式的几种方式和其存在的问题,以及反序列化和反射如何破坏单例,怎样去避免,如何合理设计单例模式?
创建对象四种方式:
- 1、构造方法
- 2、Clone对象
- 3、反序列化时创建对象
- 4、反射
创建单例的常见几种方式:
- 1、懒汉式
- 2、饿汉式
- 3、双检锁
- 4、静态内部类方式
- 5、双检锁变式 - CAS自旋锁
- 6、枚举
一、懒汉式
在需要使用的时候,才创建对象(延迟实例化),存在多线程安全问题。
package designpattern.singleton;
/**
* @author zdd
* 2020/1/10 5:15 下午
* Description: 懒汉式创建单例
*/
public class LazyInstantiateTest {
private static LazyInstantiateTest INSTANCE;
//1、私有构造方法,防止被其他类创建对象
private LazyInstantiateTest(){};
//2、对外提供静态公共方法获取单例对象
public static LazyInstantiateTest getInstance() {
if(INSTANCE == null) {
INSTANCE = new LazyInstantiateTest();
}
return INSTANCE;
}
}
二、饿汉式
也称预加载方式,类在加载初始化时就创建单例对象,饿汉抢食般地创建对象,因此以“饿汉”形容,不存在线程安全问题,但是会占用内存,类一被加载进来就实例化对象到堆中,可能很长时间才被使用或者未被使用,如此造成资源浪费。
package designpattern.singleton;
import java.io.Serializable;
/**
* @author zdd
* 2020/1/10 5:31 下午
* Description: 饿汉式实现单例
*/
public class HungryTest implements Serializable {
private static HungryTest INSTANCE = new HungryTest();
private HungryTest() {};
public static HungryTest getInstance() {
return INSTANCE;
}
}
三、双检锁
package designpattern.singleton;
/**
* @author zdd
* 2020/1/10 5:42 下午
* Description: 双检锁单例
*/
public class DoubleCheckTest {
private static DoubleCheckTest INSTANCE;
private DoubleCheckTest() {}
public static DoubleCheckTest getInstance() {
//1,第一次判空为了提高程序效率
if(INSTANCE ==null) {
//加锁,这里使用的监视器对象是该类的字节码对象
synchronized (DoubleCheckTest.class){
//2、第二次判空是为了解决多线程安全问题
if (INSTANCE == null) {
INSTANCE = new DoubleCheckTest();
}
}
}
return INSTANCE;
}
}
四、静态内部类
静态内部类借助的是类加载机制,内部类只有在被调用的时候才加载进来,实现延迟创建对象,是饿汉式的改进,既避免了初始化就创建对象占用内存,又能避免懒汉式的线程安全问题。
package designpattern.singleton;
import java.io.Serializable;
/**
* @author zdd
* 2020/1/10 5:55 下午
* Description: 静态内部类单例
*/
public class StaticInnerClassTest {
//内部类
private static class InstanceInnerClass {
private final static StaticInnerClassTest
INSTANCE = new StaticInnerClassTest();
}
private StaticInnerClassTest(){}
public static StaticInnerClassTest getInstance() {
return InstanceInnerClass.INSTANCE;
}
}
五、双检锁变式 - CAS自旋锁
网上有个面试题
面试官问:如何在不使用关键字synchronized、Lock锁的情况下,保证线程安全地实现单例模式?
能够线程安全创建单例,除了枚举外,有静态内部类和双检锁方式,双检锁用了关键字synchronized,静态内部类利用的类加载的机制,底层也是含有加锁操作的。要想实现不用锁,可以参考循环CAS,无阻塞轮询,利用cas自旋锁原理。
首先写一个自旋锁类
package designpattern.singleton;
import cas.SpinLockTest;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
/**
* @author zdd
* 2020/1/10 6:59
* Description: CAS无阻塞自旋锁
*/
public class CasLock {
static AtomicReference<Thread> atomicReference = new AtomicReference<>();
public static void lock() {
Thread currentThread = Thread.currentThread();
for (;;) {
boolean flag =atomicReference.compareAndSet(null,currentThread);
if(flag) {
break;
}
}
}
public static void unLock() {
Thread currentThread = Thread.currentThread();
Thread momeryThread = atomicReference.get();
//比较内存中线程对象与当前对象,不相等就抛出异常,防止未获取到锁的线程调用 unlock
if(currentThread != momeryThread) {
throw new IllegalMonitorStateException();
}
//释放锁
atomicReference.compareAndSet(currentThread,null);
}
}
实现双检锁变式单例模式
package designpattern.singleton;
import cas.SpinLockTest;
/**
* @author zdd
* 2020/1/10 6:46
* Description: cas实现单例,实际是cas自旋锁,在synchronized阻塞式加锁的改进,无阻塞式加锁
*/
public class SingletonCasTest {
private static SingletonCasTest INSTANCE;
private static CasLock spinLock = new CasLock();
private SingletonCasTest() {};
public static SingletonCasTest getInstance() {
if(INSTANCE == null) {
spinLock.lock();
if (INSTANCE == null) {
INSTANCE = new SingletonCasTest();
}
spinLock.unLock();
}
return new SingletonCasTest();
}
}
六、枚举
枚举类是《Effective Java》书中推荐的实现单例方式,因为其天然的可防止反序列化和反射破解单例的唯一性,保证有且仅有一个对象,
因太简洁,可读性不强。
package designpattern.singleton;
/**
* @author zdd
* 2020/1/10 6:43 下午
* Description:
*/
public enum SingletonEnum{
INSTANCE;
}
七、存在的问题
7.1 线程安全
一是需要考虑线程安全问题,这是懒汉式存在的问题,为了解决该问题,可以将getInstance() 方法加上synchronized关键字或者在方法内部加同步代码块,或者用Lock锁机制,这样会导致多线程在获取单例对象时线程安全了,但是效率会降低,同步代码块会比同步方法效率更高一些,主要是同步代码块应该尽可能的缩小代码块的包含范围(标准是恰好包括临界区部分),粒度越小,并发度才更高。
7.2 反序列问题
二是反序列化问题,在需要将对象序列化与反序列化时,首先让该单例类实现Serializable接口(标志接口,无内容,实现类可序列化),然而存在的问题就是在反序列化时会新创建一个对象,这样就违背了单例模式的对象唯一性。
将对象先转为字节写入到输入流中(序列化过程),再从输出流中读取字节,再转换为对象 (反序列化)
代码示例如下:
package designpattern.singleton;
import java.io.*;
/**
* @author zdd
* 2020/1/10 7:23 下午
* Description: 反序列化破坏单例对象唯一性
*/
public class DeserializableProblemTest {
public static void main(String[] args) throws IOException, ClassNotFoundException {
//先将对象加载到输入流中,在到输出流获取对象,以饿汉式单例为例
HungryTest hungry1 = HungryTest.getInstance();;
HungryTest hungry2 = null;
//1,将单例对象写入流中
ByteArrayOutputStream ops = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(ops);
oos.writeObject(hungry1);
//2,再从流中读出,转换为对象
ByteArrayInputStream ips= new ByteArrayInputStream(ops.toByteArray());
ObjectInputStream ois = new ObjectInputStream(ips);
hungry2 =(HungryTest) ois.readObject();
//3、判断是否为同一个对象
System.out.println(hungry1 ==hungry2);
}
}
运行结果: 证明反序列化后又新创建了对象
false
解决反序列化问题:在HungryTest类中添加如下方法
//防止反序列化破坏单例
private Object readResolve() {
return INSTANCE;
}
再执行运行结果为 true ,证明是同一个对象,未创建新对象。
为什么添加一个readResolve 方法就可以防止反序列化创建新的对象呢?
进入ObjectInputStream的 readObject() 可见,下面只列出关键代码位置,详细可自己查看源码
首先类要支持序列化,通过反射创建新对象赋值给obj
继续往下看,这里有if判断,满足3个条件,其中hasReadResolveMethod判断是否有readResolve方法,有则调用该方法,最后obj被readResolve返回对象覆盖。
那么readResolveMethod需要满足什么要求? 满足以下3个条件即可
参考博客:单例模式的攻击之序列化与反序列化
7.3 反射
三是反射,我们知道Java中反射几乎是无所不能,你不让我创建对象,那就暴力反射创建,我们如何防止反射破解单例?
暴力反射破坏单例示例:
package designpattern.singleton;
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;
/**
* @author zdd
* 2020/1/13 2:49 下午
* Description: 暴力反射破解单例
*/
public class ReflectBreakSingletonTest {
public static void main(String[] args) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {
//1,获取单例对象
HungryTest hungry1 = HungryTest.getInstance();
//2, 获取HungryTest类字节码对象
Class<HungryTest> hungryClass= HungryTest.class;
//3,获取构造器对象
Constructor<HungryTest> hungryConstructor = hungryClass.getDeclaredConstructor();
//4,设置暴力反射为true
hungryConstructor.setAccessible(true);
//5,通过构造器对象调用默认构造器创建对象 --> 反射
HungryTest hungry2= hungryConstructor.newInstance();
//6, 判断两个对象是否相同
System.out.println(hungry1 == hungry2);
}
}
运行结果: false
证明反射可以破坏单例对象唯一,新创建对象。
如何防止反射对单例的攻击?
既然反射攻击是调用默认构造器,那么反射在调用构造器时就抛出异常不让其创建对象。依然以饿汉式为例,修改默认构造方法,如果反射调用就抛出异常!
private HungryTest() {
if(null !=INSTANCE) {
throw new RuntimeException("不支持反射调用默认构造器!");
}
};
问:以上6种单例模式都可以通过在默认构造方法中抛异常防止暴力反射吗?
答:除去枚举(其天然防止反射),其他5种分为2类,类初始化就创建对象为预加载方式,另一类为延迟加载方式;饿汉式、静态内部类为预加载方式 ,懒汉式、双检锁、双检锁变式为延迟加载方式。这里预加载可以用以上方法防止暴力反射,延迟加载不行,因为在默认构造方法中首先会对单例对象判空,延迟加载在获取单例时是没有创建对象的,这时可以通过反射创建对象,因此无法防止反射攻击,因此推荐的是枚举方式实现单例,省心省力。
参考博客:单例模式的攻击之反射攻击
总结
本文从单例模式的几种方式入手,分析每个的特点及问题,其中它们公共的特点是私有构造方法,再提供一个公开静态的方法供外部获取对象;我们在理解这几种方式原理后,能够很容易写出这些单例,分析每种方式存在的问题,以及改进的方式,其中线程安全问题,反序列化问题,反射问题应着重注意,如此我们也能较为全面了解单例模式。