很多新手刚接触设计模式时,总觉得单例模式太简单了。什么是单例?通俗来说:一家公司只能有一个 CEO,一个庞大的系统通常也只需要一个全局的数据库连接池。 如果你每次用数据库都去 new 一个连接池,不仅内存当场爆炸,数据交互也会彻底乱套。保证在整个内存世界里,某个类永远只有一个活生生的对象实例,这就是单例模式的初衷。

为了实现单例,同时兼顾“用到时才创建(懒加载)”和“多线程下的极高性能”,前辈们发明了著名的 Double-Checked Locking (双重检查锁,简称 DCL)

但如果你只在代码里写了两个 if (INSTANCE == null) 外加一个 synchronized,没有用volatile修饰修饰,如果发生指令重排,会引发巨大的线上灾难!

今天,我们就把这段看似完美的 DCL 代码扔进 JVM 的底层照妖镜里,看看在字节码和多线程的交错下,它是怎么变成一颗“定时炸弹”的。


一、 是什么:看似天衣无缝的 DCL 伪代码

我们先来看一段经典但有致命缺陷的 DCL 单例代码。

它的初衷非常美好:

  1. 懒惰实例化:只有在第一次调用 getInstance() 时才去真正干活。

  2. 锁粒度细:只有第一次创建时才需要 synchronized 排队,之后哪怕有一万个并发,进门第一个 if 发现不为空,直接就拿走用了,极大地提升了效率。

Java

public final class FlawedSingleton {
    // 【致命缺陷】:没有加 volatile!
    private static FlawedSingleton INSTANCE = null;
    
    // 模拟对象内部的属性
    private int status;

    private FlawedSingleton() {
        this.status = 100; // 模拟耗时的精装修动作
    }

    public static FlawedSingleton getInstance() {
        if (INSTANCE == null) { // 第一重检查:如果没有实例化,才去抢锁
            synchronized (FlawedSingleton.class) {
                if (INSTANCE == null) { // 第二重检查:抢到锁之后再次确认
                    // 💥 炸弹就埋在这里:这里绝对不是一个原子操作!
                    INSTANCE = new FlawedSingleton();
                }
            }
        }
        return INSTANCE;
    }
}

这段代码在单线程下跑得极其丝滑,但在高并发下,它有极大概率返回一个**“半拉子工程”**,导致其他线程拿到对象后报出莫名其妙的空指针或状态异常。


二、 重点细节:灾难现场还原与“半拉子工程”

为什么会出错?这就得深入到 CPU 和 JVM 的**指令重排(Instruction Reordering)**机制了。

1. 底层拆解:盖大楼的三步曲

