每日一个设计模式之【单例模式】

本文详细介绍了Java设计模式中的单例模式,包括懒汉式和饿汉式的多种实现方式,如原始懒汉式、线程锁、登记式/静态内部类、双检锁/双重校验锁。分析了每种方式的优缺点、适用场景以及线程安全问题,特别强调了线程安全和性能的平衡。最后,推荐了使用枚举类实现的单例模式,因其安全性及性能的综合优势。
摘要由CSDN通过智能技术生成

每日一个设计模式之【单例模式】

☁️前言🎉🎉🎉

  大家好✋,我是知识汲取者😄,今天给大家带来一篇有关单例模式的学习笔记。众所周知能够熟练使用设计模式是一个优秀程序猿的必备技能,当我们在项目中选择一个或多个合适的设计模式,不仅能大大提高项目的稳健性可移植性可维护性,同时还能让你的代码更加精炼,具备艺术美感

  单例模式是 Java 设计模式中最简单的一种📗,但是你可不能小看单例模式,虽然从设计上来说它比较简单,但是在实现当中你可能还是会遇到很多的坑🙊,同时需要注意的细节也是有很多地。我们不仅要知其所以(如何写),更要知其所以然(为什么怎这么写),所以系好安全带🛃,老司机带你们上车🚗。

老司机

推荐阅读

  • 设计模式导学:🚪传送门
  • 每日一个设计模式系列专栏:🚪传送门
  • 设计模式专属Gitee仓库:✈启程
  • 设计模式专属Github仓库:🚀上路
  • 知识汲取者的个人主页:💓点我哦

🌻单例模式概述

  • 什么是单例模式?

    单例模式(Singleton Pattern)属于创建型模式,是Java中最简单的设计模式之一,单例模式需要保证系统中,应用该模式的这个类永远只有一个实例,即:一个类永远只能创建一个对象

  • 单例模式的作用

    • 保障对象的唯一性,不让系统发生混乱,比如笔记本的任务管理器就是使用了单例模式,否则多个资源管理器同时操作,会让系统混乱,甚至造成系统崩溃
    • 避免资源浪费,无论实例化多少次对象,只加载到内存中一次
    • 提高系统性能,当使用单例模式创建的对象,再次进行访问时,能够直接使用无需再次创建

    ……

    主要解决一个全局使用的类频繁地创建与销毁

  • 单例模式的优缺点

    • 优点

      • 确保对象的唯一性
      • 避免资源资源
      • 提高系统性能
      • 具有较高的可伸缩性。类自己来控制实例化进程,类就在改变实例化进程上有相应的伸缩性
    • 缺点

      • 使用范围狭窄。只适合用于不变的对象,不适合用于变化的对象,单例对象一旦被创建,它的数据就不会发生改变了
      • 难于扩展。由于单例模式中没有抽象层,无法使用继承,因此单例类的扩展有很大的困难
      • 违背面对对象的基本设计原则。单例模式职责过重,不符合单例职责原则的设计要求
      • 滥用单例将带来一些负面问题。如为了节省资源将数据库连接池对象设计为的单例类,可能会导致共享连接池对象的程序过多而出现连接池溢出;如果实例化的对象长时间不被利用,系统会认为是垃圾而被回收,这将导致对象状态的丢失

      ……

  • 单例模式的应用场景

    • 对于需要保障系统中只能存在一个对象的情况,需要使用单例模式,比如电脑上的回收站、资源管理器、Web引用中的计数器

    • 有频繁实例化然后销毁的情况,也就是频繁的new对象,可以考虑单例模式

    • 创建对象时耗时过多或者耗资源过多,但又经常用到的对象,可以考虑单例模式

    • 频繁访问IO资源的对象,例如数据库连接池或访问本地文件

    • 对于配置文件的读取,我们一般可以使用创建一个工具类,使用单例模式来创建对象

    ……

    Spring中的应用:Bean的默认作用域就是单例的 scope="singleton"

  • 单例模式的分类

    在我看来根据单例对象的创建时机可以将单例模式分为两大类:

    • 懒汉式:单例对象在被使用时才被创建(延迟加载)
    • 饿汉式:单例模式在单例类被加载时就被创建
  • 懒汉式和饿汉式比较

    • 懒汉式拥有延迟加载1的特性,能够很好地避免资源的浪费,但是需要考虑线程安全问题
    • 饿汉式拥有预加载2的特性,容易造成资源浪费,但是效率很高,且不用考虑线程安全问题

    两者并没有谁优谁劣,根据具体的使用场景来选择,我们要做到的是掌握他们各自的特点

