代码实现:

public class Single {
    // 饿汉式写法
 /*   private  static  final  Single single = new Single();
    private  Single() {}

    public static Single getInstance() {
        System.out.println("获取到了变量");
        return single;
    }*/
    // 懒汉式写法
    private static  volatile Single single ;
    private  Single(){
        if(single != null){
            throw  new RuntimeException("不允许创建多个实例");
        }
    }

    public static Single getInstance() {
        if(single == null){
            synchronized (Single.class){
                if(single == null){
                    single = new Single();
                }
            }
        }
        return single;
    }

}

问题根源:指令重排序

问题的核心在于 single = new Single(); 这行代码。它并不是一个原子操作,在 JVM 中实际上被分解为三个步骤:

  1. 分配内存空间:为新的 Single 对象分配一块内存。

  2. 初始化对象:调用构造函数,初始化对象内部的字段。

  3. 赋值引用:将 single 变量指向第一步分配好的内存地址。

然而,在现代 JVM 和 CPU 架构中,为了优化性能,指令重排序(Instruction Reordering) 是允许的。只要程序的最终结果一致,JVM 和 CPU 可能会改变指令的执行顺序。

一个可能发生的重排序是:步骤 3 和步骤 2 被交换了顺序。即:

  1. 分配内存空间。

  2. single 变量指向内存地址(此时 single 不为 null,但对象还未初始化!)

  3. 初始化对象。

没有 volatile 时会发生什么?

现在我们来看一下,如果没有 volatile,在多线程环境下会发生什么:

  1. 线程 A 第一次进入 getInstance(),发现 single == null,获得锁,进入同步块。

  2. 线程 A 再次检查 single == null,开始执行 single = new Single();

  3. 由于指令重排序,线程 A 先将未完全构造好的 Single 实例的地址赋值给了 single 变量(步骤 3先于步骤 2 执行)。此时 single 已经不是 null 了,但对象内部的字段可能还是默认值(0, false, null),构造函数还没有执行完毕。

  4. 此时,线程 B 也调用 getInstance()。它进行第一次检查 if (single == null),发现 single 已经不是 null 了(因为线程 A 已经执行了重排序后的步骤 3)!

  5. 线程 B 于是直接返回 single 所指向的那个“半成品”对象

  6. 线程 B 使用这个未完全初始化的对象,可能会导致不可预料的错误(如空指针异常、状态不一致等)。

volatile 如何解决这个问题?

当你将变量声明为 volatile 时:

java

private static volatile Single single;

你做了两件非常重要的事情:

  1. 禁止指令重排序:对 volatile 变量的写操作,会在其前后插入内存屏障(Memory Barrier)。这意味着 JVMCPU 必须保证,在 single 变量被赋值之前,new Single() 的构造函数必须已经完全执行完毕(即步骤 1->2->3 的顺序得到保证)。写操作之前的任何指令都不能被重排序到写操作之后。

  2. 保证可见性:一个线程对 volatile 变量的修改,会立即被其他线程看到。这确保了所有线程都能拿到最新的、正确的值。

加入了 volatile 关键字后,执行流程就变得安全了:

  • 线程 A 完整地构造好 Single 对象之后,才会将引用赋值给 single

  • 线程 B 在第一次检查时,要么看到 singlenull,要么看到的是一个已经完全初始化好的对象,绝不会看到一个中间状态。

总结

场景

没有 volatile

volatile

指令顺序

分配内存 -> 赋值引用 -> 初始化(可能重排序)

分配内存 -> 初始化 -> 赋值引用(顺序固定)

线程 B 第一次检查

可能看到 single 非 null,但指向一个半初始化的对象

看到 single 为 null,或指向一个完全初始化的对象

结果

bug,线程不安全

安全,线程安全

因此,对于所有使用双重检查锁定(DCL) 实现的懒汉式单例模式,必须将单例实例声明为 volatile,以防止因指令重排序导致的诡异并发 bug。你看到的 IDE 或代码检查工具的提示是非常专业和正确的。

补充:更好的替代方案

虽然 DCL + volatile 是有效的,但在 Java 中,实现线程安全的单例还有更简洁、更被推崇的方式:

  1. 静态内部类(Holder)方式(推荐):

    java

    public class Single {
        private Single() {
            if (Holder.INSTANCE != null) {
                throw new RuntimeException("不允许创建多个实例");
            }
        }
        
        public static Single getInstance() {
            return Holder.INSTANCE;
        }
        
        private static class Holder {
            private static final Single INSTANCE = new Single();
        }
    }

    这种方式利用了 JVM 的类加载机制来保证初始化实例时的线程安全性,且无需同步开销,是懒加载的。

  2. 枚举(Enum)方式(最安全,防反射攻击):

    java

    public enum Single {
        INSTANCE;
        
        // 可以添加方法
        public void doSomething() {
            // ...
        }
    }

    这是《Effective Java》作者 Josh Bloch 大力推荐的方式,它能绝对防止多次实例化,即使是面对复杂的序列化或者反射攻击。