设计模式之单例模式(Java实现)

举报
长路 发表于 2022/11/28 18:09:35 2022/11/28
【摘要】 设计模式之单例模式(Java实现),多种实现方式值得一看

@[toc]

前言

本篇博客是关于单例模式的实现,若文章中出现相关问题,请指出!

对应代码地址:Gitee(demo-exer/ java-Lear23designpatterns)Github(java-demo/ java-Lear23designpatterns)

所有博客文件目录索引:博客目录索引(持续更新)



一、认识单例模式

单例设计模式(Singleton):就是采用一定的方法保证整个软件系统中,对某个类只能存在一个对象实例,取得对象实例不能通过构造器来获取,只能通过一个方法取得实例。

实际应用场景

  • 计算机系统:windows回收站、操作系统中的文件系统、多线池中的线程池、显卡的驱动程序对象、打印机的后台处理程序、应用程序日志对象、数据库的连接池、网站的计数器、web应用的配置对象、应用程序中对话框、系统中缓存等。
  • 现实生活:公司CEO、部门经理
  • J2EE标准:ServletContextServletContentConfig
  • Spring框架:依赖注入bean实例(singleton缺省是饿汉式)、ApplicationContext、数据库连接池等。

优点

  • 保证内存只有一个实例,减少内存开销。
  • 避免对资源多重应用。
  • 设置全局访问点,可以优化和共享资源访问。

缺点

  • 单例模式无接口,扩展困难。若要扩展,除了修改原来代码,没有其他途径,违背开闭原则。
    • 开闭原则:规定对象(类、模块、函数)对于扩展应该是开放的,修改是封闭的。
  • 并发测试中,不利于代码调试。调试过程中,若之前一个线程中代码没有执行完,就不能模拟生成一个新的对象。
  • 单例模式功能代码一般写在一个类中,若功能设计不合理,容易违背单一职责原则。

目的:一个类中只能有一个实例。

实现过程:若是一个类中只能有一个实例,那么其构造器肯定不是公共能够使用的,构造器不能外部使用,获取到实例的方式只能是通过一个静态方法获取,那么其中的实例也应当是静态的。

  • 单例方式也分为懒汉式与饿汉式,他们各自创建实例的时机也是各不相同的,各有优势与缺点,对于使用普通类来创建单例都会存在安全问题(反射造成的),通过使用自定义枚举类(Enum)来创建单例解决反射安全问题!


二、三种实现方式

实现方式一:饿汉式

singleton目录下的Hungry

image-20210317230357890

class Person{
    //定义静态变量,并且进行实例化
    private static Person person = new Person();
	//构造器设置为私有,外界无法通过new来实例化
    private Person() {
    }
    //定义静态方法,方便外部获取实例
    public static Person getInstance(){
        return person;
    }
}
  • 好处:因为本身实例是在类加载时创建的,所以是线程安全的。
  • 坏处:会占用较多的空间


实现方式二:懒汉式(静态属性)

简单实现懒汉式(有线程安全问题)

下面是通过懒汉式方法来获取单例,仅仅做了一个是否为null的判断:

public class Person {
    private Person(){
        System.out.println(Thread.currentThread().getName());
    }

    private static Person PERSON;

    //获取Person类的单例对象
    public static Person getInstance(){
        if(PERSON == null){
            PERSON = new Person();
        }
        return PERSON;
    }

    //多线程来获取单例
    public static void main(String[] args) {
        for(int i = 0;i<10;i++){
            new Thread(()->Person.getInstance()).start();
        }
    }
}

此时提出问题:当多个线程同时调用该方法时是否依旧会获取到一个单例对象?

  • 我们在main方法中使用多个线程调用Person.getInstance()方法,并且在无参构造器中添加一条输出语句,这样我们就能够很清晰的看到调用了几次构造方法!

image-20210314211553397

从结果来看果然在多线程情况下调用获取单例方法通过使用简单判断的方式是不行的,因为在多线程情况下,CPU会给指定线程分配时间片(约100ms)一旦时间片结束,就切换另一个线程执行,所以当我们使用多个线程调用方法时,由于时间片太短,在执行new实例前多个线程已进入到if(PERSON == null)的方法体中,造成多次调用空参构造器,出现了线程安全问题。



同步方法、同步代码块(留有指令重排问题)

我们通过采用同步方法、同步代码块理论上能够解决线程安全问题,不过还存在一个指令重排的情况。

