单例模式 (Singleton)

13 阅读8分钟

 今天我们来深入讲解设计模式中最基础、最常用,也最容易出问题的一个模式——单例模式

不知道大家有没有这样的感觉:提到设计模式,23种里平时真正用得多的就那么几个,而单例模式绝对是其中的“人气王”。为什么?因为它解决的是一个非常朴素的场景——如何确保一个类在全局只有一个实例,并且这个实例要能方便地被各处访问。听起来简单,但真要把它写好、用对,背后涉及线程安全、性能、JVM类加载机制、反射攻击等不少知识点。今天我们就带着几个问题,一起把它吃透。


一、为什么要用单例模式?——两个真实场景带你理解

先问大家一个问题:如果你们开发一个电商系统,产品经理要求整个系统每秒最多只能处理100次请求(为了保护下游支付服务),你会怎么实现?

很多同学第一反应:写一个限流器 RateLimiter,然后在每个 Controller 里 new 一个。代码可能像这样:

public class RateLimiter {
    private int permitsPerSecond;
    private int tokens;
    // ... 限流逻辑
}

// UserController
private RateLimiter limiter = new RateLimiter(100);

// OrderController
private RateLimiter limiter = new RateLimiter(100);

思考一下:这有什么问题?
每个 Controller 都有自己的限流器对象,相当于每个模块独立限流100次,加起来系统QPS就变成200+了,限流完全失效!而且在高并发场景下,多个微服务一叠加,后果更严重。

有同学说:那我在 tryAcquire 方法上加锁行不行?

  • 如果加 synchronized(this),锁的是对象实例,不同 Controller 用的不同对象,锁不共享,没用。
  • 如果加 synchronized(RateLimiter.class),虽然能限流,但每次请求都要竞争全局锁,性能极差,而且每个 Controller 还是要 new 自己的对象,浪费内存。

最佳方案是什么?
整个系统只允许存在一个限流器实例,所有地方共享同一个令牌桶。这样限流策略就全局生效了,而且只创建一次对象,节省内存,也不需要加锁(因为共享的是同一个对象)。这就是单例模式的价值。

public class RateLimiter {
    private static final RateLimiter instance = new RateLimiter(100);
    private RateLimiter(int permitsPerSecond) { ... }
    public static RateLimiter getInstance() { return instance; }
}

再比如,在电商系统中,订单号必须全局唯一,而且往往有业务格式(日期+序列号)。如果系统中同时存在两个订单号生成器实例,就一定会出现重复订单号,导致支付对账、客服查询全部混乱。这类业务上要求全局唯一的场景,也是单例模式擅长的。

总结一下,单例模式主要解决两类问题:

  1. 资源竞争冲突:像限流器、日志记录器、数据库连接池等,如果多个实例各自为政,就会破坏全局一致性。
  2. 业务上必须全局唯一:如订单号生成器、配置管理器等。

二、单例模式怎么实现?——八仙过海,各有千秋

要实现一个正确的单例,必须抓住四个关键点:

  • 构造函数私有化:防止外部 new
  • 线程安全:多线程环境下只创建一次实例。
  • 延迟加载(懒加载) :是否在第一次使用时才创建(避免启动时加载不必要的资源)。
  • 性能getInstance() 方法的执行效率。

下面我们逐一分析 Java 中主流的实现方式。

1. 饿汉式

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

  • 优点:线程安全(JVM类加载保证),性能极高(无锁)。
  • 缺点:不支持懒加载,类加载时就创建实例,如果从未使用会造成资源浪费。
  • 适用场景:实例创建开销小,且应用启动后一定会用到。

2. 懒汉式(线程不安全)

public class Singleton {
    private static Singleton instance;
    private Singleton() {}
    public static Singleton getInstance() {
        if (instance == null) instance = new Singleton();
        return instance;
    }
}

  • 问题:多线程下可能创建多个实例,绝对不能用于生产环境

3. 懒汉式(线程安全,同步方法)

public static synchronized Singleton getInstance() {
    if (instance == null) instance = new Singleton();
    return instance;
}

  • 优点:线程安全,延迟加载。
  • 缺点:每次调用都同步,即使实例已创建也存在锁竞争,高并发下性能差。

4. 双重检查锁(Double-Checked Locking, DCL)

public class Singleton {
    private static volatile Singleton instance;
    private Singleton() {}
    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