在 Java 中,执行 INSTANCE = new FlawedSingleton(); 这一行简单的代码,在底层字节码层面,其实被拆成了极其关键的三大步:

  • 步骤一(分配内存 new:去 JVM 堆内存里买下一块空地皮

  • 步骤二(初始化对象 invokespecial:在空地上盖房子并完成精装修。也就是调用构造方法,把里头的 status 赋值为 100。

  • 步骤三(赋值引用 putstatic交大门钥匙。把这块地皮的内存地址,正式赋值给 INSTANCE 变量。(注意:只要执行了这一步,INSTANCE 就不再是 null 了!)

2. 指令重排引发的多线程车祸现场

正常人的思维是:步骤一 -> 步骤二 -> 步骤三(地买好,房盖好精装修,最后交钥匙)。

但是!JVM 的 JIT 编译器和底层 CPU 为了极致的执行效率,觉得步骤二和步骤三谁先谁后无所谓,于是很可能会进行指令重排,把执行顺序变成了 步骤一 -> 步骤三 -> 步骤二(地刚买好,先把空壳大门的钥匙交给你,然后再慢慢搞内部装修)。

一旦发生这种重排,多线程的灾难就开始了:

  1. 线程 T1 抢到了锁,开始执行 new 操作。JVM 发生了 1-3-2 的重排。

  2. 致命瞬间:T1 刚刚执行完步骤一步骤三。此时地皮买好了,钥匙也交出去了,所以 INSTANCE 已经不再是 null!但是,步骤二(精装修)还没开始干,这栋大楼只是个毛坯房(status 依然是默认的 0)。

  3. 线程 T2 杀入:就在这零点几毫秒的间隙,T2 线程进来了,执行最外层的 if (INSTANCE == null)。因为它synchronized 代码块之外,根本不受锁的限制!

  4. 系统崩溃:T2 发现 INSTANCE 不为 null(拿到的是 T1 提前给的毛坯房假钥匙),狂喜,直接 return INSTANCE;。然后 T2 拿着这个连构造方法都没执行完的“半拉子对象”去读取 status,本以为会拿到 100,结果拿到了 0,或者直接触发内部某些未初始化引用的 NPE(空指针异常),业务当场阻断!


三、 怎么用:加上 volatile 召唤“物理警戒线”

要修复这个致命 Bug,解法极其简单,只需要在声明变量时加上一个 volatile 关键字。

Java

public final class SafeSingleton {
    
    // ✅ 【正确姿势】:必须加上 volatile 防御指令重排!
    private static volatile SafeSingleton INSTANCE = null;
    
    private int status;

    private SafeSingleton() {
        this.status = 100; 
    }

    public static SafeSingleton getInstance() {
        if (INSTANCE == null) { 
            synchronized (SafeSingleton.class) {
                if (INSTANCE == null) { 
                    INSTANCE = new SafeSingleton();
                }
            }
        }
        return INSTANCE;
    }
    
    public static void main(String[] args) {
        // 多线程环境下安全获取单例
        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                System.out.println(Thread.currentThread().getName() + " 获取到实例,状态为: " + 
                    SafeSingleton.getInstance().status);
            }).start();
        }
    }
}
/**
 * 运行输出:
 * Thread-0 获取到实例,状态为: 100
 * Thread-1 获取到实例,状态为: 100
 * ... (所有线程都能安全拿到完整装修完毕的对象)
 */
底层剖析:volatile 到底干了什么?

在这里,volatile 的核心作用不是保证可见性,而是完全禁止指令重排

它通过在字节码前后插入 内存屏障 (Memory Barrier),强行拉起了几道物理警戒线。对于加了 volatile 修饰的 INSTANCE 变量,JVM 必须老老实实地按照 步骤一(分配内存) -> 步骤二(精装修初始化) -> 步骤三(交钥匙赋值) 的绝对顺序执行。

因为有内存屏障的阻挡,步骤三(交钥匙)绝对不可能越过步骤二先执行。这就保证了:只要外部的其他线程看到 INSTANCE 不为 null,那么这个对象一定已经从里到外完全初始化完毕了,绝对不会再拿到半拉子的毛坯房!


四、 对比

为了防止你在单例模式上栽跟头,这三种最常见的单例写法对比,请务必烂熟于心:

单例模式写法 线程安全性 懒加载 (按需创建) 性能 / 实战点评

饿汉式 (Eager)

 

public static final Singleton INSTANCE = new Singleton();

绝对安全 (类加载时即完成初始化) 最推荐的极简写法。 除非你的单例对象极其庞大且有可能整个生命周期都不被使用,否则首选这种无脑安全的写法。

双重检查锁 (DCL)

 

volatile + 双 if + synchronized

安全 (前提是必须加 volatile) 性能极高,实现了懒加载。但代码繁琐,极其考验对 JVM 底层并发原理的理解,是面试官用来筛人的利器。
静态内部类 (Static Inner Class) 绝对安全 完美结合了上述两者的优点!利用 JVM 类加载的初始化锁机制保证线程绝对安全,兼顾懒加载,且不需要写晦涩的 volatile业务代码实战极度推荐。
Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