1.4 面试题 双重检查锁

这段代码是一个双重检查锁定(Double-Checked Locking) 的单例模式实现,目的是延迟初始化线程安全 地获取 Test 类的唯一实例。

不过,这份代码有明显的残缺和隐患 ,我先把完整正确的版本给你,再逐条拆解它的设计意图和"坑"。


1. 这段代码原本想做什么?

java 复制代码
package swap;

public class Test {
    private volatile static Test uniqueInstance;  // 1. 静态成员变量

    private Test() {}  // 2. 私有构造器(你的代码里漏掉了!)

    public static Test getInstance() {            // 3. 全局访问点
        if (uniqueInstance == null) {             // 第一次检查(无锁)
            synchronized (Test.class) {           // 类锁
                if (uniqueInstance == null) {     // 第二次检查(有锁)
                    uniqueInstance = new Test();  // 真正创建
                }
            }
        }
        return uniqueInstance;
    }
}

2. 为什么需要"双重检查"?它解决了什么问题?

  • 懒加载(Lazy Initialization) :只有第一次调用 getInstance() 时才创建实例,不用提前占内存。
  • 线程安全:多线程环境下,保证只有一个实例被创建。
  • 性能优化 :实例创建后,后续所有线程直接走 if (uniqueInstance == null) 返回,不再进入同步块 ,避免了每次调用都加锁的性能开销(相比 synchronized 修饰整个方法)。

3. 你的代码里缺失了什么?(关键缺陷)

你粘贴的代码里,private 下面直接跟了 Extract Surround 等乱码,说明原文件可能被 IDE 误操作了。实际缺失的是 private Test() {} 私有构造器 。如果没有私有构造器,外部就可以 new Test(),单例就被破坏了。


4. volatile 在这里是必须的吗?(面试高频考点)

必须加! 原因是 new Test() 在 JVM 底层并非原子操作,它分为三步:

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

如果指令重排序 (2 和 3 可能交换),那么其他线程可能看到 uniqueInstance 不为 null ,但对象尚未初始化完成,从而拿到一个"半成品"对象,导致程序异常。

加上 volatile 后,JVM 会禁止这个重排序,保证"先初始化,再赋值引用",从而完全修复这个 Bug。这就是经典的 DCL 失效问题,在 Java 5 之前即使加 volatile 也无效(因为旧内存模型缺陷),但 Java 5 之后必须用 volatile。


5. 这道题在实际生产中的替代方案

如果面试官问"有没有更简洁的线程安全单例写法",你可以回答:

  • 饿汉式(类加载时创建)private static final Test INSTANCE = new Test(); 最简单,但不支持懒加载。

  • 静态内部类(推荐) :利用 ClassLoader 机制,既懒加载又线程安全。

    java 复制代码
    public class Test {
        private Test() {}
        private static class Holder {
            private static final Test INSTANCE = new Test();
        }
        public static Test getInstance() {
            return Holder.INSTANCE;
        }
    }
  • 枚举(最安全)enum TestEnum { INSTANCE; } 可防止反射攻击,但不够灵活。


6. 你的代码末尾的"578."是什么?

那可能是你 IDE 或笔记软件的行号标记,或者从某个题集里复制时带出的题号,不是代码的一部分,忽略即可。


总结面试回答 :这段代码是双重检查锁定的单例模式,利用 volatile 防止指令重排序,双重 if 保证线程安全且性能优化;但需要补全私有构造器,否则单例被破坏。更推荐使用静态内部类方式,更简洁且无锁。

如果你还想了解如何用反射或序列化破坏单例以及如何防御 ,我可以接着给你展开。😊

去掉 volatile 会怎样?------会导致程序在极端情况下返回一个"半成品"对象,直接引发诡异且极难复现的 Bug(比如空指针或逻辑错乱)。

这是 Java 并发编程中最经典的"坑",我带你一步步还原案发现场:

1. 去掉 volatile 后,底层发生了什么?

uniqueInstance = new Test(); 在 JVM 底层并非原子操作,它会被拆成 3 个步骤:

  1. 分配内存空间(给对象腾块地儿)
  2. 初始化对象(执行构造方法,给成员变量赋初始值)
  3. 将引用指向内存地址 (让 uniqueInstance 指向这块地)

在没有 volatile 的情况下,编译器和 CPU 为了性能,允许指令重排序。步骤 2 和 步骤 3 的顺序可能会被互换,变成:

  1. 分配内存空间
  2. 先将引用指向内存地址 (此时 uniqueInstance 已经不为 null 了,但内存里的对象还是"毛坯房")
  3. 最后才去执行构造方法(给毛坯房搞装修)

2. 加上多线程,现场一片混乱

