面试官问你单例设计模式,问问自己你真的了解单例吗?小单例,不简单!

哇塞

哇塞,被称为Java中最简单的设计模式——单例设计模式。这都可以有万字知识点总结!开始我也不敢相信,看到后我信了!

总结不易,希望大家三连伺候!在此感谢!
最近我也正在将我总结的比较好的文章以及科班学习体系文章一个个的完善在GitHub中,我希望大家能到我的GitHub给上一个Star !现在我的GitHub虽然还没有完善多少技术文章,但是在你几个月后的今天,我的GitHub也将成为一个完整的Java学习体系!

GitHub地址: https://github.com/Ziphtracks/JavaLearningmanual

大家记得给我一个Star!笔芯!

一、什么是单例设计模式?

单例模式(Singleton Pattern)是 Java 中最简单的设计模式之一。这种类型的设计模式属于创建型模式,它提供了一种创建对象的最佳方式。

这种模式涉及到一个单一的类,该类负责创建自己的对象,同时确保只有单个对象被创建。这个类提供了一种访问其唯一的对象的方式,可以直接访问,不需要实例化该类的对象。

注意:

  • 单例类只能有一个实例。
  • 单例类必须自己创建自己的唯一实例。
  • 单例类必须给所有其他对象提供这一实例。

二、单例设计模式的优缺点

优点:

  • 在内存中只有一个实例对象,减少内存开销。解决了频繁创建和销毁内存实例对象的问题。
  • 避免过多的资源占用。比如:写文件操作。

缺点:

  • 没有接口,不能继承,与单一职责原则冲突,一个类应该只关心内部逻辑,而不关心外面怎么样来实例化。

三、单例设计模式的使用

当想要控制实例数目,节省系统资源的时候。并且在一个全局内,解决内存中频繁创建和销毁实例对象问题。

四、单例设计模式分类

单例设计模式的原则是创建唯一实例,但是创建唯一实例的方法有很多,也由此生成了许多种类的单例设计模式。它们分别是:普通懒汉式单例模式、同步锁懒汉式单例模式、同步代码块懒汉式案例模式、饿汉式单例模式、双重校验锁(双检锁)单例模式、静态内部类(登记式)单例模式和枚举单例模式

五、单例模式思想的传递过程

问题: 如果我们要有写单例设计模式的思想,该如何实现单例设计模式呢?怎样才能实现全局内只创建一个实例化对象并使用呢?而且在使用过程中会不会出现其他问题呢?带着疑问先把最基础的单例设计模式写出来!

首先,先创建一个类,类名为Singleton。然后去创建一个对象如下:

class Singleton {
    Singleton instance = new Singleton();
}

看到这里,我们就发现这是一个普通的类,创建一个普通类的实例对象。那么我们如果实现单个实例对象的话,就不能让外界随便来访问创建该对象。所以我们就想到了构造方法,大家知道如果类中写任何构造方法的话,它会隐式的存在一个公共的无参构造。这时候聪明的小伙伴想到了私有该构造器,不让外界随便创建对象。代码如下:

class Singleton {
    //在类的内部创建一个类的实例对象
    private Singleton instance = new Singleton();
    //私有化构造器,使得在类的外部不能够调用此构造器,随意创建实例对象
    private Singleton() {}
}

那么下一步呢?我们如何为外界提供该类内部的实例对象呢?有的小伙伴在创建类的实例对象的同时使用了修饰符static。我真的说着很聪明。这样被static修饰了之后就可以通过类名来句点出来对象使用了?那么问题来了。如果出现以下状况怎么办呢?看以下操作!

class Singleton {
    //在类的内部创建一个类的实例对象,该静态修饰的对象随着类加载只创建一次实例
    private static Singleton instance = new Singleton();
    //私有化构造器,使得在类的外部不能够调用此构造器,随意创建实例对象
    private Singleton() {}
}

class Test {
    //分别创建了两个对象为s1和s2,并且两个对象是使用了同一个实例对象
    Singleton s1 = Singleton.instance;
    Singleton s2 = Singleton.instance;
    //不信的话,你可以比较一下两个对象的地址
    System.out.println(s1 == s2);//结果true,证明是同一个对象    
}

结果很好,那么我又来问问题了。如果创建了两个对象,这时原来的实例对象改变了,会有什么结果呢?那不就创建的两个实例对象不是同一个了嘛。对,很对。不是同一个对象了。来再继续看以下场景!

