当前位置:网站首页>只会懒汉式和饿汉式 你还不懂单例模式!

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

2022-08-10 23:47:00 黑马程序员官方

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

一.文章导读

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

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

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

二.什么叫做设计模式?

设计模式(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 {
    
    public static void main(String[] args) throws Exception {
    
        //手动加载枚举类到虚拟机中生成class对象并反射获取有参构造取消检查权限
        Class<?> singletonEnumClass = Class.forName("com.itheima.singleton.enumerate.Singleton");
        Constructor<?> singletonEnumArgsConstructor = singletonEnumClass.getDeclaredConstructor(String.class, int.class);
        singletonEnumArgsConstructor.setAccessible(true);
        //调用构造方法传递参数完成对象的创建
        Singleton singleton = (Singleton) singletonEnumArgsConstructor.newInstance("TEST", 1);
        System.out.println("通过反射枚举类构造方法创建出来的对象是:" + singleton);
    }
}

在这里插入图片描述

此种预防机制保证了无法通过反射创建枚举对象,保证枚举类中的枚举项对象的全局唯一性。

2.5 单例模式(枚举)的序列化安全问题

发现反射不可以创建枚举类对象之后,那么在没有编写readResolve方法的前提下能否通过序列化破坏枚举的单例呢? 实际上也是不可以的,因为Java对于枚举类对象的序列化与反序列化同样有预防机制,当发现反序列化的是一个枚举对象的时候,会使用内存中存在的唯一对象进行返回。

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

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

    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();
    }
}

在这里插入图片描述

2.6 单例模式(枚举)的Unsafe安全问题

那么最后一种Unsafe方式是否可以创建一个枚举对象呢? 这种方式是可以的。因为Unsafe可以直接对内存进行操作。不需要通过Java的校验机制。

public class SingletonUnsafe {
    
    public static void main(String[] args) throws InstantiationException {
    
        Unsafe unsafe = UnsafeUtils.getUnsafe();
        //通过Unsafe直接构建一个内存中的枚举对象.
        Singleton singleton = (Singleton) unsafe.allocateInstance(Singleton.class);
        System.out.println("通过Unsafe创建的枚举对象是:" + singleton);
        System.out.println("直接获取的枚举类的枚举对象是:" + Singleton.INSTANCE);
    }
}

在这里插入图片描述

2.7 单例模式(枚举)的总结

虽然对于Unsafe操作枚举破坏了单例模式,但是这个类毕竟功能强大,可以对内存直接操作,Java在本身的语言机制层面对于保证枚举类的枚举项全局唯一做了很多努力,无法通过反射或者序列化破坏也保证了线程安全。

3.单例模式(懒汉式)

3.1 单例模式(懒汉式)的出现原因

学习了单例模式(饿汉式)的代码之后已经可以设计出一个保证全局对象唯一的类了,为什么还要学习另外一种懒汉式呢?

饿汉式的核心思想是无论是否使用这个类的唯一对象,在类加载的时候就将对象初始化完成等待使用,很“饥饿”,不能等待,但是如果单例类中的的成员很多,构造过程复杂,会耗费很多时间,一时半会又用不上这个类的对象,那么上来就完成加载会浪费系统的资源,不如等到真正使用的时候再进行初始化,很“懒散”,这也是单例模式的第二种思想体现。

3.2 单例模式(懒汉式)的设计思路

虽然变为了懒汉式但是核心思想没有改变,依旧三板斧(1)构造私有 (2)维护本类对象成员变量 (3)对外提供获取方法。

基于饿汉式代码的懒汉式代码修改(本类成员变量初始化方式)

首先懒汉式是在获取该对象的时候才进行对象的初始化,所以原本的成员变量在声明的时候就完成初始化的过程删除掉。

//封装一个静态私有本类成员变量并赋值为NULL(不指向任何堆内存)
private static Singleton singleton = null;

基于饿汉式代码的懒汉式代码修改(获取对象方法的逻辑)

饿汉式的getInstance方法中直接返回了对象,但是懒汉式的是在真正调用这个方法的时候才进行对象的初始化的,所以要在方法调用的时候进行初始化,但是每次调用的时候不一定都要初始化,singleton对象不为NULL的的情况下直接返回对象,为NULL才进行初始化。所以要在方法中加一个判断。

