Java设计模式—单例模式精讲

相信学习Java的同学们对设计模式这个概念并不陌生,不管是在学习还是在求职面试的过程中我相信大家都或多或少接触过设计模式这个字眼。不知道大家在学习SSM(Springboot-Spring-Mybatis)框架的时候会不会存在疑问,咱们一个服务至少由三部分组成,即控制层到服务层,最后就是咱们的数据层。咱们创建这些业务对象使用的是Spring的控制反转,将跟项目相关的类通过反射的方式加入到IOC容器。像咱们Controller层、Service层都很好理解,我们只需要在Spring的核心配置文件扫描对应的类,在相应的类加上对应的注解,就能完成对象的创建以及装配。那么不知道大家对咱们的Mapper接口层会不会存在疑问,我们不妨回忆一下在开发Mybatis的Mapper接口过程中,我们只需要编写一个Mapper接口,然后将其与对应的Mapper.xml绑定。我们再把Mapper接口使用@AutoWired自动装配在对应的Service层,就可以完成咱们数据的增删改查。但是这个时候就会出现一个很矛盾的问题,大家在学习Java基础的时候就知道咱们的接口是没有构造器的,接口并不能直接创建对象,我们必须依赖它的实现类来进行创建对象。那么问题来了,咱们的Mapper接口并没有编写实现类,IOC中的Mapper对象是怎么产生的呢?难道它使用了接口直接创建对象了吗?

我们在进行debug的时候,会很明显的发现,咱们的mapper此时对应的对象前面有个$Proxy开头的前缀,此时咱们的Mapper接口是存在实现类的,只不过这个实现类并不是咱们自己实现的,而是mybatis底层根据咱们的映射规则使用代理模式帮助我们生成的,最后将其创建对象加入到咱们的IOC容器中。

其次还有Mybatis的缓存淘汰策略,IO流的架构都使用到了一种比较经典的设计模式,也就是装饰者模式。像咱们Spring中,单例模式、代理模式、适配器模式、工厂模式等都使用的特别频繁。所以可以说设计模式无处不在,对咱们的程序来说能够起到很好的解耦作用,从而让咱们的代码复用性以及拓展性更强。

前面咱们讲解数据结构的时候,就说过程序的灵魂为数据结构加算法,在咱们Java学习中,如果想要让咱们的技术得到质的飞跃,设计模式加反射则是必须要修炼的内功,对咱们的大多数框架,几乎都是基于反射加上设计模式的思想实现的。所以说学会设计模式是特别重要特别有意义的,那么从本篇文章开始,咱们就开始讲解设计模式这门凌驾于编程语言之上的技术,让咱们的编程内功更强,得到一个质的飞跃。

设计模式的概念其实很简单,它是随着编程技术的发展,业务与业务之间越来越错综复杂,就自然会导致类与类之间的关系依赖越来越复杂,耦合度越来越高,出现“牵一发而动全身”的灾难,设计模式是随着时间的积累,咱们老一辈优秀程序员随着开发的经验而总结出来的一些套路。这些套路主要用于设计类,并且协调类与类之间的关系,从而让咱们程序之间的耦合度更低,让咱们的拓展更方便更灵活。所以说如今的我们是幸福的,完全可以站在巨人的肩膀上,学会它们的智慧,让咱们的技术达到一个更高的阶段。

相信大多数同学都知道,设计模式有一共23种,像特别常见的单例模式、代理模式、装饰者模式等咱们也提过,从这篇文章开始咱们会依次将这23种设计模式讲解一遍,然后结合工作中的项目经验,将他们运用的更加熟练。

设计模式一共23种,根据操作类型不同讲解归纳为三大种,分别为创建型模式、结构型模式以及行为型模式。那么今天咱们则讲解创建型模式的第一种,也就是单例模式,后续的22种会陆续在后面的文章进行讲解。

一、单例模式的实现

概念:在讲解单例模式概念之前,我们需要了解一下创建型模式。

创建型模式,也就是创建对象时的套路,也就是咱们的关注点在于怎么创建对象?它的主要特点是将咱们对象的创建与使用分离,从而让咱们的系统之间的耦合度更低,让咱们的使用者不需要关注对象的创建细节,从而让咱们代码之间的依赖程度更低,复用性和拓展性更强。

创建型模式一共分为4种,分别为单例模式工厂模式原型模式以及建造者模式。接下来几篇文章咱们主要讲解一下这种模式,让咱们的知识面更广,程序的设计上得到一些灵感以及帮助。

单例模式是咱们创建型模式中最简单也是最好理解的一种设计模式,同样也是23种设计模式中最简单的一种模式,我相信大家或多或少都知道这一种设计模式。