class Test {
    //又分别创建了两个对象为s3和s4,这时我将原实例对象改变一下,把它置为空,会有什么结果呢?
    Singleton s3 = Singleton.instance;
    //把原实例对象置为空
    Singleton.instance = null;
    Singleton s4 = Singleton.instance;
    //比较两个对象的地址
    System.out.println(s1 == s2);//结果false,证明不是使用的同一个实例对象
}

因为上面的场景外界可以改变原来的实例对象,而造成创建实例不一致,那么我们就想办法限制外界更改实例。那肯定有小伙伴想到使用get方法,为类提供一个get方法,将创建好的实例对象提供给外界使用就ok了。那么get方法是外界随便就可以使用的吗,常规来说,get方法是通过创建实例对象后句点出来的,那外界创建不了实例对象我们怎么办?别忘了我们有static修饰符,加了它不就能用类名句点出来嘛。代码如下,此代码也是最终版了!

//饿汉式单例模式
class Singleton {
	//1.在类的内部创建一个类的实例,该静态修饰的对象随着类加载只创建一次实例
    private static final Singleton instance = new Singleton();
	//2.私有化构造器,使得在类的外部不能够调用此构造器
    private Singleton() {

    }
	//3.私有化此对象,通过公共的方法来调用
	//4.公共的方法,只能通过类来调用,因为设置为static的,同时类的实例也必须为static声明的
    public static Singleton getInstance() {
        return instance;
    }
}

细心的小伙伴,会发现我在创建实例的时候不单单加了static修饰,而且还使用final修饰。这是为什么呢?其实加final是为了该对象不被改变,是代码更见健壮而已!

六、懒加载(Lazy Load)

懒加载(Lazy Load),这里的懒加载指的是在使用实例对象的时候才会去创建实例对象。这就避免了资源的浪费和内存的占用问题。其实懒加载无非就是在空间换时间与时间换空间中的取舍!

再一次提问: 而且该实现还遗留了一个问题那就是,假如在此类中写的代码。我们不管用不用该实例对象,它类加载的时候就自动创建一个对象。会造成资源的浪费和内存的占用。虽然占用的不多,但是也是一种漏洞,懂吧!

那我我们考虑在单例模式思想传递过程中的终极版(此终极版就是饿汉式单例模式),它不支持懒加载。那怎样才能支持懒加载呢?那我们就需要控制创建对象不在类加载的时候创建,而是在get方法中创建实例对象为外界提供。先看代码吧,以下方法实现单例模式就支持懒加载了!

//普通懒汉式单例模式(线程不安全)
class Singleton {
    //创建实例对象
    private static Singleton instance = null;

    //私有化构造器
    private Singleton() {

    }

    //提供static修饰的get方法,以供外界创建实例对象
    public static Singleton getInstance() {
        //判断实例对象是否为空,为空则创建实例对象并返回
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
}

七、单例设计模式的线程安全问题

问题: 在单例设计模式中什么是线程不安全?

单例模式中,只会创建一个实例对象,也就是外界使用的实例对象是同一个对象,当然既然是同一个他们的地址都是相同的!所谓单例设计模式中的线程不安全,就是存在可以创建多个该实例对象的现象!

问题: 普通懒汉式单例模式是怎样个线程不安全呢?如果将其改装为线程安全的呢?

单例设计模式的线程安全问题,继懒加载问题分析后,普通的懒汉式单例模式会存在线程安全问题。

在单例设计模式创建实例对象是一个原子操作!它的线程不安全,可以解释为多个线程在并发访问创建此单例对象时,同时在判空环节抢到了CUP的时间片,创建了两个或多个该实例对象。破坏了单例设计模式单实例对象原则!

线程安全的单例模式有很多,比如介绍思想传递过程时的那个饿汉式单例模式,它天生就是线程安全的,你好好琢磨一下饿汉式,我不可能有创建多个实例的情况!

线程安全的单例模式,在第八章的分类剖析中,我会一一列举,并将所有单例模式作对于写出他们的特点、优缺点等等!

改装普通懒汉式单例模式并解决线程安全问题

如果想要改装普通懒汉式单例模式,我们就必须使用到同步锁(synchronized)了!如下两种操作可以解决普通懒汉式线程安全问题!代码如下:

1.为原子操作方法加同步锁

//同步锁懒汉式单例模式(线程安全)
class Singleton {
    //创建实例对象
    private static Singleton instance = null;

    //私有化构造器
    private Singleton() {

    }