public static Singleton getInstance() {
    
    if (Objects.isNull(singleton)) {
    
    	singleton = new Singleton();
    	return singleton; //如果本类成员变量singleton为NULL进行初始化后返回
    }
    return singleton; //如果不是NULL,直接返回本类对象
}

那么修改完成之后,构造方法依然私有。

编写测试类进行测试。

public class SingletonTest {
    
    public static void main(String[] args) {
    
        Singleton sOne = Singleton.getInstance();
        Singleton sTwo = Singleton.getInstance();
        System.out.println("第一次获取到的singleton对象是:" + sOne);
        System.out.println("第二次获取到的singleton对象是:" + sTwo);
    }
}

在这里插入图片描述

获取到的两个对象同一个,通过代码的执行流程分析,是在调用getInstance方法的时候才会完成初始化。

3.3 单例模式(懒汉式)的线程安全问题

在多线程环境下,懒汉式会出现线程安全问题,饿汉式不会出现安全问题的原因在于它的对象在类加载的时候就已经初始化完成了,而懒汉式会出现问题的原因就在于是在获取对象的时候才完成初始化。

单例模式(懒汉式)的线程安全问题代码

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();
    }
}

执行代码结果:

在这里插入图片描述

通过结果可以看到,通过多线程获取到的对象出现了2个(其实可以出现更多个不同对象,在此分析原因以出现了2个对象举例)

3.4 单例模式(懒汉式)的线程安全问题原因分析

分析原因之前,先明确一个前提就是当前单例类中的getInstance方法没有加锁,所以可能导致的结果就是一个线程在执行方法的过程中Cpu会选择其他的线程执行,而当前执行的线程等待。

明确了前提之后,来分析一下多个线程执行getInstance方法可能会出现的问题。

(1)红色线程(线程A)执行getInstance方法,进入到方法中,判断当前的singleton为NULL,则进入到if代码块中准备执行初始化操作。

在这里插入图片描述

(2)在红色线程还没有执行singleton = new Singleton()操作之前,Cpu选择了蓝色线程(线程B)执行,则线程A在初始化代码前等待执行,线程2此时判断当前的singleton为NULL。

在这里插入图片描述

(3)线程B执行了singleton = new Singleton()代码;在堆内存中创建了对象并且生成地址值赋值给sinleton,然后返回。

例:生成的对象的地址值是0xa,则本次线程B的getInstance方法的返回值对象指向的0xa地址。

在这里插入图片描述

(4)线程A在线程B执行完成之后继续执行,又执行了一次singleton = new Singleton()代码,在堆内存中又创建了一个新的Singleton对象并返回。

例:生成的对象的地址值是0xb,则本次线程A的getInstance方法的返回值对象指向0xb地址。

在这里插入图片描述

其实通过问题的分析可以明确,多线程懒汉式获取对象最大的问题在于,当一个线程进入到if代码块中,还未完成初始化操作的时候,此时的对象依然为NULL,其他的线程获取到执行权,通过判断也可以进入到代码块中,会完成多次对象的初始化操作。

3.5 单例模式(懒汉式)的线程安全问题解决(1)同步方法的好处和弊端

对于线程安全问题,解决方案有悲观锁(synchronized代码块/synchronized方法/Lock锁)以及乐观锁等,对于当前getInstance方法线程不安全,直接给getInstance方法添加一个synchronized关键字,让它变成同步方法,任意时刻只能有一个线程进行执行可以解决线程安全问题。

public synchronized static Singleton getInstance() {
    
    if (Objects.isNull(singleton)) {
    
        singleton = new Singleton();
        return singleton; //如果本类成员变量singleton为NULL进行初始化后返回
    }
    return singleton; //如果不是NULL,直接返回本类对象
}

测试情况:

在这里插入图片描述

通过同步方法可以解决线程安全问题,但是弊端就是:加锁范围太大,对于性能影响比较大,因为每一次线程调用该方法都要需要等锁,获取锁,释放锁。所以这种方法就是捡了芝麻丢了西瓜,大多数情况都不可取。

3.6 单例模式(懒汉式)的线程安全问题解决(2)同步代码块的好处与陷阱

既然同步方法加锁范围大,就需要缩小范围,那么核心的地方在于只需要在创建对象的时候才加锁,如果对象已经创建完成就不需要同步。

可以使用同步代码块来指定加锁的范围,如果当前单例类的对象为NULL,才加锁执行,如果不为NULL,可以不执行同步代码块直接返回唯一的对象。

