只会懒汉式和饿汉式 你还不懂单例模式!

Posted 黑马程序员官方

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了只会懒汉式和饿汉式 你还不懂单例模式!相关的知识,希望对你有一定的参考价值。

只会懒汉式和饿汉式 你还不懂单例模式!

一.文章导读

设计模式是每一位技术人员都应该掌握的技术,但是现在根据实际情况来看,大家对于设计模式也仅仅限于面试八股文,知其然不知其所以然。

你说设计模式很难吧,其实也没有,你说它很简单吧,但也没有那么简单。对于一个持续开发蓝图,不能支持持续更新迭代的系统注定对于每一任的开发者都是折磨,只能不停的在原有的代码上加上的新的内容从而摇摇欲坠。

如何才能持续的拥抱变化? 使用设计模式!

二.什么叫做设计模式?

设计模式(Design Pattern),简称DP,是一套具体的理论,通过代码进行体现,由软件界的先辈们总结出的一套可以利用的经验。

提高代码的可重用性,增强系统的维护性,及解决一系列的复杂问题。要注意的是设计模式并不是某一些具体的代码,而是通过代码来进行体现的一种思想。

对于开发者而言,分析现有的需求,预测可能发生的改变,但是我们不能控制需求的变更,无法控制变更,那么就需要去拥抱变化。

三.设计模式的分类

创建型模式:提供创建对象的机制,增加已有代码的灵活性和可复用性。

结构型模式:将类和对象组装成较大的结构,并同时保持结构的灵活和高效。

行为模型:负责对象间的高效沟通和职责委派。

四.单例模式的概述

单例模式是面试中问的较多也是最容易在代码中体现的一种设计模式,它是创建型设计模式中的一种。

主要的目的就是保证一个类在全局只有一个实例,并提供一个访问到该实例的全局节点。

在刚刚对于单例模式的作用中可以看出,单例模式解决了两个问题,所以说违反了单一职责原则。

1.单例模式可以解决的问题(1)

保证一个类只有一个实例,控制某些共享资源(数据库/文件)的访问权限,或者说某一个对象中的属性内容特别多,每次构建的时候都要进行赋值,而且都是相同内容赋值的时候,可以将此对象变为单例的,全局共同访问一个相同的对象使用即可。

2.单例模式可以解决的问题(2)

为该实例提供一个全局访问的方式,全局可以在任何地方访问到该实例对象。

五.单例模式的体现形式

1.单例模式(饿汉式)

1.1 单例模式(饿汉式)设计思路

1.1.1 单例模式(饿汉式)的基本设计(1)-解决可以重复创建的问题

如果有一个类叫做Singleton,想要保证全局只有一个该类对象,应该怎么做?

最应该做的就是让别人不创建该类对象,因为一旦有任何一个人创建了该对象,那么全局对象就不唯一了,张三new了一个Singleton对象,李四new了一个Singleton对象就乱套了,但是我们不能禁止别人new对象,解决方式可以将构造函数定义为私有,这个样子就可以防止别人通过new创建对象。

public class Singleton 
    //1.在本类中维护一个私有构造方法
    private Singleton() 

但是这么写本质上防止不了使用者通过构造方法访问此类并获取构造方法取消检查权限的问题,之后可以通过别的方式解决。

1.1.2 单例模式(饿汉式)的基本设计(2)-提供全局可以访问的公共方式
public class Singleton 
    //1.在本类中维护一个私有构造方法
    private Singleton() 

    

    //2.提供一个全局可以访问的静态方法用于获取到本类对象
    public static Singleton getInstance() 
        return new Singleton(); //本类中可以访问私有构造方法
    

可以发现写到这里,虽然提供了公共的访问方式,但是每次访问此静态方法都会创建一个新的Singleton对象返回,所以不符合单例的特性,解决方案就是在本类中封装了一个私有的静态的本类成员变量,然后直接进行初始化,在方法中直接返回本类成员变量即可。

public class Singleton 
    //1.在本类中维护一个私有构造方法
    private Singleton() 
    

    //2.封装一个静态私有本类成员变量并初始化.
    private static Singleton singleton = new Singleton();

    //2.提供一个全局可以访问的静态方法用于获取到本类对象
    public static Singleton getInstance() 
        return singleton; //返回本类成员变量
    

