文章背景图

单例模式

2026-08-05
12
-
- 分钟
|

单例模式

1. 噩梦的开端:混乱的全局变量

你在写一个日志工具 Logger,为了方便,你把它写成 public static,想着全系统共用,简单粗暴。

public class Logger {
    public static String logFilePath = "/app/logs/run.log";
    public static void write(String msg) { ... }
}

一开始岁月静好。但项目变大后,噩梦来了:

  • 模块A在启动时修改了 logFilePath指向 /moduleA/
  • 模块B因为依赖加载顺序问题,拿到的是A改过的路径,日志写到了A的目录里;
  • 线上查Bug时,A的日志里混着B的内容,B的日志里找不到关键信息。

你用 private修饰变量,加上 get/set,稍微好点,但依然治标不治本——任何代码都能随意new,或者通过反射、序列化破坏封装。你需要一个铁律:这个类在JVM中必须且只有一个实例,且全局提供一个访问点。

这就是单例模式要解决的核心矛盾如何控制类的实例化过程,确保全局唯一。

2. 第一版:饿汉式 —— 简单粗暴的“开机自启”

最简单的做法:类加载进内存时,立刻创建好实例,像手机里的预装应用,开机就有。

public class Logger {
    // 类加载时即初始化,由JVM保证线程安全
    private static final Logger INSTANCE = new Logger();
    private Logger() {} // 堵住new的路
    public static Logger getInstance() { return INSTANCE; }
    public void log(String msg) { ... }
}

真实翻车案例:某支付系统在启动时加载了所有对账规则到单例中,结果业务量增长后,就因为这一个类导致JVM堆内存溢出,每次扩容都要加内存,成本极高。

优点:绝对的线程安全(JVM的 <clinit>是加锁的),代码极其简洁。

说明:关于 为什么是加锁的

这句话的意思是:JVM 在执行类的初始化方法 <clinit> 时,会自动加锁,保证类初始化的线程安全。

详细解释

1. <clinit> 是什么?

  • <clinit>Class Initialization Method(类初始化方法)的缩写。
  • 它是编译器自动生成的特殊方法,用来执行:
    • static 变量的赋值
    • static {} 静态代码块
  • 每个类最多只有一个 <clinit> 方法(接口也有)。

2. 为什么说「是加锁的」?

JVM 规范规定:类的初始化必须是线程安全的

具体机制是:

  1. 当多个线程同时第一次使用某个类时(触发类初始化),JVM 会对这个类的 Class 对象加锁
  2. 只有拿到锁的那个线程才能执行该类的 <clinit> 方法。
  3. 其他线程会被阻塞等待,直到持有锁的线程把 <clinit> 执行完毕。
  4. 初始化完成后,JVM 会把类的状态标记为「已初始化」,后续线程直接使用,不再执行 <clinit>

这相当于 JVM 内部自动实现了类似下面的同步逻辑(伪代码):

synchronized (Class对象) {
    if (类还没初始化) {
        执行 <clinit>();
        标记为已初始化;
    }
}

3. 实际意义

  • 防止多线程重复初始化:避免静态变量被多次赋值,或者静态代码块被多次执行。
  • 保证可见性:一个线程初始化完后,其他线程能立刻看到正确的静态状态。

举个简单例子

public class Test {
    static {
        System.out.println(Thread.currentThread().getName() + " 正在执行静态代码块");
        try {
            Thread.sleep(2000);  // 故意睡2秒,方便观察
        } catch (InterruptedException e) {}
    }

    public static void main(String[] args) {
        new Thread(() -> new Test(), "线程A").start();
        new Thread(() -> new Test(), "线程B").start();
    }
}

你会看到类似输出:

线程A 正在执行静态代码块
(过了2秒)

只有一个线程真正执行了静态代码块,另一个线程会等它执行完。这就是 <clinit> 被加锁的效果。


总结一句话
<clinit> 是加锁的」= JVM 在执行类初始化时,自动对 Class 对象加了同步锁,保证同一个类的静态初始化只会被一个线程执行一次。

致命的缺点内存占用不可控。如果这个Logger需要加载2GB的离线词库,而你整个项目其实只在一个罕见分支下才用到它,那无论用不用,这2GB的内存都被霸占了。这在微服务容器内存紧张的时代,是不可接受的

3. 第二版:懒汉式 —— “随用随加载”的懒加载

既然怕浪费内存,那就不在类加载时创建,等到第一次调用 getInstance时再创建

public class Logger {
    private static Logger instance;
    private Logger() {}
    public static Logger getInstance() {
        if (instance == null) { // 问题在这里!
            instance = new Logger();
        }
        return instance;
    }
}

代码很美,直到上线高并发场景——两个线程同时调用 getInstance,线程A判断为 null,刚准备创建,CPU时间片切换给线程B,B也判断为 null。结果A和B各自 new了一个Logger对象,单例破功。线上表现为:两个线程拿到的对象 hashCode不同,各自维护的缓冲区不一致,日志乱序,排错排了通宵。

4. 第三版:加锁 —— 用性能换安全

解决并发最简单就是加锁,让整条马路只过一个线程:

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