采用同步方法

class Person{

    //静态变量初始为null
    private static Person person = null;
    //构造器为私有
    private Person() {
    }
    //获取person实例(线程同步)
    public static synchronized Person getInstance(){
        if(person == null){
            person = new Person();
        }
        return person;
    }
    
}
  • 好处:延迟对象的创建。
  • 坏处:执行速率慢,因为其整个方法是带锁的,那么多个线程初始调用方法时就会等待锁释放。

同步代码块

class Person{

    private static Person person;

    private Person(){

    }

    public static Person getInstance(){
        //方式一:外面包裹同步代码块
//        synchronized (Person.class){
//            if(person == null){
//                person = new Person();
//            }
//        }

        //方式二:进行双重加锁
        if(person == null){
            synchronized (Person.class){
                if(person == null){
                    person = new Person();
                }
            }
        }

        return person;
    }
}
  • 方式一:效率稍高,不过对于多线程来说需要每次进入到同步代码块中,并且包含了等待的时间,后边的线程需要等待前面的线程开锁了之后才能进(与同步方法一样)。
  • 方式二:效率相对于方式一会提高,采用了双重加锁(即synchronized内外都进行if判断),在synchronized内进行if判断是为了预防创建多个实例,否则依旧会出现线程安全问题。
    • 举例子说明:当线程A进入到synchronized代码块中,此时线程B、C也通过了外层if判断停留在synchronized外等待释放锁,一旦线程Anew完实例释放锁的话,就会又有一个线程再次进入为了预防其再次new实例,所以再次加一个if判断!

问题描述:这里实际上已经解决了多次调用空参构造器创建对象,不过依旧隐藏安全问题即为指令重排情况!见下部分说明!



双重加锁+volatile(解决指令重排)

涉及到Java内存模型,volatile关键字以及CPU指令重排概念

singleton目录下的Lazy类:

image-20210317230343342

指令重排:是指在程序执行过程中, 为了性能考虑, 编译器和CPU可能会对指令重新排序。

  • 举例说明:例如执行a=b+c;d=b+e;两条指令,在汇编中对于一条指令是可以分为很多步骤的,步骤如下:

  • a=b+c:①b加载到寄存器R1;②c加载到寄存器R2;③R1+R2放置到R3;④将R3放置到a;
    d=e+f:⑤e加载到寄存器R4;⑥f加载到寄存器R5;⑦R4+R5放置到R6;⑧将R6放置到d;
    在cpu中为了提升性能会考虑指令重排的方式
    1、不使用指令重排:依次执行顺序①②..④⑤⑥..⑧,其中.表示等待时间(数据加载到内存),也就是说如果不使用指令重排会有等待时间。为了提升性能CPU使用指令重排。
    先介绍下指令重排发生情况:只可能发生在毫无关系的指令之间, 如果指令之间存在依赖关系, 则不会重排。上面例子中③依赖于①②,④依赖于③,而对于①②、⑤⑥则互不依赖,所以可以将⑤⑥移到前面
    2、使用指令重排:执行顺序变为①②⑤③⑥④.⑦⑧,可以看到能够减少了几个时间周期。
    
  • 通过上面的说明我们了解到指令重排是什么了之后就可以去探究使用同步方法依旧留有的问题了。—参考文章Java内存模型与指令重排

Volatile:禁止指令重排序,volatile变量不会被缓存在寄存器或者对其他处理器不可见的地方,在读取volatile类型的变量时总会返回最新写入的值。

问题描述:当线程A执行new操作实际上在底层会执行三个步骤①分配内存空间;②执行构造方法,初始化对象;③将该对象指向这个空间。Java内存模型是允许编译器对操作顺序进行指令重排的,原本顺序是①②③,通过指令重排后变为①③②,若是在执行完③没有执行②时(也就是说先给对象指向了空间,但并没有进行初始化操作)此时线程B进行了if判断,发现不为null,则直接返回,由于此空间没有完成构造还是空的那么就会出现大问题!

解决方案:给对象添加volatile修饰符来禁止指令重排,保证原子性操作来解决该安全问题。

class Person{

    //添加volatile关键字来禁止指令重排解决安全问题,保证可见性
    private volatile static Person person = null;
    //构造器为私有
    private Person() {
    }
    //采用了双重加锁
    public static Person getInstance(){
        if(person == null){
            synchronized (Person.class){
        		//这里是防止一个线程创建实例之后,另一个线程进入同步代码块时没有创建实例进入
                if(person == null){
                    person = new Person();
                }
            }
        }
        return person;
    }
}