public static Singleton getInstance() {
    
    if (Objects.isNull(singleton)) {
    
        synchronized (Singleton.class) {
    
            singleton = new Singleton();
            return singleton; //如果本类成员变量singleton为NULL进行初始化后返回
        }
    }
    return singleton; //如果不是NULL,直接返回本类对象
}

以上这种写法是大多数同学思考过的结论,看上去没有任何问题,如果为NULL,才获取锁进行对象初始化操作,不为NULL,不执行同步代码块。

但实际上这就是单例模式中的陷阱,这个代码虽然加锁了但是依然有问题,通过画图来进行分析。

(1)线程A执行getInstance方法,进入到方法中,判断当前的singleton为NULL,则进入到if代码块中获取到锁,准备执行。

在这里插入图片描述

(2)在线程A还未执行singleton = new Singleton()代码的时候,Cpu选择了线程B进行执行,线程B进入到getInstance方法中,先进行判断,这时的singleton依然为NULL,所以线程2可以进入到if代码块中执行,但是由于锁已经被线程A获取到了,线程2会在同步代码块外阻塞等待。

在这里插入图片描述

(3)线程A继续执行,完成singleton的初始化操作,例:创建了一个地址值为0xa的singleton对象后返回,那么当前的singleton已经不为NULL了,本次线程A获取到的单例对象指向的地址值为0xa,并且线程A执行完成后释放锁。

在这里插入图片描述

(4)线程A释放锁之后,线程B获取到锁继续执行(注意:此时线程B已经不需要再判断singleton是否为NULL了,因为已经在获取锁前判断过了),线程B直接执行了singleton = new Singleton()完成初始化并返回。

例:本次线程B创建的对象的地址值是0xb,那么线程B调用getInstance方法获取到的单例对象指向的地址值就是0xb。

在这里插入图片描述

我们可以发现,虽然的确在代码中加了同步代码块锁,锁对象也是全局唯一的,但是依然会出现线程安全问题(获取到不同的单例对象),核心的地方在于只要判断了当前的唯一对象为NULL,就可以等待执行同步代码块了,即使之前的线程已经初始化完成了,但是当前线程依然会再进行一次初始化操作。

这种方式并没有解决多线程获取对象唯一的安全问题。

3.7 单例模式(懒汉式)的线程安全问题解决(3)同步代码块的双检锁

通过上面这种同步代码块的编写方式,好处明确就是缩小加锁范围,提高系统性能,陷阱在于加锁的范围如果大,那和同步方法没什么区别,加锁的范围如果小,又会造成安全问题,那么如何解决呢? 使用双检锁写法。

双检锁模式的核心就是在于,在初始化创建对象之前要经过两次判断,同步代码块前判断一次对象是为NULL,同步代码块中再判断一次对象是为NULL,保证执行初始化代码的时候单例对象一定为NULL,再进行初始化。

public static Singleton getInstance() {
    
    if (Objects.isNull(singleton)) {
    
        synchronized (Singleton.class) {
    
            if (Objects.isNull(singleton)) {
    
                singleton = new Singleton();
                return singleton; 
            }
        }
    }
    return singleton; 
}

这种方式可以解决懒汉式的线程安全问题,也可以保证加锁的范围小不会影响性能,代码执行的情况模拟如下:

(1)线程A经过第一次判断singleton为NULL后获取到锁进入到同步代码块中进行第二次判断,singleton依然为NULL,线程A准备执行初始化代码。

在这里插入图片描述

(2)在线程A未执行初始化代码之前,Cpu选择了线程B执行,线程B经过第一次判断singleton为NULL后,进入到第一个if代码块中,在同步代码块前由于没有锁,阻塞等待。

在这里插入图片描述

(3)线程A继续执行,执行singleton = new Singleton(),完成单例对象的初始化,例:线程A创建的singleton对象的地址是0xa,那么本次线程A的getInstance方法获取到的单例对象就是0xa这个地址的对象,执行完成返回后释放锁。

在这里插入图片描述

(4)线程B继续执行,获取到锁之后进入到同步代码块中,再进到if判断中,判断此时的singleton是否为NULL,此时的singleton已经不为NULL了,而是0xa这个对象,那么线程B不会执行singleton = new Singleton()代码,直接释放锁之后,执行最后的return singleton;也就是线程B的getInstance方法返回的单例对象依然是0xa。

