一、单例模式:全局配置管理器

单例模式,说白了就是——一个类只能有一个实例

你想想看,有些东西在系统里只需要一份就够了。比如日志记录器、数据库连接池、Spring 里的 Bean 默认作用域。如果每个地方都 new 一个,那不乱套了?

我记得刚入行那会儿,有个同事写了个日志工具类,每次调用都 new 一个实例。结果线上日志文件被多个线程同时写,内容全乱了。后来改成单例,问题就解决了。

嗯,这就是单例模式的价值所在。

1.1 什么是单例模式?

单例模式确保一个类只有一个实例,并提供一个全局访问点。

它的核心要点就两个:

  • 私有化构造方法——不让外部 new
  • 提供一个静态方法——返回唯一的实例

听起来简单吧?但实现起来有不少坑。

1.2 业务场景:日志记录器

日志记录器是最典型的单例应用。为什么?

因为日志文件只有一个。如果每个模块都自己写日志,文件锁、写入顺序、性能都会出问题。

来看一个简单的实现:

public class Logger {
    // 私有静态实例
    private static Logger instance;
    
    // 私有构造方法
    private Logger() {
        // 初始化日志文件
    }
    
    // 全局访问点
    public static Logger getInstance() {
        if (instance == null) {
            instance = new Logger();
        }
        return instance;
    }
    
    public void log(String message) {
        // 写入日志文件
        System.out.println("[LOG] " + message);
    }
}

这个写法叫懒汉式——用到的时候才创建。

但有个问题:多线程环境下,两个线程同时调用 getInstance(),可能会创建两个实例。

⚠️ 注意:懒汉式在多线程下不安全。我曾经在一个高并发项目中踩过这个坑,日志偶尔会丢失。排查了半天才发现是单例被重复创建了。

1.3 业务场景:数据库连接池

数据库连接池也是单例的经典场景。

你想想看,如果每个请求都创建一个连接池,那数据库很快就会被拖垮。连接池应该全局唯一,统一管理连接的分配和回收。

我个人习惯用双重检查锁来实现:

public class ConnectionPool {
    // volatile 防止指令重排序
    private static volatile ConnectionPool instance;
    
    private ConnectionPool() {
        // 初始化连接池
    }
    
    public static ConnectionPool getInstance() {
        if (instance == null) {
            synchronized (ConnectionPool.class) {
                if (instance == null) {
                    instance = new ConnectionPool();
                }
            }
        }
        return instance;
    }
}

这里有个细节:volatile 关键字不能少。为什么?

因为 instance = new ConnectionPool() 不是原子操作。JVM 可能会先分配内存,再赋值给 instance,最后才初始化对象。如果另一个线程在赋值后、初始化前访问,就会拿到一个半成品。

💡 小技巧:如果你用的是 Spring,直接用 @Component 注解,默认就是单例。省心又安全。

1.4 业务场景:Spring Bean 默认作用域

Spring 的 Bean 默认就是单例的。这也是为什么我们通常不需要手动实现单例模式。

Spring 容器会保证每个 Bean 只有一个实例。你只管注入,不用操心创建和销毁。

但要注意:有状态的 Bean 不适合单例

比如一个计数器 Bean,如果多个线程同时修改它的状态,就会出现数据不一致。

作用域 说明 适用场景
singleton 全局唯一实例 无状态服务、工具类
prototype 每次注入都创建新实例 有状态对象、用户会话
request 每个 HTTP 请求一个实例 Web 应用中的请求数据
session 每个用户会话一个实例 用户登录信息

我在项目中遇到过一个问题:一个 Service 里有个 List 成员变量,用来缓存数据。结果线上多个用户的数据互相覆盖了。排查下来,就是因为 Service 是单例的,List 被所有线程共享。

解决方案很简单:要么把 List 改成局部变量,要么把 Service 改成 prototype 作用域。

1.5 单例模式的几种实现方式对比

这里我整理了一张对比表,方便你选型:

实现方式 线程安全 延迟加载 推荐指数
饿汉式 ✅ 安全 ❌ 否 ⭐⭐⭐
懒汉式(不加锁) ❌ 不安全 ✅ 是
双重检查锁 ✅ 安全 ✅ 是 ⭐⭐⭐⭐
静态内部类 ✅ 安全 ✅ 是 ⭐⭐⭐⭐⭐
枚举 ✅ 安全 ❌ 否 ⭐⭐⭐⭐

我个人最推荐静态内部类的方式。它既实现了延迟加载,又保证了线程安全,代码还简洁:

public class Singleton {
    private Singleton() {}
    
    private static class Holder {
        private static final Singleton INSTANCE = new Singleton();
    }
    
    public static Singleton getInstance() {
        return Holder.INSTANCE;
    }
}

为什么这种方式是线程安全的?

因为 JVM 在加载内部类时会加锁,保证了 INSTANCE 只会被初始化一次。

1.6 单例模式的核心逻辑

下面这张图展示了单例模式的核心流程:

单例模式核心流程 客户端调用 实例已存在? 返回已有实例 创建新实例 返回新实例 关键点: 1. 私有构造方法 2. 静态全局访问点 3. 线程安全保证 4. 延迟加载(可选)

1.7 避坑指南

单例模式看着简单,但坑不少。我总结几个常见的:

  • 序列化破坏单例——如果单例类实现了 Serializable,反序列化时会创建新实例。解决方案是添加 readResolve() 方法。
  • 反射破坏单例——通过反射调用私有构造方法,可以创建多个实例。可以在构造方法中加个标志位来防御。
  • 类加载器不同——不同类加载器加载同一个类,会生成不同的实例。这在 Web 容器中要特别注意。

核心要点回顾:

  • 单例模式保证一个类只有一个实例
  • 适用于日志记录器、连接池、配置管理等场景
  • 实现方式有多种,静态内部类最推荐
  • 注意线程安全、序列化、反射等潜在问题

好了,单例模式就讲到这里。记住一句话:能用 Spring 单例就别自己写,省心又安全。

公众号:蓝海资料掘金营,微信deep3321