设计模式【单例模式】
【设计模式】单例模式:从理论到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("饿汉式单例正在执行任务...");
}
}
代码详解:
private HungrySingleton():这是单例模式的“灵魂”所在。将构造方法设为private,就意味着在类的外部,任何人都无法通过new HungrySingleton()来创建新的对象,从根本上杜绝了多个实例的产生。private static final HungrySingleton INSTANCE = new HungrySingleton();:我们在类内部就创建好了这个唯一的实例。static关键字保证了它属于类本身,而不是类的某个对象。final关键字保证了这个引用一旦被赋值,就再也不能指向其他对象,确保了唯一性。由于static变量的特性,它会在类加载阶段就被初始化。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()方法。
- 线程A判断
instance == null,结果为true。 - 就在此时,CPU的执行权切换到了线程B!
- 线程B也判断
instance == null,结果也为true。 - 线程B执行
instance = new LazySingletonUnsafe();,创建了一个实例。 - 然后,CPU执行权又切回了线程A。
- 线程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;
}
}
代码详解:
volatile关键字:这是DCL的精髓之一。new DoubleCheckedLockingSingleton()这个操作并非原子性,它大致分为三步:- 分配对象的内存空间。
- 调用构造函数,初始化对象。
- 将
instance引用指向分配的内存地址。
由于JVM的指令重排序优化,步骤2和3的顺序可能颠倒。如果线程A执行到了步骤3,但还没执行2,此时instance已经不为null了。如果这时线程B进来执行第一次检查,它会直接返回一个尚未初始化完成的instance对象,导致程序出错。volatile关键字可以禁止指令重排序,保证操作的有序性,从而避免此问题。
- 双重检查:
- 第一重检查(
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的构造方法,创建好所有非懒加载的singletonBean,并将它们存放在一个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,无需关心底层实现,即可安全地使用日期格式化功能。
总结
好了,今天关于单例模式的探索之旅就到这里。我们来回顾一下今天的核心知识点:
- 核心思想:保证一个类只有一个实例,并提供全局访问点。
- 手动实现方式:
- 饿汉式:简单、安全,但可能浪费内存。适用于对象小且必用的场景。
- 懒汉式:延迟加载,但基础版线程不安全。
- DCL(双重检查锁定):最优手动实现,兼顾了懒加载、线程安全和性能。记住
volatile关键字!
- 在SpringBoot中的应用:
- Spring默认就是单例!我们定义的
@Component,@Service,@Repository等Bean,默认都是单例的。 - 实现原理:Spring容器在启动时创建Bean并存入
Map中,使用时从Map中获取。 - 应用场景:
- 无状态Bean:如工具类、服务类(
Service),它们不保存可变的状态,天然适合单例。 - 全局配置类:如
@ConfigurationProperties类,用于统一管理配置。 - 资源管理类:如连接池、线程池、缓存等,需要全局共享以节省资源。
理解单例模式,不仅是为了应付面试,更是为了在日常开发中,能够识别出哪些场景适合使用单例,并能够利用Spring框架的特性,优雅地实现它,从而写出更高质量的代码。
希望这篇文章能对你有所帮助!如果觉得有收获,别忘了点赞、在看和转发哦!你的支持是我创作的最大动力!我们下期再见!😊
- 无状态Bean:如工具类、服务类(
- Spring默认就是单例!我们定义的