那么什么是单例模式呢?单例模式是创建对象的一种模式,顾名思义,单例单例,就是希望我们一个类创建出来的对象自始至终只会有一个。咱们学过Java都知道,一个类理论上可以产生无数个对象,那么怎么让咱们的类只会产生一个对象呢,也就是咱们单例模式这种思想带来的魅力。

单例模式分类:单例模式总体分为两种,一种为饿汉式,其次为懒汉式。饿汉式见名知意,也就是饥不择食,不管该对象会不会使用,Jvm在第一次加载的时候就会将这个对象创建出来。懒汉式则是只会在咱们使用到该对象时才会给我们对象分配内存,也就是按需分配。

而针对这两种思想,咱们单例模式目前来说主要有7种实现方法,下面针对这7种方式,咱们一一实现,让咱们仔细理解和体会设计模式的智慧与魅力。

1.1饿汉式实现一

因为代码比较简单,我们先写完,再重点讲解其中核心点。实现代码如下:

package com.ignorance.design.model.demo01;

public class SingleTon {

private static final SingleTon instance = new SingleTon();

private SingleTon() {

}

public static SingleTon getInstance(){

return instance;

}

}

这是单例模式最简单的一种实现方式,我相信代码一看大家都很明白,但是有几个重点大家很容易忽略。也就是单例设计模式这种套路的核心:

第一点:怎么产生单一对象也就是保证这个类的的内存地址唯一呢?所以需要要求咱们在类的创建时机上下功夫,咱们都知道任何对象的创建都会调用该对象对应类的构造方法,所以就需要我们在构造方法上做文章。仔细阅读上面代码比较细心的同学就会发现,我们是重新定义了该类的无参构造器的,咱们都知道一个类不显式定义构造器是存在一个默认的无参构造器的,那么我们也声明了无参构造器,这种行为并不是多此一举,而这一点正是保证单一对象的核心。咱们无参构造器默认访问修饰符是public,public的意思大家也都知道,是项目所有的地方的可以访问,也就是任意地方都可以调用这个构造器初始化对象,那么一旦声明为public,理论上咱们的类就可以产生无数个对象,这显然是错误的。所以我们将构造器的访问修饰符修改为了private,也就是除了本类其他类再也无法创建对象,从而切断其它位置创建对象的可能。

第二点:我们单例模式外部不能创建对象了,也就只能在本类中创建对象,因为咱们的访问修饰符为private私有,所以咱们创建这唯一的对象只能在本类创建。所以我们在SingleTon声明instance属性并且对它进行初始化为SingleTon类的一个对象。因为咱们的instance属性声明为私有,所以外部需要访问就需要提供一个公开的getInstance()方法。其次最重要的是instance属性以及getInstance()方法都必须被static修饰,因为非静态成员作为对象级成员,外部访问必须要创建SingleTon对象,因为咱们构造器私有化了,所以只能通过静态成员的形式访问。

所以综上所述:单例模式的核心主要是构造器私有化,提供公共且静态的访问方法。

1.2饿汉式实现二​​​​​​

package com.ignorance.design.model.demo02;

public class SingleTon {

private static SingleTon instance;

private SingleTon(){

}

static {

instance = new SingleTon();

}

public static SingleTon getInstance(){

return instance;

}

}

第二种实现方式跟第一种没有什么区别,唯一的一点不同,第一种是直接在定义变量时就初始化了对象,第二种则是利用静态代码块的方式完成对象的创建以及赋值,两则几乎一样。主要还是两个点:构造器私有以及提供对外的静态访问方法。

1.3饿汉式实现三

在Java基础的时候,咱们接触过一种特殊的类,这个类的要求就是对象只能有限个,也就是咱们的枚举。我们完全可以借助枚举这一特性完成单例模式的实现。实现代码如下:​​​​​​​

package com.ignorance.design.model.demo03;

public enum SingleTon {

INSTANCE();

}

枚举的思想跟咱们前两种单例模式的思路一样,单例模式是控制一个类产生的对象只有一个,而咱们的枚举同样需要控制产生的对象只能是有限个,所以枚举类的构造方法只能为私有方法,每个对象还需要对外提供,又因为构造器私有,外部无法创建引用,所以提供的属性或者方法只能为public以及静态的。

1.4懒汉式实现一

上面的三种实现均为饿汉式,在Jvm第一次加载时,咱们的对象也就会进行创建,然后存储在内存中。这样做的优点是不存在线程安全性问题,缺点也很明显,不管咱们使不使用这个对象,该对象都会被创建,浪费性能以及内存。

懒汉式也就是懒加载,主要是针对饿汉式带来的内存浪费做一个弥补的,它的核心是如果咱们不使用该对象,那么这个单例对象就不会被创建,只有在咱们第一次使用单例对象时才会为咱们创建这个对象,相比较而言,还是更节省空间,性能还是会更好的。

下面呢,我们使用懒汉式的思想来完成单例模式的第一种实现。​​​​​​​

