【设计模式】单例模式:从理论到SpringBoot实战,一篇就够了!

哈喽,各位技术爱好者们!👋
在软件开发的江湖中,设计模式就像前辈们总结的“武功秘籍”,能帮助我们写出更优雅、更健壮、更易维护的代码。今天,我们要聊的是其中最简单、也最常用的一种——单例模式
你可能会问:“单例模式?我听过,但好像一直没搞明白它到底有啥用,或者用的时候总感觉心里不踏实。”
别担心!这篇文章将带你彻底征服单例模式。我们不仅会用大白话讲透它的原理,还会提供可以直接运行的Java代码示例,并深入剖析它在 SpringBoot项目中的真实应用场景。准备好了吗?我们发车!🚀

一、 什么是单例模式?为什么要用它?

1. 概念

想象一下,一个国家只有一个皇帝,一个公司只有一个CEO,一台电脑通常只有一个主屏幕。这些“唯一”的实例,就是单例模式的核心思想。
单例模式:确保一个类只有一个实例,并提供一个全局访问点来获取这个实例。
简单来说,就是通过某种机制,保证在整个应用程序的生命周期内,某个类的对象只会被创建一次,之后所有需要用到这个对象的地方,拿到的都是同一个。

2. 为什么需要它?(优点)

使用单例模式主要有两大好处:

  • 资源共享,节省内存:对于一些需要频繁创建和销毁的对象(如数据库连接池、线程池、缓存等),如果每次用都new一个,会极大地消耗系统资源。单例模式可以保证这些资源只被初始化一次,全局共享,大大提高了性能。
  • 方便统一管理:有些对象,比如全局的配置管理器、日志记录器,我们希望整个应用都使用同一个实例,这样可以方便地进行统一的状态管理和行为控制。比如,你想修改日志级别,只需要修改这一个实例的配置,所有地方的日志行为都会随之改变。

二、 单例模式的几种写法(附代码详解)

在Java中,实现单例模式有多种方式,它们各有优劣,适用于不同的场景。下面我们逐一剖析。

场景设定

假设我们要创建一个 ConfigManager(配置管理器)类,用来管理应用的全局配置。这个管理器在整个应用中应该是唯一的。

1. 饿汉式 - “我饿了,先吃为敬!”

这种方式是最简单的,它在类加载的时候就完成了实例化

/**
 * 饿汉式单例模式
 * 优点:实现简单,在类加载时就完成实例化,避免了线程同步问题。
 * 缺点:如果这个实例从始至终都没被使用过,会造成内存浪费。
 */
public class HungrySingleton {
    // 1. 私有化构造方法,防止外部通过 new 关键字创建对象
    private HungrySingleton() {
        System.out.println("饿汉式单例实例被创建了!");
    }
    // 2. 在类内部创建一个唯一的实例
    // 使用 static 和 final 关键字修饰,确保它在类加载时就创建,并且不可变
    private static final HungrySingleton INSTANCE = new HungrySingleton();
    // 3. 提供一个公共的、静态的访问方法,返回这个唯一实例
    public static HungrySingleton getInstance() {
        return INSTANCE;
    }
    // 示例方法
    public void doSomething() {
        System.out.println("饿汉式单例正在执行任务...");
    }
}

代码详解:

  1. private HungrySingleton():这是单例模式的“灵魂”所在。将构造方法设为 private,就意味着在类的外部,任何人都无法通过 new HungrySingleton() 来创建新的对象,从根本上杜绝了多个实例的产生。
  2. private static final HungrySingleton INSTANCE = new HungrySingleton();:我们在类内部就创建好了这个唯一的实例。static关键字保证了它属于类本身,而不是类的某个对象。final关键字保证了这个引用一旦被赋值,就再也不能指向其他对象,确保了唯一性。由于 static变量的特性,它会在类加载阶段就被初始化。
  3. public static HungrySingleton getInstance():这是外部世界获取这个唯一实例的唯一入口。因为方法是 static的,所以我们可以通过类名直接调用:HungrySingleton.getInstance()
    适用场景:当你的单例对象占用内存不大,并且基本确定会被使用时,饿汉式是一个不错的选择,因为它简单且线程安全。