    //加同步锁并被static修饰的get方法
    public synchronized static Singleton getInstance() {
        //具体来说以下是原子操作
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
}

2.为该实例对象加同步代码块

注意: 在使用同步代码块的时候,括号内不能是this。因为我们使用static修饰创建对象,同步的对象不可能同步外界通过static句点出来的对象的,因为此操作并不合理。所以,此处写了this会飘红报错!

//同步锁懒汉式单例模式(线程安全)
class Singleton {
    //创建实例对象
    private static Singleton instance = null;

    //私有化构造器
    private Singleton() {

    }

    //加同步锁并被static修饰的get方法
    public static Singleton getInstance() {
        //此处锁的是实例对象
    	synchronized(Singleton.class) {
        	if (instance == null) {
            	instance = new Singleton();
        	}
        	return instance;
        }
    }
}

八、单例模式分类剖析

5.1 饿汉式单例模式(推荐)

image-20200525223732985

5.2 普通懒汉式单例模式

image-20200525230045774

5.3 同步锁懒汉式单例模式

image-20200525230504602

5.4 同步代码块懒汉式单例模式

image-20200526103421136

5.5 双重校验锁/双检锁单例模式(推荐)

image-20200525234005246

5.6 静态内部类/登记式单例模式(推荐)

image-20200526105157083

5.7 枚举单例模式

image-20200526184929293

九、解决多种破坏单例模式原则的方法

大家都知道,我们学过的反射技术是多么的无赖,它好似一个大哥在它面前修饰符都是一个弟弟。都可以使用暴力反射来突破封装、突破修饰符的限制。

除此之外序列化,可以通过序列化将对象写在文件中,然后通过读取文件来创建对象。

在单例设计模式中,有三种单例模式是我们推荐使用的单例模式,它们不仅效率高而且还保证了线程安全。分别是饿汉式单例模式双重锁校验单例模式静态内部类单例模式 。虽然它们是线程安全的,但是都可以被反射和序列化攻击,从而破坏了单例原则!(在这里我们先把枚举单例模式放一放!)

在此以上反射和序列化都可以破坏单例设计模原则!那我们该怎么办?

9.1 反射破坏单例模式原则剖析

反射通过突破私有构造器创建实例对象

反射破坏单例模式原则

首先,先写一个线程安全的单例模式,三者随便写一个,反射突破单例原则方法都是一样的!

//饿汉式单例模式
public class Singleton {
	//1.在类的内部创建一个类的实例,该静态修饰的对象随着类加载只创建一次实例
    private static final Singleton instance = new Singleton();
    
	//2.私有化构造器,使得在类的外部不能够调用此构造器
    private Singleton() {

    }
    
	//3.私有化此对象,通过公共的方法来调用
	//4.公共的方法,只能通过类来调用,因为设置为static的,同时类的实例也必须为static声明的
    public static Singleton getInstance() {
        return instance;
    }
}

接下来,我们利用反射技术来突破单例原则!

注意: 通过反射技术来创建单例对象的核心,即是将其私有化构造器修饰符置为无效并通过构造器来创建实例对象

public class Test {
    public static void main(String[] args) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {
        /**
         * 饿汉式单例模式获取实例对象
         */
        Singleton instance1 = Singleton.getInstance();

        /**
         * 反射获取实例对象
         */
        //获取Singleton类中的私有构造器
        Constructor<Singleton> constructor = Singleton.class.getDeclaredConstructor();
        //使private修饰失效,突破私有构造器
        constructor.setAccessible(true);
        //通过类构造器来创建实例对象
        Singleton instance2 = constructor.newInstance();
		
        //判断对象是否是同一个对象
        System.out.println(instance1 == instance2);//结果为false,证明两个实例对象不是同一个实例对象
    }
}

解决反射破坏单例原则

为了解决反射使用构造器来创建实例对象来突破单例原则,我们需要在反射突破构造器的时候判断是否已经创建过该实例对象,如果创建过该实例对象我们将为其抛出异常。简单来说在私有构造器中加上一个实例对象判空的操作。这样就能阻止反射技术突破单例原则!

//饿汉式单例模式
public class Singleton {
	//1.在类的内部创建一个类的实例,该静态修饰的对象随着类加载只创建一次实例
    private static final Singleton instance = new Singleton();
    
	//2.私有化构造器,使得在类的外部不能够调用此构造器
    private Singleton() {
        //防止反射突破单例原则
		if (null != instance) {
            //抛出异常提示:实例对象创建失败
            throw new RuntimeException("Failed to create the instance object");
        }
    }
    
