设计模式之一单例模式

目录结构

前言

接下来的系列文章我们会谈设计模式,设计模式不仅仅存在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 {
    //注:双检锁实例对象  volatile关键字修饰很重要,保证可见性,以及防止指令重排序
    private static volatile 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 volatile 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类,类初始化就创建对象为预加载方式,另一类为延迟加载方式;饿汉式、静态内部类为预加载方式 ,懒汉式、双检锁、双检锁变式为延迟加载方式。这里预加载可以用以上方法防止暴力反射,延迟加载不行,因为在默认构造方法中首先会对单例对象判空,延迟加载在获取单例时是没有创建对象的,这时可以通过反射创建对象,因此无法防止反射攻击,因此推荐的是枚举方式实现单例,省心省力。

参考博客单例模式的攻击之反射攻击

总结

本文从单例模式的几种方式入手,分析每个的特点及问题,其中它们公共的特点是私有构造方法,再提供一个公开静态的方法供外部获取对象;我们在理解这几种方式原理后,能够很容易写出这些单例,分析每种方式存在的问题,以及改进的方式,其中线程安全问题,反序列化问题,反射问题应着重注意,如此我们也能较为全面了解单例模式。


posted @ 2020-01-13 22:13  夕阳下飞奔的猪  阅读(357)  评论(0编辑  收藏  举报