代码编写到这里,饿汉式的单例模式就已经编写完成了,现在在全局任意类都可以通过Singleton.getInstance();获取到本类的唯一对象了。

对于饿汉的理解就是,这个类很"饥饿",在类被加载的时候就创建好了唯一的单例对象,不是在真正需要用这个类的时候才创建。

如果单例对象的构造比较的复杂,需要进行很多的数据封装,但是使用的频率不高,其实没有必要在类加载的时候就进行创建。那么也就延伸出了另外一种的单例模式的分类也就是懒汉式,但是饿汉式在多线程下是否可以获取同一个对象?是否还会有其他问题呢?

1.2 单例模式(饿汉式)的多线程安全问题

单例模式(饿汉式)在单线程环境下获取始终可以获取到同一个对象,多线程环境下可以通过如下代码进行测试:

public class SingletonThread 

    private static final Integer CORE_POOL_SIZE = 20; //核心线程数
    private static final ThreadPoolExecutor POOL = new ThreadPoolExecutor(CORE_POOL_SIZE, CORE_POOL_SIZE, 0, TimeUnit.MILLISECONDS, new LinkedBlockingDeque<>()); //初始化线程池

    public static void main(String[] args) 
        for (int i = 0; i < 30; i++) 
            //使用循环提交30次线程任务进行Singleton对象的唯一对象获取并打印.
            POOL.submit(() -> System.out.println(Singleton.getInstance()));
        
        POOL.shutdown();
    

通过以上代码执行,可以获取到的结果是:

并没有出现多个线程获取到的对象不唯一的问题,因为在Singleton类加载的时候类加载的初始化阶段就已经创建好了Singleton对象,真正执行多线程代码获取的时候,已经存在了一个有具体指向Singleton对象,所有线程获取到的都是已经创建好的同一个对象。这是类加载过程中加载静态内容的特点。

1.3 单例模式(饿汉式)的反射安全问题

之前在编写代码的时候已经将类的构造方法私有化了,但是并不能防止反射,因为反射可以获取到任意一个类的任意内容并且执行,在反射环境下可以编写如下代码来执行单例类的私有构造创建多个对象。

package com.itheima.hm;

import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;

public class SingletonReflect 
    public static void main(String[] args) throws ClassNotFoundException, NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException 
        //获取要反射的单例类Singleton的class对象
        Class<?> singletonClass = Class.forName("com.itheima.hm.Singleton");
        //由于该类有一个私有的无参构造方法,通过反射的方式获取到表示该构造方法的Constructor对象并取消检查权限
        Constructor<?> privateNoArgsConstructor = singletonClass.getDeclaredConstructor();
        privateNoArgsConstructor.setAccessible(true);
        //反射执行构造方法2次获取两个对象
        Singleton sOne = (Singleton) privateNoArgsConstructor.newInstance();
        Singleton sTwo = (Singleton) privateNoArgsConstructor.newInstance();
        System.out.println("第一次反射获取到的Singleton对象是:" + sOne);
        System.out.println("第二次反射获取到的Singleton对象是:" + sTwo);
    

执行结果:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-9oUpj0Rr-1660125298581)(img\\image-20220620225539814.png)]

单例模式(饿汉式)的反射攻击解决方案

对于反射破坏单例模式,其实核心就是在于如何让反射不能成功调用私有无参构造,这个由于反射特性无法阻止,但是我们在私有无参构造中进行判断,如果当前的Instance为NULL,则正常调用,如果为不为NULL,则直接抛出异常,也算某种程度上解决了反射破坏单例。

import java.util.Objects;

public class Singleton 
    //1.在本类中维护一个私有构造方法
    private Singleton() 
        if (Objects.nonNull(singleton))
            throw new RuntimeException(this.getClass().getName() + "已存在全局唯一实例!");
    

    //2.封装一个静态私有本类成员变量并初始化.
    private static Singleton singleton = new Singleton();

    //2.提供一个全局可以访问的静态方法用于获取到本类对象
    public static Singleton getInstance() 
        return singleton; //返回本类成员变量
    

