设计模式 - 单例模式

发布于:2025-06-21 ⋅ 阅读:(17) ⋅ 点赞:(0)

单例模式是设计模式中的一种,确保一个类只有一个实例,并且提供一个全局访问点。

在软件开发中,单例模式的应用场景和重要性取决于具体的需求和上下文

一、前言

1. 需要控制使用单例模式的场景

1)资源管理场景

  • 数据库连接管理:在很多应用中,数据库连接是一种有限的资源。如果频繁的创建和销毁数据库连接,会消耗大量的系统资源,并且可能导致性能问题。单例模式可以创建一个数据库连接池。
  • 线程池管理:线程的创建和销毁也有一定的开销。在多线程应用中,入一个高并发的服务器应用程序,通过单例模式创建一个线程池,可以合理的分配和复用线程资源。这样可以避免因线程过多而导致的系统资源耗尽,同时也能提高任务的执行效率。

2)共享资源访问场景

  • 日志记录:日志记录器需要在多个地方被调用,用于记录系统的运行状态、错误信息等。如果每个地方都创建一个新的日志记录器实例,可能会导致日志信息混乱。难以统一管理和分析。通过单例模式,可以确保整个应用中只有一个记录器实例。
  • 配置管理:应用的配置信息(如数据库配置、服务器地址等)通常在启动时加载一次,然后在整个应用运行期间被多次使用。使用单例模式可以创建一个配置管理器,它在第一次被访问时加载配置信息,之后每次访问都返回同一个配置管理器实例。这样可以避免重复加载配置信息,提高效率。

3)全局状态管理场景

  • 用户会话管理:在一些 Web 应用中,用户会话信息(如用户的登录状态、权限等)需要在整个应用中共享。通过单例模式,可以创建一个用户会话管理器,它存储和管理所有用户的会话信息。
  • 缓存管理:缓存可以提高系统的性能,减少对后端资源(如数据库)的访问。通过单例模式创建一个缓存管理器,可以确保整个应用中只有一个缓存实例。

2. 单例模式的重要性

单例模式在软件开发中是重要的,但也需要谨慎使用,其重要性体现在以下几个方面:

1)资源优化

  • 单例模式可以频繁的创建和销毁对象,从而节省系统资源。

  • 对于一些重量级的对象(如线程池、日志记录器等),单例模式可以确保这些对象只被创建一次,避免了不必要的资源浪费。

2)全局访问便利性

  • 单例模式提供了一个全局访问点,方便在应用的各个地方获取和使用对象。例如,配置管理器的单例可以让开发者在代码的任何位置都能方便地获取配置信息,而不需要每次都传递配置对象的引用。

  • 在多模块的复杂系统中,单例模式可以简化模块之间的通信和协作。例如,用户会话管理器的单例可以让不同的模块(如用户界面模块、业务逻辑模块等)都能方便地访问和更新用户的会话信息。

3)状态一致性

  • 单例模式可以保证对象的状态一致性。例如,在缓存管理中,单例模式可以确保缓存数据在应用的各个地方都是一致的。如果缓存有多个实例,可能会导致缓存数据不一致的问题,从而影响系统的正确性。

  • 在共享资源访问场景中,单例模式可以避免多个实例对共享资源的竞争和冲突。例如,对于一个文件系统资源管理器,单例模式可以确保对文件系统的访问是有序的,避免多个实例同时对文件系统进行写操作而导致数据损坏。

然而,单例模式也有一些缺点,比如它可能会导致代码的耦合度增加,因为其他模块依赖于单例对象。此外,单例模式可能会隐藏系统的依赖关系,使得系统的结构不够清晰。因此,在使用单例模式时,需要根据具体的应用场景和需求来权衡其利弊,合理地使用它。


二、饿汉模式

饿汉模式是在初始阶段就主动进行实例化,并时刻保持一种渴求状态,无论此单例是否有人使用。