2. 懒汉式 - “等我需要了再动手!”

懒汉式与饿汉式相反,它延迟了对象的创建,只有在第一次调用 getInstance()方法时,才会去创建实例。

版本一:基础懒汉式(线程不安全!)
/**
 * 基础懒汉式单例模式(线程不安全!)
 * 优点:实现了懒加载,节省了内存。
 * 缺点:在多线程环境下,可能会创建多个实例,破坏单例。
 */
public class LazySingletonUnsafe {
    private LazySingletonUnsafe() {}
    private static LazySingletonUnsafe instance; // 注意,这里没有 final,也没有立即初始化
    public static LazySingletonUnsafe getInstance() {
        // 如果实例为空,才创建
        if (instance == null) {
            instance = new LazySingletonUnsafe();
        }
        return instance;
    }
}

⚠️ 线程安全问题分析:
想象一下,有两个线程A和B,同时调用了 getInstance()方法。

  1. 线程A判断 instance == null,结果为 true
  2. 就在此时,CPU的执行权切换到了线程B!
  3. 线程B也判断 instance == null,结果也为 true
  4. 线程B执行 instance = new LazySingletonUnsafe();,创建了一个实例。
  5. 然后,CPU执行权又切回了线程A。
  6. 线程A接着执行它之前的计划,也执行 instance = new LazySingletonUnsafe();,又创建了一个新的实例!
    灾难发生了!现在我们有了两个 LazySingletonUnsafe的实例,单例模式被彻底破坏。所以,这种写法在多线程环境下是绝对不能用的!
版本二:同步方法懒汉式(线程安全,但性能差)

为了解决线程安全问题,最直接的想法就是加锁。

/**
 * 同步方法懒汉式单例模式(线程安全,但性能较差)
 * 优点:线程安全,实现了懒加载。
 * 缺点:每次获取实例都要加锁,性能开销大。
 */
public class LazySingletonSafeSynchronizedMethod {
    private LazySingletonSafeSynchronizedMethod() {}
    private static LazySingletonSafeSynchronizedMethod instance;
    // 在方法上添加 synchronized 关键字,保证同一时间只有一个线程能进入此方法
    public static synchronized LazySingletonSafeSynchronizedMethod getInstance() {
        if (instance == null) {
            instance = new LazySingletonSafeSynchronizedMethod();
        }
        return instance;
    }
}

代码详解:
通过在 getInstance()方法上添加 synchronized关键字,我们确保了任何时候只有一个线程能执行这个方法。这样,上面的线程安全问题就不存在了。
缺点: 这种方式虽然安全,但代价太高。因为 instance实例只在第一次创建时需要同步,之后每次调用 getInstance(),其实只是简单地返回一个已经存在的对象引用。但 synchronized锁会让所有线程排队等待,极大地降低了并发性能。

3. 双重检查锁定 - “既懒又安全,还高效!”

这是目前公认的最优的懒加载单例实现方式,它结合了懒汉式和同步方法的优点,并克服了它们的缺点。

/**
 * 双重检查锁定单例模式(推荐!)
 * 优点:线程安全,实现了懒加载,并且性能高。
 */
public class DoubleCheckedLockingSingleton {
    private DoubleCheckedLockingSingleton() {}
    // 注意:这里使用了 volatile 关键字!
    private static volatile DoubleCheckedLockingSingleton instance;
    public static DoubleCheckedLockingSingleton getInstance() {
        // 第一次检查(无锁):如果实例已经存在,直接返回,避免不必要的同步开销
        if (instance == null) {
            // 同步代码块
            synchronized (DoubleCheckedLockingSingleton.class) {
                // 第二次检查(有锁):进入同步块后,再次检查实例是否被创建
                // 这是为了防止在等待锁的过程中,其他线程已经创建了实例
                if (instance == null) {
                    instance = new DoubleCheckedLockingSingleton();
                }
            }
        }
        return instance;
    }
}