	//3.私有化此对象,通过公共的方法来调用
	//4.公共的方法,只能通过类来调用,因为设置为static的,同时类的实例也必须为static声明的
    public static Singleton getInstance() {
        return instance;
    }
}

这时候我们再试图使用反射技术来突破单例原则会出现如下现象!

image-20200526143749043

9.2 序列化破坏单例模式原则剖析

序列化通过使用ObjectOutputStreamObjectInputStream流,把对象写入文件并读取文件创建对象

序列化破坏单例模式原则

首先,写一个线程安全的单例模式,我们还是选择饿汉式单例模式,记得我们要实现序列化接口。如果不实现序列化的话,会报错的!

image-20200526180259419

//饿汉式单例模式
public class Singleton implements Serializable {
    private static final Singleton instance = new Singleton();
    
    private Singleton() {
        //防止反射突破单例原则
		if (null != instance) {
            //抛出异常提示:实例对象创建失败
            throw new RuntimeException("Failed to create the instance object");
        }
    }
    
    public static Singleton getInstance() {
        return instance;
    }
}

接下来,我们将该饿汉式单例模式使用序列化突破一下。

public class Test {
    public static void main(String[] args) throws IOException, ClassNotFoundException {
        /**
         * 饿汉式单例模式获取实例对象
         */
        Singleton instance1 = Singleton.getInstance();

        /**
         * 通过序列化获取实例对象
         */
        //创建ObjectOutputStream对象、FileOutputStream对象并创建写入obj.obj文件
        ObjectOutputStream objectOutputStream  = new ObjectOutputStream(new FileOutputStream("obj.obj"));
        //将instance1写入obj.obj文件中
        objectOutputStream.writeObject(instance1);
        //关闭流
        objectOutputStream.close();

        //创建ObjectInputStream对象、FileInputStream对象并指定读出obj.obj文件
        ObjectInputStream objectInputStream = new ObjectInputStream(new FileInputStream("obj.obj"));
        //将对象从obj.obj文件中读出并形成了instance2实例对象
        Object instance2 = objectInputStream.readObject();
        //关闭流
        objectInputStream.close();

        //比较两个对象是否为同一个对象
        System.out.println(instance1 == instance2);//false,证明两个对象不是同一个对象
    }
}

以上操作可见,我们解决反射的方式不能解决序列化破坏单例模式。

分析序列化破坏单例原则

解决序列化破坏单例原则,我们就需要了解一下底层原理啦。然后我们进入ObjectInputStream的底层看一下,并找到private Object readOrdinaryObject(boolean unshared)方法。

image-20200526181530782

在这里创建对可以理解为ObjectInputStreamreadObject()返回对象。

然后红圈内的newInstance()方法,是通过反射技术调用无参构造创建对象。

红圈左边有一个isInstantiable()方法,是判断如果serializable/externalizable的类可以在运行时被实例化,那么该方法就返回true。

由此可见,我们实现了Serializable序列化接口,该方法就返回true,然后通过反射技术调用无参构造方法创建实例对象,破坏了单例原则!

解决序列化破坏单例原则

既然我们在上文已经找到了是序列化是如何破坏单例原则的原因,那我们就可以根据它来找到解决的办法。至于解决方案,我们需要在Singleton类中定义一个readResolve方法,然后在该方法中返回实例对象即可!

//饿汉式单例模式
public class Singleton implements Serializable {
    private static final Singleton instance = new Singleton();
    
    private Singleton() {
        //防止反射突破单例原则
		if (null != instance) {
            throw new RuntimeException("Failed to create the instance object");
        }
    }
    
    public static Singleton getInstance() {
        return instance;
    }
    
