单例模式是 23 种 GoF 模式里最简单的一个。但你写的那种「双重检查锁 + volatile」版本,能被反射三行代码击穿,能被反序列化直接绕过,枚举单例才是 Java 唯一安全的实现------而 Spring 早就不用单例模式了。
面试时我让候选人写一个单例模式,10 个里有 9 个会写出这种代码:
java
public class Singleton {
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 防止指令重排,synchronized 保证原子性,两次 null 检查避免每次都加锁。教科书上标准的 DCL(Double-Checked Locking)实现。
我接着问:「这代码有什么问题?」
候选人通常会愣一下------这不就是标准答案吗?
问题是:DCL 单例在反序列化面前完全不设防。
反序列化能直接绕开你的私有构造器
如果你的单例实现了 Serializable 接口,反序列化会绕过 private 构造器,直接创建新对象:
java
public class Singleton implements Serializable {
private static final Singleton INSTANCE = new Singleton();
private Singleton() {}
public static Singleton getInstance() { return INSTANCE; }
}
// 攻击代码
Singleton s1 = Singleton.getInstance();
Singleton s2 = (Singleton) serializeAndDeserialize(s1);
System.out.println(s1 == s2); // false!两个不同对象
// 单例被破坏,反序列化时构造器根本没被调用
为什么?因为反序列化机制是通过 readObject 反射创建对象的,跟你写不写 private 构造器没关系。攻击者拿到你的字节码文件,序列化、再反序列化,就能得到第二个实例。
修补方案:加一个 readResolve 方法:
java
private Object readResolve() {
return INSTANCE; // 反序列化时直接返回这个
}
但这是补丁,不是设计。如果当时写代码的人没加 readResolve 呢?这个坑你永远不会知道。
反射三行代码打穿 private 构造器
更狠的是反射:
java
Constructor<Singleton> constructor = Singleton.class.getDeclaredConstructor();
constructor.setAccessible(true); // 强行访问 private
Singleton s1 = Singleton.getInstance();
Singleton s2 = constructor.newInstance();
System.out.println(s1 == s2); // false,单例被破坏
setAccessible(true) 一调,private 形同虚设。这不是漏洞,是 Java 反射的设计------所有访问修饰符都对反射开放。
DCL 单例的「私有构造器防 new」这件事,在反射面前就是个笑话。Spring 在某些场景下就是这么干的------通过反射调用私有构造器。
枚举单例:Java 唯一真正安全的实现
《Effective Java》的作者 Joshua Bloch 给出的终极方案:用枚举实现单例。
java
public enum Singleton {
INSTANCE;
public void doSomething() {
System.out.println("干点啥");
}
}
就这一行。枚举实现单例有四个天然优势:
- 构造器只能私有------JVM 强制保证
- 反射攻击无效 ------
Constructor.newInstance检查到目标类是枚举时会抛IllegalArgumentException: Cannot reflectively create enum objects - 反序列化安全 ------Java 序列化机制对枚举特殊处理,序列化返回的
INSTANCE就是同一个对象 - 线程安全------枚举实例由 JVM 在类加载时创建,天然线程安全
Joshua Bloch 是 Java Collections 框架的作者,他的话可以信。枚举实现的「单例」在 Java 里被认为是最严谨的版本。
唯一的缺点:不能懒加载 。枚举的实例在类被加载时就创建了,不是第一次调用 getInstance() 时才创建。如果初始化这个对象的代价很高(比如要加载大文件、建立连接),枚举就不合适了。
但现实是,大多数单例的初始化代价都很低------一个空对象、一个配置 holder------枚举单例可以覆盖 90% 的场景。
Spring 的「单例」不是单例模式
很多人用 Spring,会觉得 @Component 默认就是单例,单例模式有什么难的?
java
@Service
public class UserService {
// Spring 帮你保证了单例
}
Spring 的单例和单例模式是两回事。
GoF 单例模式的核心是:这个类的构造器由类自己控制 。调用方想拿实例,必须通过 getInstance(),不能自己 new。
Spring 的 Bean 是容器管理的 ------Bean 的生命周期由 ApplicationContext 掌控,调用方从容器里 getBean。如果跳过容器直接 new UserService(),得到的对象是「脱离 Spring 管理的野生对象」,没有依赖注入、没有 AOP、没有事务增强。
java
// 错误:在 Spring 项目里手动 new
public class OrderService {
@Autowired
private UserService userService; // 这个 userService 可能是 null
public void create() {
UserService u = new UserService(); // 野生对象,依赖全没注入
u.save(); // NPE 风险
}
}
更隐蔽的:在 Spring 项目里 new 出来的对象,如果它依赖的 Service 也是 Spring 管理的,那些 Service 的 @Transactional 注解会失效------因为没走代理。所以严格来说,Spring 没有真正的单例模式。
Spring 4.x 之后提供了一种更接近单例模式的写法:@Configuration + @Bean:
java
@Configuration
public class AppConfig {
@Bean
public DataSource dataSource() {
return new HikariDataSource(...); // Spring 保证这个方法只被调用一次
}
}
Spring 对 @Configuration 类做 CGLIB 增强,多次调用 dataSource() 拿到的都是同一个对象。这是 Spring 内部实现单例的方式。
单例的真正问题:测试困难
单例模式最大的缺点不是线程安全,是测试困难。
java
public class ConfigManager {
private static final ConfigManager INSTANCE = new ConfigManager();
private Map<String, String> config;
private ConfigManager() {
this.config = loadFromFile(); // 从文件加载配置
}
public String get(String key) {
return config.get(key);
}
}
现在你想写单元测试:
java
@Test
void testGet() {
ConfigManager config = ConfigManager.getInstance();
assertEquals("prod", config.get("env"));
}
问题来了:
- 单例在整个 JVM 生命周期内是同一个对象,测试之间状态会污染
- 你没办法在测试中 mock 它的行为(构造器是 private)
- 加载文件、连接数据库这些副作用在
static初始化时就会执行,测试根本控制不了
替代方案:依赖注入。
java
@Component
public class ConfigManager {
private final Map<String, String> config;
public ConfigManager(@Value("${config.path}") String path) {
this.config = loadFromFile(path);
}
public String get(String key) { return config.get(key); }
}
测试时直接 new ConfigManager("test-config.yml"),每个测试用例拿到的是独立实例,互不干扰。这就是 Spring 项目的标准做法。
什么时候该用单例模式?
坦白说------绝大多数时候,你都不该用单例模式。
- 状态管理 ------用 Spring Bean +
@Component,让容器管理生命周期 - 全局配置 ------用
application.yml+@ConfigurationProperties - 工具类 ------用
static方法就够了,类本身就是 final - 需要懒加载 ------用
ObjectFactory<T>或Supplier<T> - 需要继承/多态------单例模式根本不支持
真正需要单例模式的场景,已经非常少了。Joshua Bloch 那篇经典的「Singleton」建议(写于 2001 年),在 2024 年之后的 Java 项目里适用度大幅下降。
如果你的项目里有「老旧 DCL 单例」代码,建议这么处理:
- 如果是枚举能覆盖的(无懒加载需求)→ 改成枚举
- 如果是配置类 → 改成
@ConfigurationProperties注入 - 如果是工具类 → 把方法改成
static,类加final - 如果是连接池/线程池这种重型资源 → 用专门的池化框架(HikariCP、ThreadPoolExecutor),别自己写单例
单例模式的本质教训
设计模式从来不是「学会了 23 种就赢」的游戏。它是对一类问题的成熟解法。单例模式解决的是「如何让一个类只有一个实例 + 全局访问点」。
但这个问题的答案,在 2024 年是:用框架提供的依赖注入,而不是自己写单例。
Spring 的 @Component、Guice 的 @Singleton、Dagger 的 @Singleton------这些注解背后的实现都比手写 DCL 健壮得多。框架解决了线程安全、生命周期管理、测试隔离这些「单例模式的副作用」。
你学到的不应该是「单例模式要怎么写」,而是**「为什么我以为需要单例的场景,其实有更好的解法」**。
我做的那个小程序「爪爪代码冒险记」里,单例模式那关讲的是「宇宙唯一的小卖部」------为什么整个宇宙只有一个小卖部?因为卡皮巴拉要开店,但开两家就乱套了。用开店的故事讲单例比讲 DCL 有意思多了,目前还在开发,搜一下「爪爪代码冒险记」能看到。