在这里插入图片描述

通过加入双检锁的判断,保证了singleton = new Singleton();代码执行前需要经过两次判断。维护了线程安全。

3.8 单例模式(懒汉式)的优化(可见性和有序性)

在双检锁的懒汉式编写完成之后,对于懒汉式的单例模式还需要有最后一步优化的地方,就是给单例模式的类中维护的唯一成员变量加一个修饰符volatile

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

    //2.封装一个静态私有本类成员变量并赋值为NULL(不指向任何堆内存)
    private volatile static Singleton singleton = null;

    //2.提供一个全局可以访问的静态方法用于获取到本类对象
    public static Singleton getInstance() {
    
        if (Objects.isNull(singleton)) {
    
            synchronized (Singleton.class) {
    
                if (Objects.isNull(singleton)) {
    
                    singleton = new Singleton();
                    return singleton; //如果本类成员变量singleton为NULL进行初始化后返回
                }
            }
        }
        return singleton; //如果不是NULL,直接返回本类对象
    }
}

那为什么要添加volatile关键字呢? 这里就要说明一下getInstance方法底层的执行逻辑,也就是singleton = new Singleton();的逻辑。

通过javap命令对单例类进行反编译,查看方法的执行逻辑的时候,里面有这三个步骤(重要)

javap -c -v -p Singleton.class

在这里插入图片描述

(1)new命令 : 在内存中分配空间用于保存对象(只分配了空间,还未完成对象的初始化)

(2)invokespecial命令 : 调用单例类的构造方法,完成空间中单例对象的数据的初始化

(3)putstatic命令 : 将生成的空间地址赋值给成员变量

也就是说只有这三个步骤都执行完了,单例对象就初始化完毕了,可以进行使用了,正常的执行顺序如下图所示:

在这里插入图片描述

但是Java虚拟机在执行步骤没有必要关联关系的情况下可能会对指令进行重排序,重排序后执行对结果没有任何影响。没有必要关联关系就是指先执行谁再执行谁都可以。所以可能会产生如下的情况:

在这里插入图片描述

有可能会先将地址值返回给对象引用,再完成对象数据在内存中的初始化,其实这里重排序在单线程情况下没有任何影响,因为最终对象引用都指向了空间,空间中有对象的数据。
但是以上的情况在多线程中就可能出现问题,如图所示:

(1)线程A执行singleton = new Singleton()命令的三个步骤已经完成了在重排序的情况下完成了前两个,也就是说当前singleton已经有了具体的堆内存地址值,已经不为NULL了。

在这里插入图片描述

(2)在三个步骤还差一个步骤完成初始化的时候,线程B被Cpu选择执行,线程B判断singleton是否为NULL,此时由于线程A中的三个步骤重排序已经完成了前两个步骤,singleton不为NULL(但是空间数据未初始化),就会直接返回0xa这个对象。

在这里插入图片描述

通过上图所示,可能线程2返回的singleton对象有引用,但是空间中的数据没有完成初始化,使用的时候就可能会出现问题。

虽然不违反单例模式的特点,但是在使用的时候会出现问题,例如:未完成初始化,对象里面的数据无法使用。

那么加上了volatile关键字之后的特点是什么,其实就是一个禁止指令重排序,当禁止了重排序之后,必须按照开辟空间,数据初始化,赋值的三个步骤执行。

3.9 单例模式(懒汉式)的总结

所谓懒汉式和饿汉式的区别其实就是在于对象的初始化时机不同,饿汉式初始化时机为类的加载中的初始化阶段,所以线程安全,但是过早的初始化唯一对象,如果过程复杂可能会浪费系统性能,懒汉式体现为懒加载,只有在真正要使用这个对象的时候才进行初始化,线程不安全的,对于线程安全需要进行额外的双检锁以及禁止重排序的操作。

具体在开发中使用哪种方式可以根据单例对象构造的复杂性来判断,如果构造过程并不复杂可以使用饿汉式或者枚举的方式,如果构造过程复杂需要耗费时间,可以使用懒汉式节约系统性能。

4.内部类懒汉式

4.1 内部类懒汉式的概述

饿汉式保证线程安全的方式是因为Java中对于静态内容的初始化会在代码编译完成之后的静态代码块中,对于静态代码块的执行,Java虚拟机会保证线程安全。