package com.ignorance.design.model.demo4;

public class SingleTon {

private static SingleTon instance;

private SingleTon(){

}

public static SingleTon getInstance(){

if (instance == null){

instance = new SingleTon();

}

return instance;

}

}

以上是第一种懒汉式的实现,这种实现比较简单,相对于饿汉式来说,我们并没有在类加载时直接实例化咱们的对象,而是在getInstance()方法中进行初始化,在调用该方法时提供一个简单的判断来控制咱们的对象是否创建,如果当前instance为null,则直接初始化,反之则直接返回对应的instance引用。但是这种实现因为涉及到多个操作,多个操作又无法保持原子性,就会导致线程安全问题。

咱们仔细分析咱们的代码,咱们是通过if (instance == null)这个判断语句来控制当前的对象是不是唯一的,如果满足则直接调用构造器初始化一个对象。这个过程单线程还好,如果是多线程,这两条语句并不能保持原子性,也就是不能同时执行完,试想如果第一个线程A调用该方法,此时咱们的instance为null,但是刚判断成功,还没有来得及执行初始化方法,这时候线程A由于各种原因失去了cpu的调度权进入阻塞状态。此时线程B得到cpu的执行权,由于此时咱们的instance引用还是null,又会进入if内部,这时候线程B调用构造器创建出一个对象,并且返回。同样线程A恢复,再次得到cpu的执行权,此时又会调用构造器创建一个新的对象,那么此时呢咱们的单例模式就会创建出多个对象,显然是有很大安全隐患的。为了针对懒汉式带来的线程安全问题,所以懒汉式又会有多种实现方式,进而在性能和安全中做一个权衡。

1.5懒汉式实现二

在上面呢,我们分析了第一种懒汉式思想的优缺点,优点是在单线程下性能比较高,缺点就是在多线程下会造成很严重的线程安全问题,会导致咱们的单例模式创建出多个对象。所以第二种,针对线程安全问题进行解决,具体代码如下:​​​​​​​

package com.ignorance.design.model.demo4;

public class SingleTon {

private static SingleTon instance;

private SingleTon(){

}

public static synchronized SingleTon getInstance(){

if (instance == null){

instance = new SingleTon();

}

return instance;

}

}

这种方式相对上一种方式最大的区别就是给方法加了锁,也比较容易想到。比如此时线程A进入if判断后在初始化对象前进入阻塞,此时线程B得到了cpu执行权,但是因为线程A并没有释放同步锁,此时线程B依然无法进行执行,只能卡在同步方法之外,必须等到线程A执行完全并且释放同步锁才能继续执行。

这一种方式无疑安全性得到了保证,但是synchronized作为重量锁,直接让咱们所有的操作就变成了串行化,导致程序的吞吐量大大降低,显然是牺牲了性能来换取安全。

1.6懒汉式实现三

第二种实现方式特别暴力,而且特别好理解,但是呢在保证了线程安全的同时性能就会特别低,属于典型的性能换安全。

我们其实仔细想想,在单例模式中,咱们的写操作,也就是创建对象的方式永远只可能执行一次,如果执行多次就不叫单例模式了。更多的呢,是读操作,也就是直接返回第一次创建出的那个对象。

对上面的代码而言,如果线程A是第一次访问,此时由于insance为null,就会进入方法内部创建对象,但是如果此时线程A阻塞了,线程B同样访问不了,就一直会让程序陷入阻塞。

就算此时咱们线程A和B都不是第一次访问,A进入线程判断instance不为null,在返回对象的时候刚好阻塞,此时没有释放同步锁,此时线程B即使得到cpu执行权,同样会因为没有得到锁对象而进入不了咱们的getInstance方法。所以第二种实现性能并不高。

接下来针对第二种的性能问题,咱们给出第三种实现:​​​​​​​

package com.ignorance.design.model.demo05;

public class SingleTon {

//volatile为了防止智能指令重排序,保证数据的可见性以及有序性

private static volatile SingleTon instance;

private SingleTon(){

}

public static SingleTon getInstance(){

//第一个if用于判断咱们此时对象是否存在,如果对象存在也就是读操作,是不会存在线程安全问题的

if (instance == null){

//如果instance为null,说明对象没有创建,此时需要加锁,因为只要在对象创建的时候才会存在线程安全问题

synchronized (SingleTon.class){

if (instance == null){

instance = new SingleTon();

}

}

}

return instance;

}

}

这种方法是咱们懒加载最完美的一种方法,也是咱们在面试之中问及单例模式面试官最想考察咱们求职者的一种方式,希望大家一定要学会并且理解。为什么说它的性能会达到很好了,尤其是双重if判断我相信对一些初学者而言还是比较难理解的,并不是代码冗余,而恰恰是咱们的双重判断,才会让咱们的懒汉式既保证了数据安全,又提高了访问性能。接下来了,咱们来分析一下:

进入getInstance()方法后,咱们会区分当前操作为读操作还是写操作,咱们上面分析了,读操作肯定是不会存在线程安全问题的。如果对读操作进行加锁,会对咱们的并发效率造成很严重的影响,所以呢,第一次判断咱们没加锁,任何线程都可以并发执行,比如A线程判断instance不为null,在下一步操作返回instance对象时失去了cpu的执行权,陷入阻塞了。此时咱们的B线程进入,同样能够进入该方法,还是因为咱们第一种判断没有加锁,B线程此时判断instance还是不为null,直接返回给调用者就可以,A线程以后得到cpu资源后,继续返回即可。对咱们的数据安全没有一点影响。

简单的来说,咱们读操作的判断和返回这两条语句完全不需要保证严格的数据一致性,能够保证数据的最终一致性也就可以了,所以这是第一次判断给咱们的程序带来的优化,大幅度的提高了并发性能,让咱们的程序吞吐量更大。

第二呢,如果第一次判断当前instance为null,则咱们就要进入写操作,也就是创建对象。此时咱们进入第一重if内部,这个时候就必须要加锁了,这个时候相信有些哥们儿就会问,为什么要进行第二重if判断了,在前面第一重循环咱们不是判断过了吗?能进入同步代码块不就说明咱们的instance为null了吗?

但是你仔细想想,咱们第一重if判断并没有加锁,比如A线程第一重判断为null,它会进入同步代码块。但是这个时候线程A因为各种原因还没有实例化对象就陷入阻塞了,咱们线程B得到cpu执行权,虽然线程A没有释放同步锁,但是咱们此时线程B仍然能进入第一重循环判断,因为此时咱们第一重判断并没有加锁,这个时候咱们的B线程同样判断instance为null,进入第一重if判断,到同步锁阻塞,因为A没有释放锁。当咱们的A线程再度得到cpu执行权后,此时创建了对象并且释放了同步锁。B线程拿到锁,进入同步代码块,此时如果不双重判断,咱们的B线程是不是又会初始化一个对象,进而造成严重的线程安全问题呢。

所以说,双重if判断并不是多余,恰恰是这样,咱们的单例模式性能在第一重检查得到了提高,安全问题在第二重判断得到了保证。

还要注意的点就是,咱们的静态变量instance前面需要使用volatile关键字防止jvm底层指令的优化以及重排序,必须保证有序性以及可见性,不然极大可能会出现空指针的问题。

1.7懒汉式实现四

讲完了上面三种,其实实际上都是一种,我们只是针对线程安全与性能对每种实现做了补充,在懒汉式实现单例模式中,双重检验一定是个很重要的知识,希望大家一定要进行理解,体会到它的精妙与智慧。

接下来咱们讲解懒汉式的最后一种,叫做使用静态内部类的方式来实现单例模式,我们先看代码,再针对代码进行讲解:​​​​​​​

package com.ignorance.design.model.demo06;

public class SingleTon {

private SingleTon(){

}

static class SingleTonBuilder{

public static final SingleTon INSTANCE = new SingleTon();

}

public static SingleTon getInstance(){

return SingleTonBuilder.INSTANCE;

}

}

这种方式比较巧妙,实现它主要是巧妙的利用Jvm对静态内部类的加载顺序,Jvm第一次只会加载咱们的外部类,内部类并不会被加载,静态内部类的加载只会在第一次被调用的时候才会被加载,对应的静态变量也才会被初始化。也就是在咱们第一次调用外部类的getInstance()方法时静态内部类才会被加载,咱们的单例对象才会被创建。

这一种方法比较简单,相对于咱们前面三种好理解的多,也不用去担心线程安全问题,因为静态成员只会加载一次,所以不管是性能以及安全都能够得到保证。

二、单例模式的破坏

在上面的内容中,咱们主要讲解了单例模式的七种常见的实现方式,那么通过这一系列创建对象的方式一定会只有一个对象吗?也就是无论什么情况都会保证咱们创建出的对象都是唯一的吗?答案肯定是否定的,正所谓不防君子防小人,咱们可以总结出,咱们上面实现单例模式的核心就是将构造器设置为了私有,让咱们除了本类其它任何地方都无权访问咱们的构造器,那么问题来了,在咱们Java语法中,正常来说被private修饰过的成员,除了本类其它方式都无法访问,但是有一种方式除外,我相信大家或对或少会有一些印象,那就是咱们的反射,通过字节码对象不管当前成员是否是私有化的,咱们都可以一览无余,肆无忌惮的进行访问。所以呢,咱们的单例模式并不是严格上的真正安全,在咱们常见的技术中,就存在两种漏网之鱼,可以绕过咱们构造器的私有化限制,进而创建出多个对象,进而对咱们的单例模式产生破坏。第一种是对象序列化,第二就是利用反射。下面呢,咱们主要来研究一下这两种神奇的操作。重点说一下,通过枚举实现单例模式是通过JVM内部进行实现的,是无法被这两种方式进行破坏的,咱们说得破坏主要是针对其它六种实现方式。