🌱单例模式的实现

🍀懒汉式

🐳原始懒汉式

原始懒汉式的核心实现需要依靠staticprivate这两个关键字。

备注:原始懒汉式一般都是直接称作懒汉式,这是我对他的命名,主要用来区分其他的懒汉式单例模式。

  • 原始懒汉式的缺点:由于没有加锁,所以存在严重的线程安全问题😨,在多线程下无法正常使用,严格意义上讲这都不算是一种单例模式

  • 原始懒汉式的使用场景:在工作中禁止使用(使用就等着挨骂吧🤣),由于这种方式实现最简单,可以在你自己的一些小项目中使用

  • 原始懒汉式的实现步骤

    • Step1:创建一个类,使用将构造器私有化

      目的:让外部不能通过构造器获取该类的实例化对象,从而保障实例化对象的唯一性

    • Step2:创建一个静态成员变量

      目的:用于存储创建的单例对象

    • Step3:提供一个公开的使用static修饰的get方法

      目的:用于外部获取获取单例对象,get方法需要使用static修饰是因为单例类已经将构造器私有化了,外部无法通过实例化对象来调用get方法

    具体实现见如下代码:

    package com.hhxy.singleton;
    
    /**
     * 原始懒汉式
     * @author ghp
     * @date 2022/9/17
     */
    public class Singleton1 {
        //Step2: 创建一个静态成员变量
        private static Singleton1 instance = null;
    
        //Step1: 私有化构造器
        private Singleton1(){}
    
        //Step3: 提供一个get方法
        public static Singleton1 getInstance() {
            if(instance == null){
                instance = new Singleton1();
            }
            return instance;
        }
    }
    

这种方式是最简但的实现方式,但是存在严重的线程安全问题,主要体现在get方法上。当我们在多线程的场景下使用这种方式,由于线程之间存在一定的时间差,大概率会出现这种情况:当Thread1进入if后,还没有执行instance = new Singleton1();语句,Thread2也跟着进入了if语句中来了,这就导致单例模式的失败!显然在今这种高并发时代,这种场景是十分常见的!

口说无凭,上代码:

测试代码:

    /**
     * 用于创建线程的内部类
     */
    class MyThreadInner{
        /**
         * 不断调用单例类的get方法,获取单例对象
         */
        public void createInstance(){
            new Thread(){
                @Override
                public void run() {
                    System.out.println(Singleton1.getInstance().hashCode());
                }
            }.start();
        }
    }

    /**
     * 测试对象;原始懒汉单例模式
     * 测试目标:原始懒汉单例模式是否存在线程安全
     * 测试结果:存在线程安全
     */
    @Test
    public void Singleton1Test() {
        SingleTest.MyThreadInner myThreadInner = new SingleTest().new MyThreadInner();
        myThreadInner.createInstance();
        myThreadInner.createInstance();
        myThreadInner.createInstance();
    }

测试结果如下:

image-20220922220801395

在使用单元测试时遇到一个小bug,具体请参考:使用单元测试测试多线程时无输出问题的解决方案强烈推荐阅读

🐳使用线程锁

由于原始的懒汉式存在严重的线程安全问题,所以我们对其进行改进:通过给get方法添加一个synchronized修饰,让其变成一个同步方法,这样就能很好地避免线程安全问题了😃

  • 使用线程锁解决的问题:线程安全问题

  • 使用线程锁的缺点性能较低,因为每次调用get方法,都要上锁、解锁很大程度上消耗了时间😔

  • 使用线程锁的懒汉式的使用场景:适用于对效率要求不高,但极为看重线程安全的系统\软件,推荐使用

  • 使用线程锁的懒汉式的实现步骤

    • Step1:创建一个类,使用将构造器私有化

      目的:让外部不能通过构造器获取该类的实例化对象,从而保障实例化对象的唯一性

    • Step2:创建一个静态成员变量

      目的:用于存储创建的单例对象

    • Step3:提供一个公开的使用staticsynchronized修饰的get方法

      目的:用于外部获取获取单例对象,get方法需要使用static修饰是因为单例类已经将构造器私有化了,外部无法通过实例化对象来调用get方法,使用synchronized修饰是为了保障线程安全

    具体实现见如下代码:

    package com.hhxy.singleton;
    
    /**
     *
     * @author ghp
     * @date 2022/9/17
     */
    public class Singleton2 {
        //Step2: 创建一个静态成员变量
        private static Singleton2 instance = null;
    
        //Step1: 私有化构造器
        private Singleton2(){}
    
        //Step3: 提供一个get方法
        public static synchronized Singleton2 getInstance() {
            if(instance == null){
                instance = new Singleton2();
            }
            return instance;
        }
    }
    

