zoukankan      html  css  js  c++  java
  • 设计模式之一单例模式

    目录结构

    前言

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

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

    总结

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


  • 相关阅读:
    nginx 服务器重启命令,关闭
    eclipse实现热部署和热启动
    Intellij IDEA 文件修改提示星号
    IntelliJ IDEA 自动编译功能无法使用,On 'update' action:选项里面没有update classes and resources这项
    idea最常使用的快捷键
    centos 切换用户显示bash-4.2$,不显示用户名路径的问题
    汉诺塔
    C语言笔记
    @org.springframework.beans.factory.annotation.Autowired(required=true)
    Error creating bean with name 'xxxx' defined in URL
  • 原文地址:https://www.cnblogs.com/flydashpig/p/12189431.html
Copyright © 2011-2022 走看看