如果再运行反射代码反射该类的构造方法,则会直接抛出运行期异常。

但是在这里要说一句,毕竟反射是人为的编写代码,本质上是可防的!

1.4 单例模式(饿汉式)的序列化安全问题

Java的序列化机制允许将实现序列化的Java对象转换成字节序列,这些字节序列可以被保存在磁盘上,或者通过网络传输。

对于单例模式我们可以将该类获取到的全局对象转换为字节序列保存到文件中,序列化特点:只保存对象的信息,但是不保存对象的地址。当我们再将对象反序列化读取到内存中的时候会重新构建一个该对象,但是不是使用原有地址,所以可能会导致内存中会出现两个指向不同地址的单例类对象,破坏了单例模式。

序列化攻击的前提是单例类实现了Serializable接口(否则会出现运行期异常),通过代码序列化对象到文件中再反序列化出来。

import java.io.*;

public class SingletonSerializable 
    public static void main(String[] args) throws IOException, ClassNotFoundException 
        //获取一个用于序列化到文件中的对象
        Singleton singletonOne = Singleton.getInstance();
        writeObjectToFile(singletonOne);

        //调用反序列化方法获取读取到的对象
        Singleton singletonTwo = readObjectFromFile();
        System.out.println("序列化到文件中的对象的地址值是:" + singletonOne + ",对象的类型是:" + singletonOne.getClass());
        System.out.println("从文件中读取到的对象的地址值是:" + singletonTwo + ",对象的类型是:" + singletonTwo.getClass());
    

    public static Singleton readObjectFromFile() throws IOException, ClassNotFoundException 
        //获取一个反序列化流并绑定当前模块下的obj文件
        ObjectInputStream ois = new ObjectInputStream(new FileInputStream("chapter-01-singleton\\\\obj"));
        //从文件中反序列化对象到内存中(由于已经明确了文件中的对象是Singleton所以直接向下转型)
        return (Singleton) ois.readObject();
    

    public static void writeObjectToFile(Singleton singleton) throws IOException 
        //获取一个序列化流并绑定目的地为当前模块下的obj文件
        ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("chapter-01-singleton\\\\obj"));
        oos.writeObject(singleton);
        oos.close();
    

得到的结果如下:

我们能看到的就是序列化进去的对象和反序列化出来的对象的地址值不一样,无论内容是否相同,已经破坏了单例模式,在全局中出现了两个对象。

那么如何对序列化单例模式问题进行解决呢?

通过对ObjectInputStream源码的观察和运行过程我们可以得到以下的过程:

实际上反序列化最核心的内容,就是在反序列化的过程中会判断要反序列化出来的对象所在的类总是否存在一个readResolve方法,如果有的话,则反序列化的过程会调用此方法将此方法的返回值作为readObject方法的返回值,如果没有才会通过反序列化类的逻辑进行对象的反序列化。

解决方案:在单例类中创建一个readResolve方法,并将全局唯一对象返回。

import java.io.Serializable;
import java.util.Objects;

public class Singleton implements Serializable 
    //1.在本类中维护一个私有构造方法
    private Singleton() 
        if (Objects.nonNull(singleton))
            throw new RuntimeException(this.getClass().getName() + "已存在全局唯一实例!");
    

    //2.封装一个静态私有本类成员变量并初始化.
    private static Singleton singleton = new Singleton();

    //2.提供一个全局可以访问的静态方法用于获取到本类对象
    public static Singleton getInstance() 
        return singleton; //返回本类成员变量
    

    //3.声明readResolve方法作为反序列化的readObject方法的返回值,将唯一对象返回。
    public Object readResolve()
        return singleton;
    

再次尝试运行测试类,即可获取到同一个对象。

1.5 单例模式(饿汉式)Unsafe的安全问题

Unsafe类是Java中提供的类似于C++可以手动操作内存的类,从名字上来看这个类的使用是极其不安全的。但是这个类也提供了对于一个类不通过构造函数直接进行对象创建的功能,也可以用于破坏单例模式。

1.5.1 Unsafe类的获取