懒汉式又可以懒加载,保证用到这个单例类对象的时候才进行加载。

那么有没有既可以在使用的时候才进行加载,又可以在静态代码块中完成初始化让Java虚拟机保证线程安全的方式呢,其实这就是单例模式(内部类懒汉式)。

4.2 内部类懒汉式的特点

内部类懒汉式的特点就是将单例对象的初始化过程放到了内部类中,特点:外部类用到了内部类才会进行初始化操作,不用不加载。

其次内部类中维护一个外部类的静态对象,当内部类加载的时候,会把静态内容的初始化会放到静态代码块中的执行,保证了线程安全。

4.3 内部类懒汉式的编写方式

编写外部类

将要全局对象唯一的类编写为外部类,构造方法私有(不让外界访问)。

public class Singleton {
    
    //私有构造方法
    private Singleton() {
    
    }
}

编写内部类

在外部类中编写一个私有静态内部类,私有的目的是不让外界访问,静态的目的是因为之后外部类要通过静态方法访问内部类的成员,所以内部类必须是静态的才能够访问到。

私有静态内部类的声明

public class Singleton {
    
    //私有构造方法
    private Singleton() {
    
    }

    private static class Inner {
    
       
    }
}

私有静态内部类的成员

在私有静态内部类中声明一个静态成员变量,并且完成初始化操作(此处虽然在声明的时候就已经完成了初始化,但是只有当内部类被使用的时候才会加载,所以不是饿汉式,而是懒汉式。)

因为内部类可以访问到外部类的内容,所以可以访问到私有的构造方法。

private static class Inner {
    
	static Singleton singleton = new Singleton();
}

外部类中提供获取唯一对象的方式

外部类提供提供一个静态方法用于返回唯一对象,在方法中使用内部类名直接访问静态成员变量即可获取到对象。

public class Singleton {
    
    //私有构造方法
    private Singleton() {
    
    }

    private static class Inner {
    
        static Singleton singleton = new Singleton();
    }

    public static Singleton getInstance() {
    
        return Inner.singleton;
    }
}

4.4 内部类懒汉式的总结

该种方式是懒汉式的推荐方式,不需要担心线程安全问题,不需要加双检锁和禁止指令重排序的关键字,通过静态内容的初始化会在静态代码块中由Java虚拟机保证线程安全的方式来进行对象的初始化。

六. 单例模式的总结

单例模式是面试中常问的一种设计模式,只了解过懒汉和饿汉的基本写法,不足以应对面试官的要求。

在此将所有的单例模式的情况一一列举,如果是饿汉式推荐使用枚举的方案,如果是懒汉式推荐使用内部类的方案。

}

}


私有静态内部类的成员

在私有静态内部类中声明一个静态成员变量,并且完成初始化操作(<font color=blue>此处虽然在声明的时候就已经完成了初始化,但是只有当内部类被使用的时候才会加载,所以不是饿汉式,而是懒汉式。</font>)

因为内部类可以访问到外部类的内容,所以可以访问到私有的构造方法。

```java
private static class Inner {
	static Singleton singleton = new Singleton();
}

外部类中提供获取唯一对象的方式

外部类提供提供一个静态方法用于返回唯一对象,在方法中使用内部类名直接访问静态成员变量即可获取到对象。

public class Singleton {
    
    //私有构造方法
    private Singleton() {
    
    }

    private static class Inner {
    
        static Singleton singleton = new Singleton();
    }

    public static Singleton getInstance() {
    
        return Inner.singleton;
    }
}

4.4 内部类懒汉式的总结

该种方式是懒汉式的推荐方式,不需要担心线程安全问题,不需要加双检锁和禁止指令重排序的关键字,通过静态内容的初始化会在静态代码块中由Java虚拟机保证线程安全的方式来进行对象的初始化。

六. 单例模式的总结

单例模式是面试中常问的一种设计模式,只了解过懒汉和饿汉的基本写法,不足以应对面试官的要求。

在此将所有的单例模式的情况一一列举,如果是饿汉式推荐使用枚举的方案,如果是懒汉式推荐使用内部类的方案。

对于不同的方案的优缺点要牢记于心,一定要下去练习写出来。

原网站

版权声明
本文为[黑马程序员官方]所创,转载请带上原文链接,感谢
https://blog.csdn.net/itcast_cn/article/details/126271565