  • 优点:延迟加载,线程安全,且实例创建后无锁开销,性能高。
  • 关键:必须用 volatile 修饰,防止指令重排序导致半初始化对象被其他线程读取。
  • 缺点:实现较复杂,需要理解 Java 内存模型;在 JDK 1.5 以下版本有缺陷(现在已修复)。

5. 静态内部类(推荐写法)

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

  • 原理:外部类加载时不会加载内部类,只有调用 getInstance() 时才会触发 Holder 类加载,从而创建实例。JVM 在类加载时会加锁,保证 INSTANCE 只被初始化一次。
  • 优点:懒加载、线程安全、高性能(无锁)、代码简洁。
  • 缺点:无法传递参数(可通过静态方法变通),无法防止反射/序列化破坏。

6. 枚举单例

public enum Singleton {
    INSTANCE;
    public void doSomething() { ... }
}

  • 优点:天然线程安全、懒加载、高性能,而且自动防止反射和序列化攻击(反射不能创建枚举实例,序列化也不会产生新对象)。
  • 缺点:无法延迟加载(枚举类被引用时就会加载),不能继承其他类(但可以实现接口)。
  • 适用场景:最简洁、最安全的单例实现,是 Effective Java 推荐的写法。

7. CAS(AtomicReference)

private static final AtomicReference<Singleton> INSTANCE = new AtomicReference<>();
public static Singleton getInstance() {
    for (;;) {
        Singleton instance = INSTANCE.get();
        if (instance != null) return instance;
        INSTANCE.compareAndSet(null, new Singleton());
        return INSTANCE.get();
    }
}

  • 优点:无锁,利用 CAS 实现线程安全。
  • 缺点:每次创建实例失败时都会新建一个临时对象,造成浪费;代码可读性一般,不如静态内部类优雅。

三、单例模式有哪些问题?

同学们,任何模式都不是银弹,单例模式也有其局限性和潜在问题:

  1. 反射攻击:通过 setAccessible(true) 可以调用私有构造器,创建多个实例。
    解决方案:在构造器中加入判断,若实例已存在则抛出异常;或者使用枚举单例(天然防御)。
  2. 序列化破坏:实现 Serializable 接口后,反序列化会创建新对象。
    解决方案:重写 readResolve() 方法返回已有实例。
  3. 测试困难:单例的全局状态会让单元测试之间相互影响,难以隔离。
  4. 违反单一职责:单例类既要管理自己的业务逻辑,又要控制实例的创建,职责过重。
  5. 扩展性差:子类无法继承单例(私有构造器),不利于面向接口编程。

四、单例和静态类的区别?

很多同学会混淆单例和静态类(全是静态方法的类,如 Math)。它们有本质区别:

维度单例静态类
实例化有且仅有一个实例,可以继承、实现接口不能实例化,完全是工具方法
生命周期由类控制,可延迟初始化类加载时即初始化
灵活性可以配置参数、替换实现无法改变行为
内存占用只占一份对象内存静态方法不占对象内存,但静态变量常驻
使用场景需要维护状态(如限流器、连接池)无状态工具方法(如数学计算)

一句话:如果你需要维护状态,并且必须全局唯一,用单例;如果只是提供一组无状态工具方法,用静态类。


五、有哪些替代方案?

随着架构演进,单例模式在大型项目中也逐渐暴露出不足,我们有一些更现代的替代思路:

  1. 依赖注入(DI) :通过 Spring 等容器管理 Bean 的作用域(如 @Singleton@RequestScoped),既保证唯一性,又便于测试和解耦。
  2. 工厂模式:由工厂统一管理对象的创建和生命周期,可以在工厂内部实现单例逻辑,但调用方不直接依赖单例类。
  3. 作用域限定:比如 Web 应用中的请求级别单例(每个请求一个实例),而不是全局单例。

建议:在大型项目或框架中,尽量利用 Spring 的 Bean 作用域来替代手写单例,既能享受容器的生命周期管理,又不会侵入业务代码。


六、总结与课后思考

今天我们围绕单例模式,从“为什么用”到“怎么实现”,再到“有什么坑”和“怎么替代”,系统梳理了一遍。希望能记住几个关键点:

  • 单例的核心:确保一个类只有一个实例,并提供全局访问点。
  • 实现时务必注意:线程安全、延迟加载、性能。
  • 生产环境推荐:静态内部类(懒加载、性能优)或枚举(最安全)。
  • 不要滥用单例:它会使代码耦合变重、测试变难,用依赖注入往往是更好的选择。