假设线程 A 和线程 B 同时调用 getInstance(),按照以下时序走:

  • 时刻 1 :线程 A 进入 synchronized 块,执行 new Test()。由于指令重排序,JVM 先分配了内存,并把 uniqueInstance 指向了该地址(此时 uniqueInstance != null),但还没来得及调用构造方法初始化(装修还没搞完)。
  • 时刻 2 :就在这个毫秒级的间隙,线程 B 也来调用 getInstance()
  • 时刻 3 :线程 B 执行第一层 if (uniqueInstance == null),发现 uniqueInstance 不为 null (因为它已经指向地址了),于是线程 B 直接跳过同步块return uniqueInstance;
  • 时刻 4 :线程 B 拿着这个"半成品"对象去调用业务方法(比如查里面的 userIdstatus 字段),但这些字段还没被构造方法赋值,全部是默认值(null0false)。

最终结果 :你的程序会在完全没有报错的情况下,出现莫名其妙的 NullPointerException 或业务逻辑计算错误。而且这种 Bug 只在 CPU 负载极高、线程切换极其频繁的生产环境偶尔出现,在本地 Debug 时几乎复现不了。


3. 加上 volatile 为什么就修好了?

volatile 关键字在这里的作用是禁止指令重排序 。它会在 uniqueInstance = new Test(); 这行代码周围加一道内存屏障(Memory Barrier) ,强制 JVM 必须按部就班:先分配内存 -> 再初始化对象 -> 最后才把引用指向地址

这就保证了线程 B 永远不可能看到指向地址但未初始化的对象,彻底堵死了这个漏洞。


4. 结合你之前"查日志"的运维经验

如果你们线上代码有人去掉了 volatile 却上线了,你在排查日志时会非常痛苦:

  • 你会看到日志里打印出了 uniqueInstance 的地址(HashCode),说明对象已经创建。
  • 但调用它的字段时,却报 NullPointerException
  • 你会怀疑是不是 SQL 查错了、Redis 超时了,甚至怀疑日志印错了,绝对想不到是对象没初始化完

所以,这道题面试官最想听到的核心回答就是:去掉 volatile 会导致 DCL(双重检查锁定)失效,返回半成品对象,产生不可预测的运行时异常。


补充一个小细节 :这个 Bug 在 Java 1.4 及以前 极其猖獗,因为当时即使加了 volatile 也无法完全禁止重排序(旧内存模型缺陷)。直到 Java 5(JSR-133) 之后,加强了对 volatile 的语义,才彻底解决了这个问题。所以如果你面试时提到这一层,会非常加分。😊

去掉 volatile 会怎样?------会导致程序在极端情况下返回一个"半成品"对象,直接引发诡异且极难复现的 Bug(比如空指针或逻辑错乱)。

这是 Java 并发编程中最经典的"坑",我带你一步步还原案发现场:

1. 去掉 volatile 后,底层发生了什么?

uniqueInstance = new Test(); 在 JVM 底层并非原子操作,它会被拆成 3 个步骤:

  1. 分配内存空间(给对象腾块地儿)
  2. 初始化对象(执行构造方法,给成员变量赋初始值)
  3. 将引用指向内存地址 (让 uniqueInstance 指向这块地)

在没有 volatile 的情况下,编译器和 CPU 为了性能,允许指令重排序。步骤 2 和 步骤 3 的顺序可能会被互换,变成:

  1. 分配内存空间
  2. 先将引用指向内存地址 (此时 uniqueInstance 已经不为 null 了,但内存里的对象还是"毛坯房")
  3. 最后才去执行构造方法(给毛坯房搞装修)

2. 加上多线程,现场一片混乱

假设线程 A 和线程 B 同时调用 getInstance(),按照以下时序走:

  • 时刻 1 :线程 A 进入 synchronized 块,执行 new Test()。由于指令重排序,JVM 先分配了内存,并把 uniqueInstance 指向了该地址(此时 uniqueInstance != null),但还没来得及调用构造方法初始化(装修还没搞完)。
  • 时刻 2 :就在这个毫秒级的间隙,线程 B 也来调用 getInstance()
  • 时刻 3 :线程 B 执行第一层 if (uniqueInstance == null),发现 uniqueInstance 不为 null (因为它已经指向地址了),于是线程 B 直接跳过同步块return uniqueInstance;
  • 时刻 4 :线程 B 拿着这个"半成品"对象去调用业务方法(比如查里面的 userIdstatus 字段),但这些字段还没被构造方法赋值,全部是默认值(null0false)。

最终结果 :你的程序会在完全没有报错的情况下,出现莫名其妙的 NullPointerException 或业务逻辑计算错误。而且这种 Bug 只在 CPU 负载极高、线程切换极其频繁的生产环境偶尔出现,在本地 Debug 时几乎复现不了。