2.1对象的序列化破坏反射

咱们在学习IO的时候学习过对象的序列化以及反序列化,序列化,其实就是将咱们的内存数据写到硬盘文件的一种方式,反序列化则是将硬盘文件加载到咱们内存的一个过程,下面咱们针对咱们的单例模式,来验证一下在对象序列化以及反序列化时咱们创建出来的对象内存地址是不是唯一不可变的。

第一步呢:咱们需要对待序列化的类实现咱们的序列化接口,并初始化一个唯一的序列化ID​​​​​​​

package com.ignorance;

import java.io.Serializable;

public class SingleTon implements Serializable {

private static final long serialVersionUID = -6849794470754667710L;

//volatile为了防止智能指令重排序,保证数据的可见性以及有序性

private static volatile SingleTon instance;

private SingleTon(){

}

public static SingleTon getInstance(){

//第一个if用于判断咱们此时对象是否存在,如果对象存在也就是读操作,是不会存在线程安全问题的

if (instance == null){

//如果instance为null,说明对象没有创建,此时需要加锁,因为只要在对象创建的时候才会存在线程安全问题

synchronized (SingleTon.class){

if (instance == null){

instance = new SingleTon();

}

}

}

return instance;

}

}

第二步呢:我们使用IO的方式向硬盘文件写出当前的单例对象:​​​​​​​

package com.ignorance;

import java.io.FileOutputStream;

import java.io.IOException;

import java.io.ObjectInputStream;

import java.io.ObjectOutputStream;

public class SingTonDestoryTest {

private static final String FILE_PATH = "hello.txt";

public static void main(String[] args) {

testWriteFile();

}

public static void testWriteFile(){

ObjectOutputStream objectOutputStream = null;

try {

objectOutputStream = new ObjectOutputStream(new FileOutputStream(FILE_PATH));

SingleTon singleTon = SingleTon.getInstance();

objectOutputStream.writeObject(singleTon);

} catch (IOException e) {

e.printStackTrace();

}finally {

if (objectOutputStream != null){

try {

objectOutputStream.close();

} catch (IOException e) {

e.printStackTrace();

}

}

}

}

}

学过IO的同学们来说,上述的代码应该很简单,理解起来并不是很费劲,咱们因为要向硬盘写出一个对象,所以就使用了比较方便的对象流。通过SingleTon类产生一个单例对象,并且调用writeObject()方法将这个对象写入到咱们的硬盘文件中。

运行结果如下:

此时咱们的硬盘上的hello.txt文件内容如下所示,因为文件保存的是二进制,咱们人眼肯定看不懂,不过没关系,我们知道它有内容就行:

第三步:对咱们序列化的这个文件进行读取,读文件的方法如下所示:​​​​​​​

public static Object readObjectFile(){

ObjectInputStream objectInputStream = null;

try {

objectInputStream = new ObjectInputStream(new FileInputStream(FILE_PATH));

return objectInputStream.readObject();

}catch (Exception e){

e.printStackTrace();

}finally {

if (objectInputStream != null){

try {

objectInputStream.close();

} catch (IOException e) {

e.printStackTrace();

}

}

}

return null;

}

上述代码主要是利用对象流对咱们上一步写出的对象进行加载,下面呢,我们对这个文件读取两次,测试一下两次读取的对象是否是同一个内存地址,如果两次是同一个地址,说明咱们的单例模式没有被破坏,反之则说明咱们的对象在序列化以及反序列化的过程中单例模式就产生了破坏。​​​​​​​

public static void main(String[] args) {

Object instance1 = readObjectFile();

Object instance2 = readObjectFile();

System.out.println(instance1);

System.out.println(instance2);

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

}

在main方法中,咱们调用了两次读取文件的方法,此时返回两个对象,我们只需要打印一下两个对象的内存地址,看一下是不是同一个地址。

测试结果如下图所示:

可以看出通过序列化操作,咱们两次读取的对象并不是同一个,也就是说咱们的单例模式在对象序列化就会进行失效。

那么对象序列化为什么会让咱们的对象地址发生改变了,咱们通过上面的案例可以得出结论,咱们每次调用ObjectInputStream类的readObject()方法都会返回一个新的对象,也就是等同于每次都会重新创建一个新的对象。

对于这个原理,咱们需要研究一下readObject()这个方法的源码:

第一步:我们在idea环境中进入该方法,重点关注咱们下面标记的代码:

