Skip to content

多线程-单例模式

更新: 1/23/2026 字数: 0 字

A. 双检锁 (DCL, Double-Checked Locking)

通过手动加锁和 volatile 关键字保证线程安全。

java

public class Singleton {

    // 必须加 volatile 关键字
    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

如果不加,instance = new Singleton(); 可能会发生指令重排:

  • 1.分配内存
  • 2.将 instance 指向内存地址
  • 3.初始化对象

线程 A 执行到步骤 2 时,线程 B 判断 instance != null 直接返回,导致拿到一个未初始化完成的“半成品”对象。

B. 静态内部类 (Holder 模式)

利用 JVM 类加载机制保证安全,是目前最推荐的写法。

java

public class Singleton {
    private Singleton() {
    }

    private static class Holder {
        // static: 全局唯一
        // final: 确保初始化安全性(写屏障)
        // 写操作屏障:在 Holder 类完成初始化(即 <clinit> 执行完毕)并允许其他线程访问 Holder.INSTANCE 之前,INSTANCE 指向的内存必须已经完全写入。
        // 构造函数边界:这里的“构造函数返回之前”对 static 变量来说,指的就是 类初始化动作完成之前。
        static final Singleton INSTANCE = new Singleton();
    }

    public static Singleton getInstance() {
        return Holder.INSTANCE;
    }
}

核心优势分析

  • 懒加载:加载 Singleton 类时,Holder 不会被加载。只有调用 getInstance() 时才会触发 Holder 的初始化。

  • 线程安全:JVM 在加载类时会获取内部锁,确保多个线程同时访问时,类只被初始化一次。

  • 高性能:无需手动加锁,利用 JVM 底层机制实现同步。

静态内部类单例深度解析
  1. 加载 Singleton 类时,内部类 Holder 并不会被加载,也不会初始化;
  2. 第一次调用 getInstance() 方法时,代码访问了 Holder.INSTANCE。此时,JVM 才会发现需要加载并初始化 Holder 类;
  3. 线程安全性:当多个线程同时尝试初始化同一个类时,JVM 会获取初始化锁,确保所有线程看到同一个完全构造好的实例。

JMM 要求编译器和处理器在 final 域的写之后,构造函数返回之前,插入一个 StoreStore 屏障,保证在构造器返回之前,final 字段是完全初始化完成的; 针对读方的约束(LoadLoad 屏障):初次读一个包含 final 域的对象引用,与随后初次读这个 final 域,这两个操作之间不能重排序; final 保护的是“内部属性”:它保证对象里面的成员变量是构造完整的; volatile 保护的是“引用本身”:在 DCL 中,instance = new Singleton() 这个赋值操作本身在非 final 情况下可能由于重排序导致,instance 提前变为非 null

Holder 模式下,即便内部是普通变量,依然是安全的,因为 类加载(Class Loading)的可见性保障 比普通的 final 规则还要强;

  1. 为什么普通变量也安全?(类加载的可见性屏障) 在 Java 中,类的初始化过程是由 JVM 严格控制的。根据 JMM 规范,类的初始化阶段(执行 <clinit>)和“读取该类静态变量”之间存在一个显式的 Happens-Before 关系。 线程 A:初始化 Holder 类,执行 new Singleton()。此时它修改了 Singleton 内部的普通变量 int x = 10JVM 内部锁:JVM 保证在线程 A 完成 <clinit> 之前,其他任何线程都不能访问这个类。 可见性传递:当线程 A 完成初始化并释放类初始化锁后,JMM 保证初始化阶段产生的所有写入(包括对 Singleton 内部普通变量的写入)对后续访问该类的线程全部可见。

  2. 既然如此,为什么还要写 final? 主要有三个原因: 防止引用被篡改: 如果没有 final,单例实例可能会被类内部的其他方法(或者通过反射)修改,指向另一个对象,破坏了“单例”的唯一性。 额外的 JMM 保障(双重保险): final 提供的“初始化安全性”是语言层面的强约束。万一未来代码演变成不再通过类加载器初始化(虽然在这个模式下不可能),final依然能保证对象的完整性。 性能优化: JVM 编译器(如 JIT)对 final 变量有特殊的优化逻辑,因为它知道这个值一旦设定就不会改变,可以更放心地址进行内联或常量折叠。

安全性来源:程序员手动加锁

java

// 情况 A:Holder 模式
// Holder 内部
static final Singleton INSTANCE = new Singleton();
// 安全性来源:JVM 类加载锁 + Happens-Before 规则。
// 结论:哪怕 Singleton 里的变量全是普通的,也 100% 安全。

// 情况 B:双重检查锁定 (DCL)
public static Singleton getInstance() {
    if (instance == null) {
        synchronized (Singleton.class) {
            if (instance == null) {
// 这里如果没有 volatile 或 final,就会出事
                instance = new Singleton();
            }
        }
    }
    return instance;
}

结论:

如果 instance 不加 volatile(或者内部变量不加 final),线程 B 可能会在 synchronized 块外面看到一个“半成品”对象。因为 DCL 避开了类加载器的自动同步。

Holder 静态内部类写法中:

  • 1、final 确保了 INSTANCE 引用本身不可变,并提供了对象构造的“写屏障”。

  • 2、static 触发了 JVM 的类加载机制,利用类初始化锁保证了线程安全和全局可见性。

这种组合拳让它成为了 Java 中性能最高、最简洁的懒加载单例实现

2. 深度对比:Holder 模式 vs DCL

维度Holder 内部类模式双重检查锁定 (DCL)
安全性来源JVM 类加载锁 + Happens-Before程序员手动加锁 (synchronized)
可见性保障类加载的可见性屏障(比 final 更强)必须依赖 volatile 或 final
性能表现极高(利用 JVM 原生机制,无额外锁开销)较高(但存在 volatile 内存屏障开销)
实现风险简洁、稳固,不易出错容易遗漏关键字导致重排序 Bug
结论推荐:哪怕内部是普通变量也 100% 安全慎用:避开了类加载器的自动同步

Happens-Before

是 Java 内存模型(JMM)中最核心的概念。它是判断数据是否存在竞争、线程是否安全的主要依据。

简单来说,Happens-Before 是一套规则,用来定义**“操作 A 是否对操作 B 可见”**。如果操作 A Happens-Before 操作 B,那么 A产生的所有影响(修改变量、内存写入等)在 B 执行时都能被观察到。

1. 为什么要引入 Happens-Before?

在底层,为了提高性能,编译器、处理器和缓存会频繁进行指令重排序和内存刷新延迟。 如果没有一套规则,程序员将完全无法预测多线程代码的结果。Happens-Before就像是程序员与 JVM 之间的一份协议:

“只要你遵循这些规则,我就保证你的代码执行结果是符合逻辑可见性的。”

2. Happens-Before 的核心规则

JMM 定义了 8 条原生规则,其中与我们日常开发最相关的有 5 条:

① 程序次序规则 (Program Order Rule) 内容:在一个线程内,按照代码书写顺序,前面的操作 Happens-Before 后面的操作。

解读:虽然单线程内也会重排序,但 JVM 保证最终执行结果与顺序执行的结果一致。

② 管程锁定规则 (Monitor Lock Rule) 内容:一个 unlock(解锁)操作 Happens-Before 后续对同一个锁的 lock(加锁)操作。

解读:如果线程 A 释放了锁,随后线程 B 获取了锁,那么线程 A 在释放锁之前做的所有操作,对线程 B 都是可见的。

③ volatile 变量规则 (Volatile Variable Rule) 内容:对一个 volatile 变量的写操作 Happens-Before 后续对这个变量的读操作。

解读:这强制了变量从工作内存刷新到主内存,保证了实时可见性。

④ 传递性 (Transitivity) 内容:如果 A Happens-Before B,且 B Happens-Before C,那么 A Happens-Before C。

⑤ 线程启动/终止规则 内容:Thread.start() Happens-Before 该线程的每一个动作;线程的所有动作都 Happens-Before 此线程的终止检测(如 Thread.join())。

重点:类初始化规则(解开你单例模式的疑惑) 这一条规则是解决单例模式安全性的关键:

一个类的初始化过程(<clinit> 方法)Happens-Before 于后续任何对该类的静态变量的访问。

在你的 Singleton Holder 模式中:

线程 A 触发 Holder 类加载。

JMM 保证:执行静态常量 INSTANCE 的赋值(构造函数逻辑)是初始化阶段的一部分。

Happens-Before 发生:初始化阶段的所有操作 Happens-Before 任何线程调用 getInstance() 时读取 Holder.INSTANCE。

结果:即使 Singleton 内部有普通变量(非 volatile,非 final),由于这个规则的存在,它们也会在 INSTANCE 被读到之前强制刷新到主内存。

Happens-Before 与“时间先后”的区别 这是最容易混淆的点。

时间先后:A 先发生,B 后发生,不代表 A 对 B 可见(比如缓存导致可见性问题)。

Happens-Before:这是逻辑可见性。即便 A 和 B 发生了指令重排(B 的指令在 A 之前),只要 JMM 保证 A 的结果对 B 可见,我们就说 A Happens-Before B。

总结:如何利用它? 当你分析一段多线程代码是否安全时,不需要去研究复杂的汇编指令,只需套用这几条规则:

是否有 synchronized(管程锁定规则)?

是否有 volatile 变量(volatile 规则)?

是否有 final 变量(虽然不是 HB 规则,但有类似的初始化安全性)?

是否利用了 类初始化锁(单例 Holder 模式的原理)?

如果以上都没有,且两个线程在操作同一个变量,那么这段代码就是不安全的。

我们可以利用 volatile 的 可见性 和 Happens-Before 的 传递性 规则,实现一种非常巧妙的“顺带同步”。