关于线程安全的测试:

    /**
     * 用于创建线程的内部类
     */
    class MyThreadInner{
        /**
         * 不断调用单例类的get方法,获取单例对象
         */
        public void createInstance(){
            new Thread(){
                @Override
                public void run() {
                    System.out.println(Singleton2.getInstance().hashCode());
                }
            }.start();
        }
    }

	/**
     * 测试对象:使用线程锁的原始懒汉式
     * 测试目标:测试使用线程锁的原始懒汉式是否存在线程安全
     * 测试结果:线程安全
     */
    @Test
    public void Singleton2Test() {
        SingleTest.MyThreadInner myThreadInner = new SingleTest().new MyThreadInner();
        myThreadInner.createInstance();
        myThreadInner.createInstance();
        myThreadInner.createInstance();
        myThreadInner.createInstance();
        myThreadInner.createInstance();
        myThreadInner.createInstance();
    }

测试结果:

image-20220922225801868

🐳登记式/静态内部类

前面我们使用线程锁虽然解决了线程安全问题,但是却让程序的性能较低,为了解决这一问题,我们使用静态内部类方式对其改进。

主要实现方式:静态内部类

  • 登记式/静态内部类解决的问题:性能较低的问题

  • 登记式/静态内部类的缺点无法解决传参问题,无法防止序列化攻击,由于静态内部类的形式创建单例,故而无法传递参数进去,例如Contxt这种参数

  • 登记式/静态内部类的使用场景:不需要考虑传参,但是需要考虑线程安全、性能

  • 登记式/静态内部类的实现步骤

    • Step1:创建一个类,将构造器私有化

      目的:让外部不能通过构造器获取该类的实例化对象,从而保障实例化对象的唯一性

    • Step2:创建一个静态内部类,在静态内部类中获取单例对象

      目的:使用static修饰内部类,是为了不使用静态内部类对象获取单例对象,避免创建对象导致资源浪费;至于为什么在静态内部类中获取单例对象,这是为了保障懒加载

    • Step3:提供一个公开的使用staticfinal修饰的get方法

      目的:使用final修饰只是为了让方法不被重写(其实这里也可以不使用final修饰,也是有效果的,使用final修饰,会显得你更加专业),使用static修饰,构造器已经私有化了,外部无法通过对象访问get方法

    具体实现代码如下所示:

    package com.hhxy.singleton;
    
    /**
     * 登记式/静态内部类
     * @author ghp
     * @date 2022/9/17
     */
    public class Singleton4 {
        //Step2: 创建一个静态内部类,在静态内部类中获取单例对象
        private static class Singleton4Holder{
            private static final Singleton4 INSTANCE = new Singleton4();
        }
    
        //Step1: 私有化构造器
        private Singleton4(){}
    
        //Step3: 提供一个get方法
        public static final Singleton4 getInstance(){
            return Singleton4Holder.INSTANCE;
        }
    }
    

    测试代码:

    /**
     * 测试序列化、反序列化攻击
     */
    public class Test{
        public static void main(String[] args) throws Exception{
            System.out.println(Singleton4.getInstance().hashCode());
    
            Singleton4 singleton4 = Singleton4.getInstance();
            System.out.println(singleton4.hashCode());
    
            //通过writeObject将对象进行序列化,然后存储到文件中
            ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("tempFile.txt"));
            oos.writeObject(singleton4);
    
            //读取文件中的数据,然后进行反序列化,将数据存储到对象中
            ObjectInputStream ois = new ObjectInputStream(new FileInputStream("tempFile.txt"));
            Singleton4 singleton41 = (Singleton4) ois.readObject();
    
            System.out.println(singleton41.hashCode());
        }
    }
    

    注意事项:使用序列化和反序列化的前提,是必须让Singelton4实现序列化接口,否则会抛异常

    image-20220924145333994

    知识拓展

    可以在Singleton4中添加一个readResolve方法,从而防止序列化反序列化攻击

    备注:这种方式适用于所有的无法防止序列化反序列化攻击的单例模式,前提是必须让单例类实现序列化接口

    //实现readResolve方法可以解决反序列化攻击,反序列化时会检查有没有这个方法,如果有可以调用这个方法返回对象
     private Object readResolve() {
         return Singleton4Holder.INSTANCE;
     }
    

    image-20220924150137869