3. 加上 volatile 为什么就修好了?

volatile 关键字在这里的作用是禁止指令重排序 。它会在 uniqueInstance = new Test(); 这行代码周围加一道内存屏障(Memory Barrier) ,强制 JVM 必须按部就班:先分配内存 -> 再初始化对象 -> 最后才把引用指向地址

这就保证了线程 B 永远不可能看到指向地址但未初始化的对象,彻底堵死了这个漏洞。


4. 结合你之前"查日志"的运维经验

如果你们线上代码有人去掉了 volatile 却上线了,你在排查日志时会非常痛苦:

  • 你会看到日志里打印出了 uniqueInstance 的地址(HashCode),说明对象已经创建。
  • 但调用它的字段时,却报 NullPointerException
  • 你会怀疑是不是 SQL 查错了、Redis 超时了,甚至怀疑日志印错了,绝对想不到是对象没初始化完

所以,这道题面试官最想听到的核心回答就是:去掉 volatile 会导致 DCL(双重检查锁定)失效,返回半成品对象,产生不可预测的运行时异常。


补充一个小细节 :这个 Bug 在 Java 1.4 及以前 极其猖獗,因为当时即使加了 volatile 也无法完全禁止重排序(旧内存模型缺陷)。直到 Java 5(JSR-133) 之后,加强了对 volatile 的语义,才彻底解决了这个问题。所以如果你面试时提到这一层,会非常加分。😊

当然可以!这道题是经典的"Java 值传递 + 不可变对象"面试陷阱,标准答案必须用反射

面试时,你直接写出下面这份代码,并随口提一句"注意 Integer 缓存池的副作用",基本就是满分回答。

1. 面试现场可直接写的完整代码

java 复制代码
import java.lang.reflect.Field;

public class SwapInteger {
    public static void main(String[] args) throws Exception {
        Integer a = 1;
        Integer b = 2;
        System.out.println("交换前: " + a + "---" + b); // 输出 1---2

        swap(a, b);

        System.out.println("交换后: " + a + "---" + b); // 输出 2---1

        // 【加分项验证】由于反射修改了缓存,后续获取 1 会变成 2
        Integer c = 1;
        System.out.println("缓存池里的 1 现在变成了: " + c); // 输出 2(惊不惊喜)
    }

    private static void swap(Integer a, Integer b) throws Exception {
        // 1. 获取 Integer 内部的 value 字段(private final)
        Field field = Integer.class.getDeclaredField("value");
        // 2. 暴力破解私有权限
        field.setAccessible(true);

        // 3. 交换值(使用 field.getInt 避免自动拆箱干扰)
        int temp = field.getInt(a);          // 暂存 a 的值(1)
        field.setInt(a, field.getInt(b));    // 把 b 的值(2)赋给 a
        field.setInt(b, temp);               // 把暂存值(1)赋给 b
    }
}

2. 如果面试官追问"为什么要这么写?"(你的回答思路)

  • 第一步(分析失败原因) :直接写 int temp = a; a = b; b = temp; 是无效的。因为 Java 只有值传递,swap 方法里拿到的是 ab 引用的副本,交换副本不影响主方法;并且 Integer 是不可变类,没有提供 setValue() 方法。
  • 第二步(解题关键) :必须借助 反射(Reflection) ,绕过访问控制,强行修改 Integer 内部的 private final int value 字段。
  • 第三步(致命副作用------加分项) :由于 12Integer 缓存池(-128~127)范围内,反射把 a 改为 2 的同时,把 JVM 缓存中代表数字 1 的那个单例对象也永久改成了 2 。所以代码最后打印 Integer c = 1; 时,结果会是 2。这在实际生产环境中是绝对禁止的。

3. 如果面试官问"有没有无副作用的写法?"

如果你觉得反射污染缓存池风险太大,可以展示这两种更"工程化"的思路,显得你考虑周全:

  • 方案 A(推荐) :把参数换成 AtomicInteger(可变整数)。

    java 复制代码
    private static void swap(AtomicInteger a, AtomicInteger b) {
        int temp = a.get();
        a.set(b.get());
        b.set(temp);
    }
  • 方案 B :把 ab 放在数组里交换(利用数组引用不变,改变数组内容)。

    java 复制代码
    private static void swap(Integer[] arr) {
        int temp = arr[0];
        arr[0] = arr[1];
        arr[1] = temp;
    }
    // 调用时:swap(new Integer[]{a, b});

面试小贴士 :写代码时,记得加上 throws Exception 处理反射的检查异常,否则编译不通过。如果面试官问"setAccessible(true) 会不会有安全风险?",你可以答:在安全管理器下可能被拒绝,且破坏封装性,所以实际项目慎用。😊