  1. 传递性规则的妙用 假设我们有两个变量,一个是普通变量 a,一个是 volatile 变量 v。
java

public class VolatileExample {
    int a = 0;
    volatile boolean v = false;

    public void writer() {
        a = 42;          // 步骤 1
        v = true;        // 步骤 2
    }

    public void reader() {
        if (v) { // 步骤 3
            System.out.println(a); // 步骤 4
        }
    }
}
JVM 底层的 内存屏障(Memory Barrier) 机制
  1. 什么是内存屏障? 内存屏障是一组硬件指令,它主要做两件事:

阻止重排序:禁止屏障两侧的指令交换顺序。

强制刷新缓存:确保数据写入主内存,或从主内存读取最新数据。

对于 volatile 变量,JVM 会在指令序列中插入四种屏障:

  • StoreStore 屏障:保障屏障前的写操作,先于屏障后的写操作刷新到内存。

  • StoreLoad 屏障:最重的一个屏障,保证写操作先于后面的读操作。

  • LoadLoad 屏障:保障屏障前的读操作,先于屏障后的读操作。

  • LoadStore 屏障:保障屏障前的读操作,先于屏障后的写操作。

  1. 正常顺序下的内存布局(步骤 1 在前)

当代码顺序是 a = 42; v = true; 时,JVM 的底层动作如下:

步骤操作底层屏障/动作作用
1a = 42普通写 (Store)修改线程本地内存/缓存中的共享变量
屏障StoreStore 屏障禁止禁止重排序:确保在该屏障之前的普通写操作(a)在 volatile 写操作(v)执行前,已经刷新到主内存中
2v = trueVolatile 写 (Store)更新 volatile 变量的值,发出状态信号
屏障StoreLoad 屏障最重屏障:确保 volatile 写操作刷新到主内存,并防止其与后面可能存在的读操作重排序

在这种情况下,由于 StoreStore 屏障的存在,a = 42 永远不可能被重排序到 v = true 之后。

  1. 互换顺序后的灾难(步骤 2 在前) 如果你把代码改为 v = true; a = 42;,底层逻辑就变成了:
  • 执行 v = true(Volatile 写)。

  • 触发屏障,同步 v 的值。

  • 执行 a = 42(普通写)。

问题出在哪里? 线程 B(Reader)在执行 if (v) 时,只要步骤 1 完成了,v 就是 true。但此时线程 A 可能还没来得及执行步骤 3(a = 42),或者执行了但还没刷新到主内存。线程 B 读到的 a 依然是初始值 0。

  1. Happens-Before 规则的“传递性” 这是 Java 并发内存模型(JMM)中的核心概念。在原代码中:
  • 根据程序次序规则:a = 42 Happens-Before v = true。

  • 根据 Volatile 规则:线程 A 的 v = true Happens-Before 线程 B 的 v == true 判定。

  • 根据传递性:a = 42 Happens-Before 线程 B 的 System.out.println(a)。

一旦你互换了顺序,第一条规则就失效了,传递性也就断了。

总结

volatile 变量就像是一个门卫。

  • 原顺序:搬运工先把货(a=42)放进仓库,门卫再锁门(v=true)。你看到锁门了,货一定在。