通过上面的源码可以看出,咱们的readObject()方法在其内部调用了readObject0()这个方法,接下来咱们进入readObject0()这个方法内部;

第二步:进入readObject0()方法内部:

在这个方法中,存在一系列的switch判断,因为咱们操作的是对象,就看TC_OBJECT这个分支即可,我们进入readOrdinaryObject()这个方法内部,看一下它到底干了什么。

第三步:进入readOrdinaryObject()方法:

可以看出,在这个方法内部,它实际上就是通过咱们的反射创建的对象,当咱们每次调用readObject()方法时,都会通过反射的方式创建出一个新的对象,所以咱们多次读到的内存地址完全不一样。

那么,对于序列化引起的问题,咱们一定不能解决吗?答案肯定是可以解决,咱们不妨再从源码中找到答案。如下图所示:

在这里,我标记了这几句核心代码,这几句核心代码比较重要。它是做了判断,判断当前类里面是否存在readResolve()这个方法,如果存在就通过反射的方式返回这个方法的返回值。所以呢,我们利用它拓展的这个方法来改变咱们对象输入流readObject()的底层创建对象的逻辑。所以呢,为了解决单例模式在序列化下会被破坏的问题,我们需要给当前的SingleTon类添加并且实现readResolve()这个方法进而改变每次读取对象时都要通过反射创建对象的逻辑。

序列化问题破坏单例模式的解决

结合上面的源码分析,咱们只需要在咱们的SingleTon这个类中提供并且根据自己的业务实现readResolve()这个拓展方法即可,目的是改变readObject()这个方法的底层逻辑,让其不再通过默认的方式每次读取都通过反射创建新的对象。代码如下:​​​​​​​

package com.ignorance;

import javafx.beans.binding.ObjectExpression;

import java.io.Serializable;

public class SingleTon implements Serializable {

private static final long serialVersionUID = -6849794470754667710L;

//volatile为了防止只能指令重排序,保证数据的可见性以及有序性

private static volatile SingleTon instance;

private SingleTon(){

}

public static SingleTon getInstance(){

//第一个if用于判断咱们此时对象是否存在,如果对象存在也就是读操作,是不会存在线程安全问题的

if (instance == null){

//如果instance为null,说明对象没有创建,此时需要加锁,因为只要在对象创建的时候才会存在线程安全问题

synchronized (SingleTon.class){

if (instance == null){

instance = new SingleTon();

}

}

}

return instance;

}

public Object readResolve(){

return SingleTon.getInstance();

}

}

在上述代码中,我们根据readObject()的源码逻辑,提供了readResolve()方法,此时咱们的序列化类中存在这个方法,底层就直接会调用这个方法来返回咱们的对象。所以呢,此时咱们的对象不论读取多少次都会是只有一个。

下面呢,咱们来测试一下,把文件重新清空,并且重新读取多次,我们看一下运行结果:

此时咱们发现,咱们读取多次的对象都会是同一个内存地址了,这个时候咱们的序列化对象中存在readResolve()这个方法,就会使用咱们刚开始单例模式的思想进行创建对象,即没有就创建,有就复用,从而覆盖了readObject()底层默认的通过反射调用构造器创建对象的问题。

2.2反射破坏序列化

咱们学过Java的小伙伴都知道,咱们创建对象的方式并不是只存在一种,即直接通过new对象的方式。像咱们上述的单例模式思想,只能避免new这个关键字来创建对象的途径,因为构造器私有了,外部再也访问不到这个构造方法了,从而也就导致咱们自始至终创建的对象只会存在一个。但是呢,咱们还有另外一种可以绕过权限修饰符的方式,也就是咱们的反射,精切的来说叫做暴力反射,对暴力反射来说,不管你的类成员是私有还是怎么,在反射面前都像被扒光了衣服一样,赤裸裸的展示在对方面前。比如我们可以通过暴力反射的方式在外部创建无数个对象,如下所示:​​​​​​​

public static void destoryByReflect() throws Exception {

Class<?> clazz = Class.forName("com.ignorance.SingleTon");

Constructor<?> constructor = clazz.getDeclaredConstructor();

constructor.setAccessible(true);

for (int i = 0;i < 10;i++){

Object instance = constructor.newInstance();

System.out.println(instance);

}

}

上诉代码,我们主要是利用当前SingleTon的字节码对象,通过获取无参构造器,使用无参构造器的方式创建了10个对象。

下面呢,我们看一下这10个对象是否能够被创建出来,或者这10个对象创建出来内存地址是否会发生改变。

测试代码如下:​​​​​​​

public static void main(String[] args) throws Exception {

destoryByReflect();

}

下面呢,我们看一下运行结果:

我们通过上面的测试,可以看出通过暴力反射完全可以忽略咱们的private修饰符,直接调用构造器创建咱们的对象,对象每调用一次构造器,肯定就会分配新的内存,从而导致每次的对象不同。所以说,使用暴力反射的方式,咱们的单例模式同样也会遭到破坏,同样会玩不转。