Unsafe类并不是核心类库中的类,而是拓展包sun.misc中的类,而且此类的构造私有,不能直接获取,所以有以下两种的获取方式:

Unsafe类通过反射的方式进行获取

通过反射Unsafe类的私有构造进行Unsafe对象的创建。

public static void main(String[] args) throws Exception 
    //1.获取Unsafe类的Class对象后获取到私有构造方法取消检查权限后创建对象
    Class<?> unsafeClass = Class.forName("sun.misc.Unsafe");
    Constructor<?> unsafePrivateConstructor = unsafeClass.getDeclaredConstructor();
    unsafePrivateConstructor.setAccessible(true);
    Unsafe unsafe = (Unsafe) unsafePrivateConstructor.newInstance();

Unsafe类通过反射获取成员变量的方式进行获取

Unsafe类中有一个成员变量叫做theUnsafe,维护的就是一个Unsafe的对象,通过反射也可以进行获取。

 public static void main(String[] args) throws Exception 
     //1.获取Unsafe类的Class对象后获取到私有成员变量(theUnsafe)取消检查权限后获取成员变量值
     Class<?> unsafeClass = Class.forName("sun.misc.Unsafe");
     Field theUnsafeField = unsafeClass.getDeclaredField("theUnsafe");
     theUnsafeField.setAccessible(true);
     Unsafe unsafe = (Unsafe) theUnsafeField.get(null);
 

Unsafe类通过Spring工具类的方式进行获取

Spring框架中提供了一个UnsafeUtils的工具类,通过静态方法getUnsafe也可以获取到一个Unsafe对象(需要导入spring-core核心包)。

public static void main(String[] args) throws Exception 
    Unsafe unsafe = UnsafeUtils.getUnsafe();

1.5.2 单例模式(饿汉式)的Unsafe类的内存攻击

Unsafe类中提供了一个allocateInstance方法,该方法可以传递要创建对象的类的class对象进行对象创建,而且不会通过本类的构造方法,直接在内存中进行创建。

import org.springframework.objenesis.instantiator.util.UnsafeUtils;
import sun.misc.Unsafe;

public class SingletonUnsafe 
    public static void main(String[] args) throws Exception 
        //获取Unsafe对象
        Unsafe unsafe = UnsafeUtils.getUnsafe();
        //通过Unsafe类的allocateInstance方法传递要创建对象的单例类即可获取到创建出来的对象.
        Singleton sOne = (Singleton) unsafe.allocateInstance(Singleton.class);
        Singleton sTwo = (Singleton) unsafe.allocateInstance(Singleton.class);
        System.out.println("通过allocateInstance获取到的第一个Singleton对象是:" + sOne);
        System.out.println("通过allocateInstance获取到的第二个Singleton对象是:" + sTwo);
    

1.5.3 单例模式(饿汉式)的Unsafe类的内存攻击解决方案

Unsafe类破坏单例无法通过代码层面预防,因为这本质上就是直接操作内存而且不通过构造方法的一种方式,但代码是人写的,总体是可控的。

2.单例模式(枚举)

2.1 单例模式(枚举)的概述

其实通过对于懒汉式的单例模式编写,核心的点有三个,(1)构造方法私有,(2)维护一个私有静态的本类成员变量,(3)对外提供一个公共静态方法用于获取该类对象。

但是直接编写这种单例类的方式,不仅仅需要预防反射攻击,还需要编写readResolve方法预防反序列化,而Java为我们提供了一种方式可以更完美的解决单例模式的困扰,这就是枚举,通过枚举的方式编写需要全局单例的类,不仅在多线程的环境下是安全的,而且也可以防止反射和序列化攻击。

单例模式(枚举)的编写方式

枚举类的声明需要通过public enum 类名进行声明,然后在第一行声明一个本类的单例对象名称即可。

其他的可以和之前的一样,编写本类的成员变量,成员方法,但是要注意的是不需要编写任何的构造方法(因为枚举类中不建议主动声明构造,就算声明了也默认是私有的)