线程安全了,但性能雪崩。在十万级QPS的系统里,即使实例已经创建好了,每次获取还要排队抢锁。高并发下,成千上万个线程堵在这个方法门口,吞吐量直线下降90%。这就是过度同步的代价。

5. 第四版:双重检查锁(DCL)—— 竞态条件下的平衡艺术

计算机科学家们提出一个精妙方案:先不加锁检查,如果没创建再抢锁,抢到锁后再检查一次

public class Logger {
    private static volatile Logger instance;
    private Logger() {}
    public static Logger getInstance() {
        if (instance == null) {               // 第一次检查(无锁)
            synchronized (Logger.class) {
                if (instance == null) {       // 第二次检查(有锁)
                    instance = new Logger();
                }
            }
        }
        return instance;
    }
}

关键细节:为什么必须加 volatile
因为 instance = new Logger();在JVM底层是三个步骤

  1. 分配内存空间;
  2. 初始化对象;
  3. 将引用指向内存地址。

如果没有 volatile,JVM为了性能可能指令重排成 1 → 3 → 2。当线程A执行到第3步(引用已赋值,但对象还没初始化),线程B来了,第一次检查发现 instance != null,直接返回这个半成品对象。线程B调用 log()方法,可能读到的是未初始化的默认值(比如文件路径是 null),直接抛出空指针异常

volatile在这里的作用就是禁止指令重排,保证写操作“先行发生于”读操作。

现实意义:这是Java并发编程中最经典的考点,也是许多中间件(如连接池、缓存管理器)初始化时的标准写法。

6. 第五版:静态内部类 —— 最优雅的“懒加载”实现

上面DCL代码臃肿,且 volatile有一定性能损耗(禁用缓存行优化)。更优雅的办法是利用JVM的类加载机制

public class Logger {
    private Logger() {}
    private static class Holder {
        private static final Logger INSTANCE = new Logger();
    }
    public static Logger getInstance() {
        return Holder.INSTANCE; // 第一次调用时,Holder类才被加载
    }
}

原理:外部类 Logger加载时,内部类 Holder并不会被加载。只有调用 getInstance触发 Holder.INSTANCE时,Holder才会被JVM加载,并在 <clinit>中线程安全地创建实例。这既实现了懒加载,又不用显式加锁,代码极简。

7. 终极版:枚举单例 —— 抵御反射与序列化的“物理级”防御

以上的所有方式,包括DCL和静态内部类,都有两个致命漏洞

  1. 反射攻击:通过 setAccessible(true)强行调用私有构造器,可以创建新实例;
  2. 序列化攻击:将单例对象写入文件再反序列化,会生成新对象(除非重写 readResolve方法)。

为了堵死这些漏洞,Java大神Josh Bloch(《Effective Java》作者)给出终极方案:枚举

public enum Logger {
    INSTANCE;
    private String logPath;
    // 构造器默认是private的
    Logger() { logPath = "/default/path"; }
    public void log(String msg) { ... }
}

为什么枚举无敌?

  • 枚举类没有构造器(即使反射也无法创建枚举实例,会直接抛异常);
  • 序列化机制对枚举特殊处理,只会查找已存在的枚举常量,不会新建对象;
  • 线程安全由JVM保证。

在如今的实际工程中,如果是一个轻量级工具包,枚举单例几乎是最佳选择

8. 现代视角:单例模式的“去中心化”

随着Spring、Guice等依赖注入(DI)框架的普及,我们不再手工写单例。Spring容器默认将所有Bean的作用域设为 Singleton,由容器来保证唯一性。你只需要加个 @Component注解,容器启动时帮你创建,运行时替你注入。

但这并不是说单例模式过时了。理解它的演化史,能帮你明白:

  • JVM类加载器的机制;
  • 内存可见性(volatile)的本质;
  • 反射与序列化的安全性边界;
  • IoC容器解决单例“全局耦合”问题的底层逻辑(把唯一性控制权交给容器,便于单元测试时替换为Mock对象)。

本章核心脉络图(无需记忆,重在理解过程)

演进版本 核心痛点 解决方案 代价/新问题
全局变量(反模式) 随处修改,无法管控 public static 状态混乱,无唯一性保障
饿汉式 控制唯一性 类加载即创建 内存浪费,启动慢
懒汉式 节省内存 延迟加载 线程不安全
同步懒汉式 保证线程安全 synchronized 性能瓶颈(锁竞争)
DCL双重检查 提升性能 volatile + 两次检查 代码复杂,依赖内存模型
静态内部类 解耦并发与加载 类加载机制托管 无法抵御反射/序列化
枚举 终极安全 语言级特性支持 无法继承(但单例不需要)
IoC容器单例 去中心化管理 外部容器(Spring) 需要引入框架,增加复杂度

本章总结

单例模式看似简单,但它的每次进化都对应着一个真实的软件工程困境:从内存控制、并发安全,到防御性编程,再到架构层面的解耦。掌握它,你不仅学会了如何创建一个全局唯一对象,更学会了权衡的艺术——在性能、安全、简洁和可测试性之间找到当前场景的最优解。

原创

单例模式

本文链接: 单例模式

本文采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。

评论交流

文章目录