代码详解:

  1. volatile关键字:这是DCL的精髓之一。new DoubleCheckedLockingSingleton()这个操作并非原子性,它大致分为三步:
    • 分配对象的内存空间。
    • 调用构造函数,初始化对象。
    • instance引用指向分配的内存地址。
      由于JVM的指令重排序优化,步骤2和3的顺序可能颠倒。如果线程A执行到了步骤3,但还没执行2,此时 instance已经不为 null了。如果这时线程B进来执行第一次检查,它会直接返回一个尚未初始化完成的 instance对象,导致程序出错。volatile关键字可以禁止指令重排序,保证操作的有序性,从而避免此问题。
  2. 双重检查
    • 第一重检查(if (instance == null):在同步块之外。绝大多数情况下,实例已经被创建,线程直接进入这个判断,然后返回实例,完全不需要进入同步块,性能极高。
    • 第二重检查(if (instance == null):在同步块之内。当多个线程同时通过第一重检查后,它们会争抢锁。抢到锁的线程进入同步块后,必须再次检查实例是否已被其他线程创建。因为可能在它抢锁的过程中,另一个线程已经创建并释放了锁。这防止了重复创建。
      适用场景:需要懒加载,并且对性能有较高要求的场景。这是最推荐的手动实现单例的方式。

三、 单例模式在SpringBoot项目中的实际应用

讲了这么多理论,终于到了我们最关心的部分:单例模式在 SpringBoot中到底怎么用?
核心答案:Spring IoC容器默认管理的所有Bean都是单例的!
是的,你没听错。Spring框架的核心功能之一就是依赖注入,而它默认的 scope就是 singleton。这意味着,当你在Spring容器中定义一个Bean时,Spring会保证在整个应用上下文中,这个Bean只有一个实例。

1. Spring如何实现单例?

Spring并不是用我们上面写的 DCL那种“手动挡”方式来实现单例的。它利用了自己作为容器的强大控制力。

  • 启动时创建:默认情况下,Spring容器在启动时(ApplicationContext被初始化时),就会通过反射调用Bean的构造方法,创建好所有非懒加载的 singleton Bean,并将它们存放在一个 ConcurrentHashMap中。
  • Map式管理:这个 Map的Key通常是Bean的名称(beanName),Value就是Bean的实例。当你需要使用某个Bean时,Spring会从这个Map中根据Key去查找,找到后直接返回给你。因为Map里只有一个,所以天然就是单例。
    这种“容器管理”的方式比我们自己手动实现要更强大、更灵活,也天然地解决了线程安全问题(因为Bean的创建过程由容器保证同步)。

2. 实际应用场景及示例

场景一:全局配置类

假设我们有一个全局配置,需要从 application.yml文件中读取,并在项目的多个地方使用。
Step 1: application.yml 文件

app:
  config:
    app-name: "My Awesome App"
    version: "1.0.0"
    feature-enabled: true

Step 2: 创建配置属性类
使用 @ConfigurationProperties注解,可以非常方便地将配置文件中的属性绑定到一个类上。

import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Component
@ConfigurationProperties(prefix = "app.config") // 指定配置文件中的前缀
public class AppConfigProperties {
    private String appName;
    private String version;
    private boolean featureEnabled;
    // Getters and Setters...
    public String getAppName() {
        return appName;
    }
    public void setAppName(String appName) {
        this.appName = appName;
    }
    public String getVersion() {
        return version;
    }
    public void setVersion(String version) {
        this.version = version;
    }
    public boolean isFeatureEnabled() {
        return featureEnabled;
    }
    public void setFeatureEnabled(boolean featureEnabled) {
        this.featureEnabled = featureEnabled;
    }
}

Step 3: 在服务中使用这个单例Bean
现在,我们可以在任何需要配置的地方,直接注入 AppConfigProperties,Spring会保证我们拿到的是同一个实例。

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class WelcomeService {
    // Spring会自动注入AppConfigProperties的单例实例
    private final AppConfigProperties appConfig;
    // 推荐使用构造器注入
    @Autowired
    public WelcomeService(AppConfigProperties appConfig) {
        this.appConfig = appConfig;
    }
    public String generateWelcomeMessage() {
        // 从单例配置Bean中获取数据
        if (appConfig.isFeatureEnabled()) {
            return "Welcome to " + appConfig.getAppName() + " (v" + appConfig.getVersion() + ")";
        } else {
            return "Welcome!";
        }
    }
}

优势分析

  • 单例:整个应用共享同一份配置,内存中只有一份 AppConfigProperties实例。
  • 统一管理:如果配置需要更新(比如通过动态配置中心),理论上只需要更新这个单例Bean的状态(虽然Spring的默认单例是只读的,但可以通过扩展实现),所有引用它的地方都会生效。
  • 解耦:业务代码不再需要关心配置文件的具体位置和读取逻辑,直接使用即可。
场景二:工具类或资源管理类

比如,我们想创建一个全局的日期格式化工具类。SimpleDateFormat这个类是非线程安全的,如果在多线程环境下共享同一个实例,会出问题。一个常见的解决方案就是为每个线程创建一个实例,即使用 ThreadLocal。我们可以将这个 ThreadLocal包装在一个单例Bean中。

import org.springframework.stereotype.Component;
import java.text.SimpleDateFormat;
@Component // 声明为Spring管理的Bean,默认就是单例
public class DateFormatHelper {
    // 使用ThreadLocal来保证每个线程都有自己的SimpleDateFormat实例,从而保证线程安全
    private static final ThreadLocal<SimpleDateFormat> dateFormatHolder =
            ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));
    public String format(long timestamp) {
        // 从ThreadLocal中获取当前线程的SimpleDateFormat实例
        return dateFormatHolder.get().format(timestamp);
    }
}