🐳双检锁/双重校验锁

双检锁/双重校验锁(DCL,double checked locking)不仅效率高,而且线程安全,同时还能够解决传参问题,可以说是懒汉式中最完美的一种方案了,推荐使用😃

双检验所核心实现:volatile synchronized两个关键字的使用。

  • 双检锁/双重校验锁解决的问题:无法给单例对象进行传参、序列化攻击

  • 双检锁/双重校验锁的缺点无法防止反射攻击

  • 双检锁/双重校验锁解决的使用场景:JDK版本大于1.5(因为volatile)

  • 双检锁/双重校验锁解决的实现步骤

    • Step1:创建一个类,将构造器私有化

      目的:让外部不能通过构造器获取该类的实例化对象,从而保障实例化对象的唯一性

    • Step2:创建一个使用volatilestatic修饰的成员变量

      目的:存储创建的单例对象,使用volatile修饰的原因后续详细解说,至于使用static修饰,是因为我们的单例对象是在静态方法中创建的,静态方法中的变量都是静态变量,必须要使用静态变量来存储静态变量

    • Step3:提供一个公开的、使用static修饰的get方法

      目的:为了让外部能够获取单例对象,至于使用static修饰,已经说过很多遍了,在此不在赘述

    具体实现代码如下所示:

    package com.hhxy.singleton;
    
    /**
     * 双重锁/双重校验锁
     * @author ghp
     * @date 2022/9/17
     */
    public class Singleton5 {
    
        //Step2: 创建一个使用volatile和static修饰的成员变量
        private volatile static Singleton5 instance;
    
        //Step1: 将构造器私有化
        private Singleton5(){}
    
        //Step3: 提供一个get方法
        public static Singleton5 getInstance() {
            //第一层锁,提高效率
            if(instance == null){
                //第二层锁,提高线程安全
                synchronized (Singleton5.class){
                    instance = new Singleton5();
                }
            }
            return instance;
        }
    }
    

    测试代码:

    /**
     * 使用反射攻击双重校验锁的单例对象
     */
    public class Test {
        public static void main(String[] args) {
            System.out.println(Singleton5.getInstance().hashCode());
            //1、获取类对象
            Class cls = Singleton5.class;
            Constructor constructor = null;
            try {
                //2、获取构造器对象
                constructor = cls.getDeclaredConstructor();
                //3、暴力反射,无视private修饰
                constructor.setAccessible(true);
                //4、使用构造器对象重新获取一个单例对象
                Singleton5 singleton5 = (Singleton5) constructor.newInstance();
                System.out.println(singleton5.hashCode());
            } catch (Exception e) {
                throw new RuntimeException(e);
            }
        }
    }
    

    测试结果:

    image-20220924140616249

  • 为什么需要使用volatile修饰成员变量

    使用new创建对象,一般在JVM中可以分成3步执行:

    1. 在堆中开辟对象所需空间,分配地址
    2. 根据类加载的初始化顺序进行初始化
    3. 将堆中的内存地址返回给栈中的引用变量

    由于 Java 内存模型允许“无序写入”,JVM为了提高性能,可能会把上述步骤中的 2 和 3 进行重排序。如果不使用volatile修饰,可能出现2,3重排,在多线程下就会产生多个不同的实例

    image-20220924124019067

🍀饿汉式

🐳原始饿汉式

原始饿汉式的核心实现是利用static关键字的特性。

备注:一般而言,原始饿汉模式我们都是直接称作饿汉模式,这里是博主为了区分他和其他饿汉式的区别,就将它命名为原始饿汉式