public enum Singleton 
    SINGLETON; //声明一个本类的对象名称

    //不需要声明任何成员变量

    //声明成员方法
    public void show() 
        System.out.println("Singleton类的show方法执行了!");
    

    //重写toString是因为默认父类Enum的toString会打印该对象名称无法看到十六进制哈希值,重写后逻辑为打印十六进制哈希值.
    @Override
    public String toString() 
        return "类名称" + this.getClass().getName() + " 地址值:" + Integer.toHexString(hashCode());
    


在其他类中获取该类的唯一对象的时候可以直接通过枚举类型.枚举项名进行访问即可获取到一个对象直接使用。

public class SingletonTest 
    public static void main(String[] args) 
        //通过枚举类型.枚举项名可以获取到唯一对象.
        System.out.println("Singleton类的唯一对象是:" + Singleton.SINGLETON);
        Singleton.SINGLETON.show(); //调用成员方法
    

2.2 单例模式(枚举)的底层原理

当枚举类编译之后,虚拟机会通过枚举的内容的将枚举类变为一个具体.class文件,.class文件中的内容可以反编译为以下代码(重点部分)

public class Singleton 
    //构造方法
    private Singleton(String name, int oridinal) 
        this.name = name;
        this.oridinal = oridinal;
    
	
    //静态成员变量
    public static final Singleton SINGLETON;

    static 
        SINGLETON = new Singleton("SINGLETON", 0);
    

    public void show() 
        System.out.println("Singleton类的show方法执行了!");
    

    public String toString() 
        return "类名称" + this.getClass().getName() + " 地址值:" + Integer.toHexString(hashCode());
    

(1)枚举类编译之后会生成一个私有的有参构造方法,用于传递枚举对象的名称和枚举对象的编号。

(2)会维护一个本类的静态对象成员变量,成员变量的名称就是枚举项的名称。

(3)有一个静态代码块,会在本类加载的时候执行,用于给静态变量进行初始化。

(4)在枚举类的其他的成员方法也会一并在.class文件中出现。

我们通过阅读编译后的代码可以发现,实际上枚举类变为.class文件,.class文件被加载的时候就会给类中的成员变量执行静态代码块进行初始化。

我们使用枚举类名.枚举项名进行对象的获取其实本质上就是获取枚举类的静态成员变量,变量的数据类型就是该类本身,也就相当于获取到了一个本类对象。

2.3 单例模式(枚举)的线程安全问题

在查看完枚举类编译之后的源码,通过多线程代码获取唯一对象是不会出现问题,原因还是在于在获取对象的代码执行前,枚举类的对象就已经在静态代码块中初始化完毕了。

public class SingletoThread 
    private static final Integer CORE_POOL_SIZE = 20; //核心线程数
    private static final ThreadPoolExecutor POOL = new ThreadPoolExecutor(CORE_POOL_SIZE, CORE_POOL_SIZE, 0, TimeUnit.MILLISECONDS,
            new LinkedBlockingDeque<>()); //初始化线程池

    public static void main(String[] args) 
        for (int i = 0; i < 30; i++) 
            //使用循环提交30次线程任务进行Singleton对象的唯一对象获取并打印.
            POOL.submit(() -> System.out.println(Singleton.INSTANCE));
        
        POOL.shutdown();
    


所以多线程情况下枚举解决单例模式是没有任何问题的。

2.4 单例模式(枚举)的反射安全问题

反射可以获取任意一个类的组成部分,调用该类的方法。对于枚举类编译后的.class文件中也提供了一个私有的有参构造(不会生成无参构造),是否可以通过反射有参构造来进行枚举类对象的创建呢? 实际上是不可以的,Java对于枚举类的反射有预防机制,当发现反射要创建的是一个枚举类的对象的时候,会触发错误机制,抛出运行期异常。

public class SingletonReflect 

以上是关于只会懒汉式和饿汉式 你还不懂单例模式!的主要内容,如果未能解决你的问题,请参考以下文章

单例设计模式中的懒汉式和饿汉式

[转]设计模式--单例模式懒汉式和饿汉式

关于Java单例模式中懒汉式和饿汉式的两种类创建方法

Java单例模式--------懒汉式和饿汉式

单例--项目中用到单例的地方

JAVA中的单例设计模式-分为懒汉式和饿汉式(附代码演示)