然后在任何需要格式化日期的地方,注入这个 DateFormatHelper即可。

@Service
public class OrderService {
    private final DateFormatHelper dateFormatHelper;
    @Autowired
    public OrderService(DateFormatHelper dateFormatHelper) {
        this.dateFormatHelper = dateFormatHelper;
    }
    public void processOrder() {
        long now = System.currentTimeMillis();
        System.out.println("Order processed at: " + dateFormatHelper.format(now));
    }
}

优势分析

  • 资源管理DateFormatHelper作为单例,统一管理了 SimpleDateFormat的创建和获取逻辑。
  • 线程安全:通过 ThreadLocal巧妙地解决了 SimpleDateFormat的线程安全问题,同时避免了频繁创建对象的开销。
  • 便捷性:任何服务只需注入 DateFormatHelper,无需关心底层实现,即可安全地使用日期格式化功能。

总结

好了,今天关于单例模式的探索之旅就到这里。我们来回顾一下今天的核心知识点:

  1. 核心思想:保证一个类只有一个实例,并提供全局访问点。
  2. 手动实现方式
    • 饿汉式:简单、安全,但可能浪费内存。适用于对象小且必用的场景。
    • 懒汉式:延迟加载,但基础版线程不安全。
    • DCL(双重检查锁定)最优手动实现,兼顾了懒加载、线程安全和性能。记住 volatile关键字!
  3. 在SpringBoot中的应用
    • Spring默认就是单例!我们定义的 @Component, @Service, @Repository等Bean,默认都是单例的。
    • 实现原理:Spring容器在启动时创建Bean并存入 Map中,使用时从 Map中获取。
    • 应用场景
      • 无状态Bean:如工具类、服务类(Service),它们不保存可变的状态,天然适合单例。
      • 全局配置类:如 @ConfigurationProperties类,用于统一管理配置。
      • 资源管理类:如连接池、线程池、缓存等,需要全局共享以节省资源。
        理解单例模式,不仅是为了应付面试,更是为了在日常开发中,能够识别出哪些场景适合使用单例,并能够利用Spring框架的特性,优雅地实现它,从而写出更高质量的代码。
        希望这篇文章能对你有所帮助!如果觉得有收获,别忘了点赞、在看和转发哦!你的支持是我创作的最大动力!我们下期再见!😊