主要有两种写法:

  • 方式一:在成员变量初始化时进行创建对象。这种方式利用了类加载机制,当类被加载时,类的成员变量会被初始化到内存中,

    后续除非调用set方法,否则成员变量的值是不会发生改变的

  • 方式二:在静态代码块中创建对象。这种方式利用了静态代码块的特点,静态代码块在类被加载时,会被执行且只会被执行一次,然后将静态代码块中的数据加载到内存中,后续访问会直接访问前面加载到内存的数据

  • 原始饿汉式的缺点无法防止序列化攻击、反射攻击

  • 原始饿汉式的使用场景:需要线程安全和高效率,内存充足,推荐使用

  • 原始饿汉式的实现步骤

    • 方式一

      • Step1:创建一个类,将构造器私有化

      • Step2:使用一个static成员变量,用于获取单例对象

        目的:使用static修饰,是为了能够让静态方法访问

      • Step3:提供一个公共的,使用static修饰的get方法

        目的:使用静态方法,是因为我们将类的构造器私有化了,外部无法通过对象.方法名调用get方法,而静态方法可以使用类名.方法名调用get方法

      public class Singleton3 {
          //Step2: 使用静态成员变量获取单例对象(也可以将赋值操作放在构造器中)
          private static  Singleton3 instance = new Singleton3();
          
          //Step1: 将构造器私有化
          private Singleton3(){}
      
          //Step: 提供一个get方法
          public static Singleton3 getInstance() {
              return instance;
          }
      }
      
    • 方式二:(一般常用这种方式读取配置文件)

      • Step1:创建一个类,将构造器私有化
      • Step2:创建一个静态成员变量,用来存储静态代码块创建的单例对象
      • Step3:在静态代码块中创建单例对象
      • Step4:提供一个static修饰的get方法
      public class Singleton3{
          //Step2: 创建一个静态成员变量
          private static Singleton3 instance = null;
      
          //Step1: 将构造器私有化
          private Singleton3(){}
      
          //Step3: 在静态代码块中创建单例对象
          static {
               instance = new Singleton3();
          }
      
          //Step4: 提供一个get方法
          public static Singleton3 getInstance() {
              return instance;
          }
      }
      

知识拓展:类的加载时机

  • 创建类的实例对象时,类会被加载
  • 创建子类实例对象时,父类会被加载
  • 执行类中的main方法时,类会被加载
  • 调用类的静态方法或静态成员变量时,类会被加载
🐳使用枚举类

这种方式是Java之母 Josh Bloch 提倡的方式,它不仅能避免多线程同步问题,而且还自动支持序列化机制,防止反序列化重新创建新的对象,同时还能防止反射攻击,堪称是最为安全的单例模式,唯一的瑕疵就是会浪费内存。

  • 使用枚举类解决的问题:防止序列化攻击、反射攻击

  • 使用场景:对于安全要求很严格的系统,同时JDK要求是1.5以上的版本

  • 实现步骤

    • 创建自己的单例对象:

      public enum Singleton6 {
          //Step1: 创建一个Singleton6对象
          INSTANCE;
          //通过 类名.INSTANCE获取Singleton6对象,然后就能调用该方法了
          public void say(){
              System.out.println("我是枚举类创建的对象");
          }
      }
      

      image-20220924141057155

    • 创建其他类的单例对象(一般都是这么使用的):

      public enum  SingletonEnum {
          //其他类通过 SingletonEnum.INSTANCE.getObj()获取单例对象
          INSTANCE;
          private Object obj = null;
          private SingeltonEnum(){
              obj = new Object();
          }
          public Object getObj(){
              return obj;
          }
      }
      

🌲总结

image-20220924160529043

总的来讲,用的最多的还是【登记式】和【原始饿汉式】,最推荐使用的的是【双重检验锁】和 【使用枚举类实现的饿汉式】,至于这6种单例模式的具体实用场景,读者可以依据它们各自的特点进行选择(●ˇ∀ˇ●)

此致,文章就结束了,如果觉得本文对你有一丢丢帮助的话😄,欢迎点赞👍+评论✍,您的支持将是我写出更加优秀文章的动力O(∩_∩)O

晚安


上一篇:设计模式导学

下一篇:每日一个设计模式之【原型模式】

参考文章

在此致谢


  1. 懒加载是指只有当被调用时才加载到内存 ↩︎

  2. 预加载是指在类的初始化时就已经被加载到内存 ↩︎

评论 10
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

知识汲取者

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值