这里与之前懒汉式不同的是,这里采用了双重加锁,并且person属性增加了volatile原子性。



实现方式三:内部静态类方式(懒汉式)

通过创建一个静态内部类,在该内部类中静态属性来获取一个Instance实例:

public class Instance {

    private Instance(){}

    public static Instance getInstance() {
        return Holder.instance;
    }

    public static class Holder {
        private static Instance instance = new Instance();
        //可以放开继续测试
//        static {
//            System.out.println("666");
//        }
    }

    public static void main(String[] args) {
        //懒加载:只有当真正去获取实例时才会去触发Holder初始化
        System.out.println(Instance.getInstance());
    }

}

这种方式级为巧妙,很多框架内部都使用这种方式来实现单例模式,只有当我们去调用Holder的instance方法时才会进行Holder中Instance属性的实例化。


三种实现方式存有问题说明

问题描述1:我们通过反射的手段依旧能够破坏上面的单例,通过反射能够取消java语言访问检查,从而能够创建新的实例,我们就拿懒汉式(双重加锁+volatile)下手看看:

public class Person {

    private volatile static Person PERSON;

    private Person(){
        System.out.println(Thread.currentThread().getName());
    }

    public static Person getInstance(){
        if(PERSON == null){
            synchronized (Person.class){
                if(PERSON == null){
                    PERSON = new Person();
                }
            }
        }
        return PERSON;
    }

    //反射来破坏单例类的结构
    public static void main(String[] args) throws Exception {
        //1、通过实例方法来获取
        Person instance = Person.getInstance();
        Constructor<Person> constructor = Person.class.getDeclaredConstructor();
        //取消构造器访问检查
        constructor.setAccessible(true);
        //2、反射来获取实例
        Person person = constructor.newInstance();
        System.out.println(instance == person);

    }
}

image-20210315150234218

  • 可以很明显看到调用了两次空参构造器,也就是说创建了多个实例!

解决方案1:在空参构造器里加上判断

private Person(){
    synchronized (Person.class){
        //当PERSON实例被创建时就抛出异常
        if(PERSON != null){
            throw new RuntimeException("请不要做坏事情");
        }
    }
    System.out.println(Thread.currentThread().getName());
}

image-20210315150520553

  • 在构造器里加上对PERSON实例判断后,再次使用原本的程序测试,我们能够看到果然成功抛出异常了!

问题描述二:若是我不调用Person.getInstance(),其中PERSON就不会被被赋予实例,接着我多次反射不就又能获取多个实例了吗?

public static void main(String[] args) throws Exception {
    Constructor<Person> constructor = Person.class.getDeclaredConstructor();
    constructor.setAccessible(true);
    //反射获取实例1
    Person instance = constructor.newInstance();
    //反射获取实例2
    Person person = constructor.newInstance();
    System.out.println(instance == person);
}

image-20210315150926463

  • 我们能够看到若是PERSON始终为空,依旧能够new出多个实例出来!

解决方案二

针对于问题2,我们再次修改代码,这次并不对于PERSON来进行判断,而是限定构造器仅仅使用一次!

private static boolean flag = false;

private Person(){
    synchronized (Person.class){
        if(!flag){
            flag = true;
        }else{
            throw new RuntimeException("不要企图通过反射多次new实例");
        }
    }
    System.out.println(Thread.currentThread().getName());
}

image-20210315151519419

  • 针对于问题2我们看似解决了其实我们若是在第一个newInstance()实例后通过反射修改对应flag的布尔值为true,则依旧会失效!!!

总结:总而言之通过反射技术,我们创建的单例类使用是存在安全问题的!!!那么如何解决呢,我们可以通过使用枚举类来实现单例!!!



三、枚举类实现单例(解决反射安全问题)

查看源码

为什么我们通过使用枚举类能够实现单例呢?

  • 通过看反射方法newInstance()(Constructor类)的源码。