  • 互换后:门卫先锁门,搬运工才开始搬货。你看到锁门了,仓库里可能还是空的。

这种“先改数据,后改标志位”的模式也被称为 Release-Acquire 语义,是构建无锁编程(Lock-free)的基础。

3.按照 Happens-Before 规则推导:

程序次序规则:操作 1 Happens-Before 操作 2。

volatile 变量规则:操作 2(写 v)Happens-Before 操作 3(读 v)。

传递性:因为 1 -> 2 且 2 -> 3,所以 操作 1 Happens-Before 操作 3。

最终推导:操作 1 也 Happens-Before 操作 4。

结论:即使 a 是一个普通变量,只要线程 B 读到了 v 为 true,它就一定能看到线程 A 对 a 做的修改。这就是所谓的“搭便车”机制。

  1. 回到你的单例模式:Happens-Before 的全家桶 你的 Holder 单例模式其实集齐了 JMM 中最强大的几条保证:

类加载锁(内置同步):JVM 在执行 static final Singleton INSTANCE = new Singleton(); 时,会自动获取一个锁,确保只有一个线程能执行初始化。

初始化可见性:根据“类初始化规则”,这个初始化写入对任何读取 INSTANCE 的线程可见。

final 语义:final 确保了 Singleton 对象内部构造的完整性。

  1. 反思:为什么 DCL (双重检查) 容易写错? 对比一下,很多开发者写的 DCL 为什么会挂:
java

// 错误案例
public static Singleton getInstance() {
    if (instance == null) { 
        // 1. 这里没有 Happens-Before 保证,可能读到半成品
        synchronized (Singleton.class) {
            if (instance == null) {
                instance = new Singleton();
            }
        }
    }
    return instance;
}

由于步骤 1 在同步块之外,它不遵循管程锁定规则。如果没有 volatile 保证,线程在这里看到的 instance 可能因为指令重排,指向了一块还没初始化完的内存(即 Happens-Before 断开了)。

4. 总结:JMM 的核心哲学

JMM 的 Happens-Before 原则实际上是给编译器和 CPU 留出了优化空间,同时给程序员提供了一个逻辑上的“确定性”锚点。

没有规则,就没有可见性。

有了规则,就有了跨线程的顺序保证。

你的 Holder 模式之所以被称为“教科书级”,就是因为它不需要你手动去维护这些复杂的 Happens-Before 链条,而是直接利用了 JVM 最底层、最稳固的类加载协议。

在最新的 Java 版本(如 Java 17、21 乃至 2026 年的现状)中,单例模式的核心逻辑依然稳固,但在性能优化、底层内存模型增强以及新的语言特性方面,有一些非常值得关注的演进。

  1. VarHandle:比 volatile 更细粒度的控制 在 Java 9 之后引入了 VarHandle,它允许开发者以更细粒度的方式处理 Happens-Before 关系。

在传统的 DCL 单例中,我们将变量声明为 volatile。但这会带来一点性能开销,因为每次读取 instance 都会触发内存屏障。使用 VarHandle,我们可以实现**“普通读 + 获取(Acquire)语义写”**,在某些高性能场景下进一步压榨 CPU 性能。

  1. 密封类(Sealed Classes)与单例安全性 Java 17 引入了 Sealed Classes。在以前,为了防止单例类被继承破坏,我们必须使用 final 关键字。 现在,你可以通过 sealed 更加精准地控制继承树。虽然单例通常就是 final,但在复杂的框架设计中,密封类提供了一种“有证可循”的扩展方式,同时保证了单例逻辑不被意外篡改。

  2. Project Loom (虚拟线程) 带来的冲击 Java 21 正式发布了虚拟线程(Virtual Threads)。

以前:线程很贵,单例模式中的 synchronized 锁竞争会导致内核级线程阻塞,开销巨大。

现在:虚拟线程是用户态的,阻塞成本极低。这意味着即使你使用简单的同步方法实现单例,在海量虚拟线程并发时,性能表现也会比以前好得多。

但注意:你的 Holder 模式 依然是虚拟线程时代的首选,因为它完全不阻塞(除了第一次初始化),这完美契合了响应式和高并发的需求。

  1. 永久代(PermGen)消失后的类加载 在旧版本 Java 中,类元数据存在永久代,容易 OOM。现在类信息存在 Metaspace(元空间)。

这一点强化了 Holder 模式的可靠性:即使应用中有大量的单例 Holder,也不会像以前那样容易撑爆内存,因为元空间使用的是本地内存,且回收机制更高效。

最后的思考:为什么你给出的这段代码经久不衰? 即使 Java 版本不断更迭,你开头给出的那 10 行代码依然是面试和实战中的“黄金标准”。它优雅地平衡了 Happens-Before 安全性、懒加载性能 和 代码简洁度。

可以说,理解了这段代码,你就理解了 Java 类加载机制与内存模型的精华。