你写的 DCL 单例,在反序列化面前就是个弟弟——单例模式的破局与重建

单例模式是 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("干点啥");
    }
}

就这一行。枚举实现单例有四个天然优势:

  1. 构造器只能私有------JVM 强制保证
  2. 反射攻击无效 ------Constructor.newInstance 检查到目标类是枚举时会抛 IllegalArgumentException: Cannot reflectively create enum objects
  3. 反序列化安全 ------Java 序列化机制对枚举特殊处理,序列化返回的 INSTANCE 就是同一个对象
  4. 线程安全------枚举实例由 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"));
}

问题来了:

  1. 单例在整个 JVM 生命周期内是同一个对象,测试之间状态会污染
  2. 你没办法在测试中 mock 它的行为(构造器是 private)
  3. 加载文件、连接数据库这些副作用在 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 项目的标准做法。

什么时候该用单例模式?

坦白说------绝大多数时候,你都不该用单例模式

  1. 状态管理 ------用 Spring Bean + @Component,让容器管理生命周期
  2. 全局配置 ------用 application.yml + @ConfigurationProperties
  3. 工具类 ------用 static 方法就够了,类本身就是 final
  4. 需要懒加载 ------用 ObjectFactory<T>Supplier<T>
  5. 需要继承/多态------单例模式根本不支持

真正需要单例模式的场景,已经非常少了。Joshua Bloch 那篇经典的「Singleton」建议(写于 2001 年),在 2024 年之后的 Java 项目里适用度大幅下降。

如果你的项目里有「老旧 DCL 单例」代码,建议这么处理:

  • 如果是枚举能覆盖的(无懒加载需求)→ 改成枚举
  • 如果是配置类 → 改成 @ConfigurationProperties 注入
  • 如果是工具类 → 把方法改成 static,类加 final
  • 如果是连接池/线程池这种重型资源 → 用专门的池化框架(HikariCP、ThreadPoolExecutor),别自己写单例

单例模式的本质教训

设计模式从来不是「学会了 23 种就赢」的游戏。它是对一类问题的成熟解法。单例模式解决的是「如何让一个类只有一个实例 + 全局访问点」。

但这个问题的答案,在 2024 年是:用框架提供的依赖注入,而不是自己写单例

Spring 的 @Component、Guice 的 @Singleton、Dagger 的 @Singleton------这些注解背后的实现都比手写 DCL 健壮得多。框架解决了线程安全、生命周期管理、测试隔离这些「单例模式的副作用」。

你学到的不应该是「单例模式要怎么写」,而是**「为什么我以为需要单例的场景,其实有更好的解法」**。


我做的那个小程序「爪爪代码冒险记」里,单例模式那关讲的是「宇宙唯一的小卖部」------为什么整个宇宙只有一个小卖部?因为卡皮巴拉要开店,但开两家就乱套了。用开店的故事讲单例比讲 DCL 有意思多了,目前还在开发,搜一下「爪爪代码冒险记」能看到。

相关推荐
长栎1 小时前
命令模式和策略模式代码长一样——你分不清是因为你没看穿它们的本质
后端
用户298698530141 小时前
Java Word 文档样式进阶:段落与文本背景色设置完全指南
java·后端
苍何1 小时前
开源个狠活,世界杯 AI 模型竞技场!
后端
Dilee1 小时前
Spring AI 1.1.7 接入 MCP:Filesystem Server 最小 Demo
人工智能·后端
程序员小富1 小时前
我开源了一个开发者专属的智能 JSON 工具,得到了媳妇高度认可
前端·vue.js·后端
苍何1 小时前
深度测评 MiniMax M3,能打但不贵
后端
苍何1 小时前
爆款博主,已经没有秘密了。。。
后端
dunky1 小时前
Spring 的三级缓存与循环依赖
后端·spring