场景示例☀️:

  • 太阳系里面只有一个太阳,我们需要严格把握太阳实例化的过程。
  • Sun类代表太阳类,我们只需要一个太阳实例。

    1. 设计

    1. 从单例的角度,我们在进行代码设计时,必须满足以下条件

    • private构造方法:任何人都不能创建太阳的实例,禁止外部调用构造器
    • static实例保证自有永有:将太阳放入内存静态区,在类加载的时候初始化,与类同在。(与类同时期并早于堆中的对象实例化的,该实例在内存中永生,内存垃圾回收器也不会将其回收)
    • 静态getInstance:获取太阳的单例对象,同时设置为public暴露给外部使用

    2. 建议满足条件

    • 使用final关键字修饰单例对象,确保对象在初始化后不被修改:
      • 确保对象不被重新赋值:确保在对象创建后,该引用不会指向另一个对象。这有助于防止在程序运行时意外地改变单例对象的引用。
      • 提高代码可读性与可维护性:使用final关键字可以明确地告诉其他开发者,这个对象是不可变的,从而提高代码的可读性和可维护性
      • 防止多线程的并发问题:在多线程环境中,如果多个线程同时访问单例对象的创建代码,可能会导致多个实例被创建。使用final关键字可以确保对象在初始化后不会被修改,从而避免并发问题

    2. 实现代码

    实现代码如下:

    public class Sun {
        private static final Sun sun = new Sun(); // 自有永有的太阳单例
        private Sun() { // 构造方法私有化
        }
        
        public static Sun getInstance() { // 阳光普照,方法公开化
            return sun;
        }
    }

    以上代码使用了final关键字修饰单例对象,确保对象在初始化后不被修改。

    1. 确保对象不被重新赋值:通过将单例对象声明为final,可以确保在对象创建后,该引用不会指向另一个对象。这有助于防止在程序运行时意外地改变单例对象的引用。

    2. 提高代码的可读性和可维护性:使用final关键字可以明确地告诉其他开发者,这个对象是不可变的,从而提高代码的可读性和可维护性。

    3. 防止多线程环境下的并发问题:在多线程环境中,如果多个线程同时访问单例对象的创建代码,可能会导致多个实例被创建。使用final关键字可以确保对象在初始化后不会被修改,从而避免并发问题

    但是实际上,在饿汉式单例模式中,final关键字并不是绝对必须的。即使不使用final关键字,饿汉式单例模式也可以正常工作,因为单例对象在类加载时就已经创建好了,并且构造方法是私有的,外部无法通过new关键字创建新的实例。因此,即使没有final关键字,单例对象也不会被意外地修改或替换。


    三、懒汉模式

    刚刚饿汉模式,使太阳从一开始就准备就绪,随时供应免费日光。

    然而,如果始终没人获取日光,那岂不是白造了太阳,一块内存区域被白白的浪费了?

    这正类似于商家货品滞销的情况,货架上堆放着商品却没人买,白白浪费空间。因此,商家为了降低风险,规定有些商品必须提前预订,这就是“懒汉模式”(lazyinitialization)。

    相较饿汉模式优劣:

    1. 无请求就不实例化,节省内存空间

    2. 第一次请求的时候,速度较之前的饿汉初始化模式慢,因为要消耗CPU资源去临时造太阳。

    1. 设计

    1. 我们一开始并没有造太阳,所以去掉关键字final

    2. 需要避免,多线程情况下反复的对太阳进行多次赋值(覆盖)操作,违背单例理念。(可以加一个synchronized同步锁,让其同步,这样以来,某线程调用前必须获取同步锁,调用完后会释放锁给其他线程用。也就是给请求排队,一个接一个按顺序来)

    这样可以避免多线程陷阱,但是请注意:

    • 线程还没进入到方法内部便不管三七二十一直接加锁排队,会造成线程阻塞,资源与时间被白白浪费。
    • 为了实例化一个单例对象,不至于如此“兴师动众”,使用synchronized让所有请求排队等候。所以,要保证多线程并发逻辑的正确性,同步锁一定要加的恰到好处,其位置是关键所在。

    2. 加synchronized同步锁

    public class Sun {
        private static Sun sun; // 这里不进行实例化
    
        private Sun() { // 构造方法私有化
        }
    
        public static synchronized Sun getInstance() { // 此处加入同步锁
            if (sun == null) { // 如果无日才造日
                sun = new Sun();
            }
            return sun;
        }
    }

    3. 双重校验锁 - 增加volatile关键字

    关键词volatile可用于解决多线程情况下,变量可见和有序性问题。

    因此可以在volatile的基础上,在内部加一个双重校验锁:

    public class Sun {
        private volatile static Sun sun; // 声明一个私有的静态变量,使用volatile关键字
    
        private Sun() { // 私有化构造方法
        }
    
        public static Sun getInstance() { // 获取实例的方法
            if (sun == null) { // 如果实例为空,则进入同步块
                synchronized(Sun.class) { // 同步代码块,锁住当前类对象
                    if (sun == null) { // 再次检查实例是否为空,防止多线程环境下的重复创建
                        sun = new Sun(); // 创建实例
                    }
                }
            }
            return sun; // 返回实例
        }
    }

    我们一共用了两个嵌套的判空逻辑,这就是懒加载模式的“双检锁”:

    • 外层放宽入口,保证线程并发的高效性
    • 内层加锁同步,保证实例化的单词运行

    如此里应外合,不仅达到了单例模式的效果,还完美的构建过程的运行效率,一举两得。


    四、大道至简

    相比“懒汉模式”,我们在大多数情况下通常会更多地使用“饿汉模式”,原因在于这个单例迟早是要被实例化占用内存的,延迟加载的意义并不大,加锁反而是一种资源浪费,同步更是会降低CPU的利用率,使用不当的话反而会带来不必要的风险。越简单的包容性越强,而越复杂的反而越容易出错。

    除了”饿汉式“与“懒汉式”这两种单例模式,其实还有其他的实现方式。但是万变不离其宗,它们统统是由这两种模式发展、衍生而来的。

    实际上,Springboot框架中的IOC容器帮助我们很好的托管了业务对象,如此我们就不需要手动去实例化这些对象了,而在默认情况下,我们使用的正是框架提供的“单例模式”。诚然,究其代码实现当然不止如此简单,但我们应该追本溯源,抓住其本质的部分,理解其核心的设计思想,再针对不同的应用场景做出相应的调整与变动,结合实践举一反三。

    -- 秒懂设计模式学习笔记

    -- 单例模式


    网站公告

    今日签到

    点亮在社区的每一天
    去签到