那么接下来我们来谈一下解决方式,其实我们发现,不管是new对象还是通过反射创建对象调用的始终都是当前类的构造器,咱们new对象的时候是因为可以受到private修饰符的限制,利用这一特性可以切断外部创建对象的途径,进而达到抑制对象创建的效果。但是咱们对暴力反射而言,所有的修饰符都没什么作用,故而导致我们外部可以无限访问到构造器,进而导致无数个对象都能被创建出来。所以说呢,我们既然修饰符不起作用,我们依然可以在构造器上做文章。

我们完全可以转换思路,在构造器加入判断,定义一个成员变量,比如叫做isCreated的静态布尔变量,初始值设置为false,表示咱们的SingleTon这个类还没有创建出一个对象。当咱们第一次调用构造方法时,重点判断isCreated这个变量的值就可以了,如果为true就表示咱们的SingleTon类已经创建出来一个对象了,直接抛出异常即可,反之让构造器正常执行即可,同时需要将isCreated设置为true。代码如下:​​​​​​​

private static boolean isCreated = false;

private SingleTon(){

synchronized (SingleTon.class){

if (isCreated){

throw new RuntimeException("当前已经存在唯一对象,创建对象失败...");

}

isCreated = true;

}

}

对构造器来说,因为涉及到多个操作,在多线程下同样存在线程安全问题,所以我们需要对其加锁用于解决线程同步问题。

下面呢,我们再使用执行反射创建对象的方法,看一下是否还能肆无忌惮的创建无数个对象。

执行结果如下:

可以看出咱们的效果还是可以的,通过构造器限制完美的解决了咱们反射破坏单例模式的问题。

以上呢,就是常见的两种单例模式会被破坏的问题,咱们再进行一下总结:

第一种就是对象序列化会引起单例模式失效,主要原因在于调用对象流的readObject()方法时底层会调用反射的方式调用构造器重新创建新的对象,进而导致咱们每次调用readObject()方法一次,就会重新创建新的对象,从而导致单例模式会被破坏。咱们解决的方式是查看readObject()这个方法的源码,发现底层早都为咱们考虑到了,通过在序列化对象中实现readResolve()方法可以定义咱们自己的逻辑进而改变默认的方式,从而解决了咱们单例模式被破坏,每次都创建对象的难题。

第二种就是通过暴力反射可以无视咱们的private修饰符,咱们采取的方式:既然不可避免让其能够访问构造器,咱们不妨就在构造器内部代码做文章,定义一个变量来控制当前构造器是否正常执行,如果能执行说明是第一个对象,反之则直接抛出异常,让其异常终止。

三、单例模式的应用

在咱们Java整个技术栈中,单例模式作为一种比较经典和优秀的设计模式,使用的也是比较多。下面呢,我们主要看一下几种底层使用到单例模式思想的源码。

3.1Runtime类

Runtime类是JDK内部为咱们提供的一个类,咱们可以通过这个类来模拟Windows的cmd命令,进而可以在Java代码中控制程序指令的执行。在该类中,创建对象就使用到了单例模式,咱们不妨看看源码,如下所示:

在Runtime类源码之中,我已经把单例模式的核心代码进行了标记。可以看出该类的构造器是私有化的,也就表示咱们外部无法通过new对象的方式进行创建对象。其次呢,在该类中,创建了一个本类的静态实例对象,并且是在成员变量的位置直接进行了初始化,可以看出跟咱们讲解的饿汉式几乎完全一样。最后提供了一个公共且静态供外部访问该实例的方法,也就是getRuntime()方法,就相当于咱们SingleTon类提供的getInstance()方法一般。

接下来了,咱们通过Runtime这个类提供的getRuntime()方法获取该类对应的两个实例,看一下它们的内存地址是否一致,如果相等则说明该类同样是使用的是单例模式的思想:

测试代码如下:​​​​​​​

package com.ignorance;

public class RuntimeTest {

public static void main(String[] args) {

Runtime runtime01 = Runtime.getRuntime();

Runtime runtime02 = Runtime.getRuntime();

System.out.println(runtime01);

System.out.println(runtime02);

System.out.println(runtime01 == runtime02);

}

}

下面呢,我们可以看一下运行结果:

通过上面的测试结果来看,咱们的程序跟咱们预期的结果一致,说明咱们的Runtime类使用的就是咱们单例模式的思想进行对象的创建的。

3.2Spring Ioc中Bean对象的创建

学习Java的小伙伴,学习Spring框架是必不可少的,大家也都知道Spring最强大的功能之一就是将咱们对象的创建完全交给容器,在咱们Spring创建Bean的过程,其中就使用到了单例模式的思想。下面呢,咱们写一个小的Spring程序,步骤主要如下:

第一步:创建User对象,作为咱们IOC容器待加入的Bean​​​​​​​

package com.ignorance.spring;

public class User {

private Long id;

private String name;

private String gender;

private String tel;

public User(Long id, String name, String gender, String tel) {

this.id = id;

this.name = name;

this.gender = gender;

this.tel = tel;

}

public User() {

}

}

第二步:创建Spring的核心配置文件applicationContext.xml,用于IOC容器的构建​​​​​​​

<?xml version="1.0" encoding="UTF-8"?>

<beans xmlns="http://www.springframework.org/schema/beans"

xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"

xsi:schemaLocation="http://www.springframework.org/schema/beans

http://www.springframework.org/schema/beans/spring-beans.xsd">

<bean id="user" class="com.ignorance.spring.User" scope="singleton"></bean>

</beans>

这个配置文件我相信不用做过多的解释,大家应该都懂,在Spring的核心配置文件中,一个bean标签对应IOC中的一个对象,咱们IOC容器底层使用到的是HashMap结构,以id属性对应的值作为key,class属性值表示当前bean所在的类路径,底层获取到这个类路径的时候会通过反射的方式会创建出当前bean的一个实例。比如:Class.forName("com.ignorance.spring.User").newInstance();然后将这个对象作为value加入到IOC容器,第三个属性scope就是用来指定咱们当前的bean对象的创建方式,默认是singleton,表示创建对象的方式使用的是单例模式,同样还会存在其他的,咱们将其修改为singleton就是加强对单例模式的重视。

下面呢,我们调用测试方法,多次获取IOC容器中的user对象,咱们不妨看一下多次获取的user对象是否是同一个内存地址。测试方法如下:​​​​​​​

package com.ignorance.spring.test;

import com.ignorance.spring.User;

import org.junit.Test;

import org.springframework.context.ApplicationContext;

import org.springframework.context.support.ClassPathXmlApplicationContext;

public class SpringIOCTest {

@Test

public void testIOC(){

ApplicationContext iocContext = new ClassPathXmlApplicationContext("applicationContext.xml");

User user1 = iocContext.getBean(User.class);

User user2 = iocContext.getBean(User.class);

System.out.println(user1);

System.out.println(user2);

System.out.println(user1 == user2);

}

}

下面呢,我们查看一下测试结果:

通过测试结果来看,咱们两次从IOC容器中获取到的user对象均是同一个内存地址,也充分的说明在咱们Spring IOC底层是使用到单例模式这种思想来创建对象的。

那么接下来呢,我们知道了Bean的创建使用到了单例模式,下面呢,我们不妨看一下getBean()这个方法的源码。如下图所示:

可以看出在getBean()方法内部,会进行判断当前指定的参数是否是单例,如果是的话就会调用doGetSingleton()这个方法进行创建对象。下面呢,我们不妨就进入doGetSingleton()这个方法的内部看一下它的逻辑。

可以看出在这个方法中,会调用singletonObjects的get()方法返回一个对象,会进一步判断当前对象是不是单例对象,不是的话就直接报错,反之直接返回即可。

通过以上的两个案例,可以看出单例模式这种思想还是比较重要的,像咱们一些耳熟能详的技术,比如Spring、数据库连接池、线程池等底层都使用到了单例模式这种思想来创建咱们的对象。

总结

在本篇文章中,咱们主要讲解了学习设计模式的必要性以及咱们用到它的一些常用场景。其次呢,讲解了咱们第一种相对比较简单大家几乎都听说过或者接触过的设计模式,也就是单例模式,相当于对咱们的设计模式之旅做了一个开头。

单例模式的核心是让一个类产生的对象只有一个,它的实现原理就是我们以上重点提出的两点,构造器私有化,提供公共的访问方法。然后针对单例模式的两种,即懒汉式以及饿汉式展开了详细的分析与讲解。

除此之外,咱们也讲解了单例模式并不是绝对安全的,通过对象序列化以及暴力反射都可以进行破坏,破坏的原理以及解决方式在本篇文章中咱们也进行了比较详细的讲解,相信大家理解了单例模式实现的基础上,单例模式的安全性问题以及解决方法也能很快的熟悉。

最后呢,咱们也主要介绍了一些源码中使用到单例模式的场景,一个是Runtime类,还有就是咱们Spring框架中IOC容器创建Bean的方式都可以发现咱们单例模式思想使用的足迹。

在本篇文章最后作者想告诉大家,单例模式很重要,尤其是懒汉式中的双重判断法以及静态内部类实现方式,对咱们的理解能力还是需要一定的要求,所以呢,希望同学们一定要重点掌握这两种。一方面呢是面试经常问,其次更重要的是能够提升咱们的内功心法,让咱们的Java或者编程之路走得更远更稳。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值