@CallerSensitive
public T newInstance(Object ... initargs)
    throws InstantiationException, IllegalAccessException,
           IllegalArgumentException, InvocationTargetException
{
    if (!override) {
        if (!Reflection.quickCheckMemberAccess(clazz, modifiers)) {
            Class<?> caller = Reflection.getCallerClass();
            checkAccess(caller, clazz, null, modifiers);
        }
    }
    //如果是枚举类型直接,直接抛出异常,不让创建实例对象
    if ((clazz.getModifiers() & Modifier.ENUM) != 0)
        throw new IllegalArgumentException("Cannot reflectively create enum objects");
    ConstructorAccessor ca = constructorAccessor;   // read volatile
    if (ca == null) {
        ca = acquireConstructorAccessor();
    }
    @SuppressWarnings("unchecked")
    T inst = (T) ca.newInstance(initargs);
    return inst;
}

通过源码我们可以知道对于枚举类型无法使用newInstance来创建实例,也就能够解决单例在反射中的安全问题!


测试过程

single目录下的OwnStudent:这是后来补上去的仅仅是类名不相同而已

image-20210317230323136

自定义枚举类

public enum Student {
    STUDENT;
}

我们来实验一下即可知道了,首先来若是想要通过反射来newInstance首先需要获取Constructor构造器实例,所以我们最起码得先知道其构造器的参数以及类型吧,我们通过反编译Student.class文件来查看一下:

IDEA中的反编译

public enum Student {
    STUDENT;

    private Student() {
    }
}

JDK工具javap进行反编译

image-20210315160600506

③使用jad工具来进行反编译,获取一个java文件如下:

public final class Student extends Enum
{

    public static Student[] values()
    {
        return (Student[])$VALUES.clone();
    }

    public static Student valueOf(String name)
    {
        return (Student)Enum.valueOf(xyz/changlu/Student, name);
    }

    private Student(String s, int i)
    {
        super(s, i);
    }

    public static final Student STUDENT;
    private static final Student $VALUES[];

    static 
    {
        STUDENT = new Student("STUDENT", 0);
        $VALUES = (new Student[] {
            STUDENT
        });
    }
}

测试程序

检测IDEA中的私有无参构造创建实例:

class Test1{
    public static void main(String[] args) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {
        //通过Student的字节码类来获取构造器(空参)
        Constructor<Student> constructor = Student.class.getDeclaredConstructor();
        constructor.setAccessible(true);
        System.out.println(constructor.newInstance());
    }
}

image-20210315161251220

  • 通过检验我们能够知道IDEA中的反编译结果并不正确的!

IDEA中反编译出来的实际上有问题的,在枚举类中创建实例并不是通过无参构造来创建的,我们以jad工具反编译出的结果为准,是通过一个私有的有参构造来创建实例的。

class Test1{
    public static void main(String[] args) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {
        //获取单例
        System.out.println(Student.STUDENT);
        //传入参数获取无参构造
        Constructor<Student> constructor = Student.class.getDeclaredConstructor(String.class,int.class);
        constructor.setAccessible(true);
        System.out.println(constructor.newInstance());
    }
}

image-20210315161849639

这个报错结果才应该出现的情况,与我们之前在newInstance()源码中抛出的异常结果相同!



总结

1、单例模式优点是减少内存系统开销,保证单个类只有一个实例;缺点是扩展困难,并发测试不利于调试,容易违背单一职责原则。

2、实现单例主要有三种方式:①饿汉式(线程安全的);②懒汉式(双重加锁+volatile);③内部静态类。通过反射技术上面三种方式都容易出现安全问题!

3、通过枚举类实现单例来解决反射带来的安全问题,因为在反射技术中newInstance()方法对于枚举类是无法进行实例化的!!!



参考资料

博客学习网站:C语言中文网23种设计模式

视频:【狂神说Java】单例模式-23种设计模式系列 讲述的特别详细,推荐!!!

[1]. Java中Volatile关键字详解

[2]. Java单例模式及开发应用场景 单例的饿汉式、懒汉式(线程安全);懒汉式(双重加锁机制);类级内部类方法

[3]. 设计模式六大原则(一):单一职责原则

[4]. 单例设计模式中的“懒汉式”单例,以及java的双重锁(锁块) 讲述双重锁

[5]. Java内存模型与指令重排 指令重排介绍的很清楚

[6]. JAVA的newInstance()和new的区别(JAVA反射机制,通过类名来获取该类的实例化对象) newInstance: 弱类型。低效率。只能调用无参构造。new: 强类型。相对高效。能调用任何public构造。

[7]. 枚举类型enum例题_使用枚举的正确姿势 其中包含了newInstance()源码针对于枚举类

【版权声明】本文为华为云社区用户原创内容,转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息, 否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。