这段代码是一个双重检查锁定(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 底层并非原子操作,它分为三步:
- 分配内存空间
- 初始化对象
- 将引用指向内存地址
如果指令重排序 (2 和 3 可能交换),那么其他线程可能看到 uniqueInstance 不为 null ,但对象尚未初始化完成,从而拿到一个"半成品"对象,导致程序异常。
加上 volatile 后,JVM 会禁止这个重排序,保证"先初始化,再赋值引用",从而完全修复这个 Bug。这就是经典的 DCL 失效问题,在 Java 5 之前即使加 volatile 也无效(因为旧内存模型缺陷),但 Java 5 之后必须用 volatile。
5. 这道题在实际生产中的替代方案
如果面试官问"有没有更简洁的线程安全单例写法",你可以回答:
-
饿汉式(类加载时创建) :
private static final Test INSTANCE = new Test();最简单,但不支持懒加载。 -
静态内部类(推荐) :利用 ClassLoader 机制,既懒加载又线程安全。
javapublic 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 个步骤:
- 分配内存空间(给对象腾块地儿)
- 初始化对象(执行构造方法,给成员变量赋初始值)
- 将引用指向内存地址 (让
uniqueInstance指向这块地)
在没有 volatile 的情况下,编译器和 CPU 为了性能,允许指令重排序。步骤 2 和 步骤 3 的顺序可能会被互换,变成:
- 分配内存空间
- 先将引用指向内存地址 (此时
uniqueInstance已经不为 null 了,但内存里的对象还是"毛坯房") - 最后才去执行构造方法(给毛坯房搞装修)
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 拿着这个"半成品"对象去调用业务方法(比如查里面的
userId或status字段),但这些字段还没被构造方法赋值,全部是默认值(null、0或false)。
最终结果 :你的程序会在完全没有报错的情况下,出现莫名其妙的 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 个步骤:
- 分配内存空间(给对象腾块地儿)
- 初始化对象(执行构造方法,给成员变量赋初始值)
- 将引用指向内存地址 (让
uniqueInstance指向这块地)
在没有 volatile 的情况下,编译器和 CPU 为了性能,允许指令重排序。步骤 2 和 步骤 3 的顺序可能会被互换,变成:
- 分配内存空间
- 先将引用指向内存地址 (此时
uniqueInstance已经不为 null 了,但内存里的对象还是"毛坯房") - 最后才去执行构造方法(给毛坯房搞装修)
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 拿着这个"半成品"对象去调用业务方法(比如查里面的
userId或status字段),但这些字段还没被构造方法赋值,全部是默认值(null、0或false)。
最终结果 :你的程序会在完全没有报错的情况下,出现莫名其妙的 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方法里拿到的是a和b引用的副本,交换副本不影响主方法;并且Integer是不可变类,没有提供setValue()方法。 - 第二步(解题关键) :必须借助 反射(Reflection) ,绕过访问控制,强行修改
Integer内部的private final int value字段。 - 第三步(致命副作用------加分项) :由于
1和2在Integer缓存池(-128~127)范围内,反射把a改为2的同时,把 JVM 缓存中代表数字1的那个单例对象也永久改成了2。所以代码最后打印Integer c = 1;时,结果会是2。这在实际生产环境中是绝对禁止的。
3. 如果面试官问"有没有无副作用的写法?"
如果你觉得反射污染缓存池风险太大,可以展示这两种更"工程化"的思路,显得你考虑周全:
-
方案 A(推荐) :把参数换成
AtomicInteger(可变整数)。javaprivate static void swap(AtomicInteger a, AtomicInteger b) { int temp = a.get(); a.set(b.get()); b.set(temp); } -
方案 B :把
a和b放在数组里交换(利用数组引用不变,改变数组内容)。javaprivate 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) 会不会有安全风险?",你可以答:在安全管理器下可能被拒绝,且破坏封装性,所以实际项目慎用。😊