    //解决序列化破坏单例模式
    private Object readResolve() {
        return instance;
    }
}

这时候,我们再使用序列化攻击后,对两个对象的地址就会返回true了,返回true结果就证明了两个实例对象是同一个实例对象。然后,我们分析这是怎么实现的?来继续回到ObjectInputStream源码中,继续找到那个readOrdinaryObject方法,其中有这么些代码,如下:

image-20200526210721500

第一个红线框中的hasReadResolveMethod()方法代表的是如果实现 Serializable 或者 Externalizable接口的类中包含readResolve方法,则返回结果true。

image-20200526211209012

第二个红线框中的invokeReadResolve()方法代表的是通过反射技术调用要被发序列化的类的readResolve方法。

image-20200526211241498

底层原理也解释过了,所以可以总结为在Singleton中定义readResolve方法,并在该方法中指定要返回的对象的生成策略,就可以防止单例被破坏。

十、枚举单例模式的关键底层和攻击解决

关于枚举单例模式的代码,就只是一个INSTANCE。但是它怎么实现的,是如何避免反射和序列化攻击的,我们有待研究。

10.1 反射攻击枚举单例模式

/**
 * 枚举
 */
public enum Singleton {
    INSTANCE
}
public class Test {
  public static void main(String[] args)
      throws NoSuchMethodException, IllegalAccessException, InvocationTargetException,
          InstantiationException {
    Singleton instance1 = Singleton.INSTANCE;
    Constructor<Singleton> constructor = Singleton.class.getDeclaredConstructor();

    constructor.setAccessible(true);

    Singleton instance2 = constructor.newInstance();

    System.out.println(instance1 == instance2);
  }
}

尝试使用反射攻击,得到的结果却是一个飘红的报错信息并显示没有无参构造初始化,如下:

image-20200526190426409

然后我们进入Enum的源码查看,发现枚举类确实是没有无参构造。所以反射不可能从无参构造器中攻击!

那么有参构造器呢?枚举类中有嘛?于是我翻了一下源码,发现枚举是提供有参构造的!

image-20200526201408120

那么我们就顺藤摸瓜,来使用反射攻击一下有参构造!

public class Test {
  public static void main(String[] args)
      throws NoSuchMethodException, IllegalAccessException, InvocationTargetException,
          InstantiationException {
    Singleton instance1 = Singleton.INSTANCE;
    Constructor<Singleton> constructor = Singleton.class.getDeclaredConstructor(String.class, int.class);

    constructor.setAccessible(true);

    Singleton instance2 = constructor.newInstance();

    System.out.println(instance1 == instance2);
  }
}

结果很明显,又出现了飘红的报错信息并显示无法以反射方式创建枚举对象 ,如下:

image-20200526201901354

通过反射技术来对枚举单例模式,显然是不可以创建实例对象的。所以枚举单例模式,避免的反射技术的攻击。那么它最终是怎么实现反射有参构造也不可以创建对象的呢?那我们就需要进入反射技术的newInstance()方法中查看源代码了。看到源代码的你,是否知道了为什么不能反射枚举类创建对象了吗?是因为它对枚举做了判断,如果是枚举就会抛出异常!

image-20200526202854293

10.2 序列化攻击枚举单例模式

/**
 * 枚举
 */
public enum Singleton {
    INSTANCE
}
public class Test {
    public static void main(String[] args) throws IOException, ClassNotFoundException {
        Singleton          instance1          = Singleton.INSTANCE;
        ObjectOutputStream objectOutputStream = new ObjectOutputStream(new FileOutputStream("obj.obj"));

        objectOutputStream.writeObject(instance1);
        objectOutputStream.close();

        ObjectInputStream objectInputStream = new ObjectInputStream(new FileInputStream("obj.obj"));
        Object            instance2         = objectInputStream.readObject();

        objectInputStream.close();

        System.out.println(instance1 == instance2);//true,证明两个对象是同一个对象
    }
}

Java规范中规定,每一个枚举类型极其定义的枚举变量在JVM中都是唯一的,因此在枚举类型的序列化和反序列化上,Java做了特殊的规定。
在序列化的时候Java仅仅是将枚举对象的name属性输出到结果中,反序列化的时候则是通过 java.lang.Enum 的 valueOf() 方法来根据名字查找枚举对象。
也就是说,以枚举为例,序列化的时候只将 INSTANCE 这个名称输出,反序列化的时候再通过这个名称,查找对应的枚举类型,因此反序列化后的实例也会和之前被序列化的对象实例相同。也就是我们看到的结果true了。

十一、表格总结

注意: 除枚举单例模式以外,其他的单例模式都是可以通过我们的干预来避免反射或系列化攻击的,而且我在文章中有过详细的讲解和底层分析!

单例模式分类 是否线程安全 是否支持懒加载 效率 是否可避免反射或序列化攻击
饿汉式(推荐)
普通懒汉式
同步锁懒汉式
同步代码块懒汉式
双重校验锁/双检锁(推荐)
静态内部类/登记式(推荐)
枚举(最佳,没被广泛采用) 是(天生可以避免)

大家好,我是Ziph。欢迎大家为我点赞、留言!有不懂的可以提出哦!我会回复的!

猜你喜欢

转载自blog.csdn.net/weixin_44170221/article/details/106365623