Skip to content

单例与建造者:对象创建的两个极端

本文是设计模式系统学习系列的 L2 核心篇。前置:设计模式全景与选型。 学完可以配合面试题食用:DCL 单例为什么要 volatile

为什么把这两个放一起

单例和建造者都管对象创建,但方向完全相反。单例说"一个类全局只能有一个实例",建造者说"一个对象参数太多,split 成几步构造"。看似无关,实际互补——单例解决"全局唯一"的约束,建造者解决"复杂构造"的难题。放在同一篇讲,是因为实际工程里经常同时用到:一个配置管理器(单例)本身就可能用建造者模式来构建其配置对象。

单例五种写法与演进

单例的核心需求:一个类最多只有一个实例,并提供全局访问点。但"怎么写对"在 Java 里踩了 20 年坑。五种主流写法,按实战推荐度排序:

1. 饿汉式

java
public class EagerSingleton {
    private static final EagerSingleton INSTANCE = new EagerSingleton();
    private EagerSingleton() {}
    public static EagerSingleton getInstance() { return INSTANCE; }
}

类加载时就把实例造好,最简单。缺点:不管用不用都占内存,且无法传参构造。

2. 懒汉式 + synchronized

java
public class LazySingleton {
    private static LazySingleton instance;
    private LazySingleton() {}
    public static synchronized LazySingleton getInstance() {
        if (instance == null) instance = new LazySingleton();
        return instance;
    }
}

用到才创建,但每次 getInstance() 都要抢锁,高并发下性能差。

3. DCL(Double-Checked Locking)+ volatile

java
public class DclSingleton {
    private static volatile DclSingleton instance;
    private DclSingleton() {}
    public static DclSingleton getInstance() {
        if (instance == null) {
            synchronized (DclSingleton.class) {
                if (instance == null) {
                    instance = new DclSingleton();
                }
            }
        }
        return instance;
    }
}

外层 null 判断避免无谓加锁,内层 null 判断防止并发穿过的线程重复创建。volatile 禁止 instance = new DclSingleton() 的指令重排序——没有 volatile 的话,JVM 可能在构造方法执行完之前就把引用赋值出去了,另一个线程拿到半成品对象。

4. 静态内部类(推荐)

java
public class HolderSingleton {
    private HolderSingleton() {}
    private static class Holder {
        static final HolderSingleton INSTANCE = new HolderSingleton();
    }
    public static HolderSingleton getInstance() { return Holder.INSTANCE; }
}

利用 JVM 类加载机制:Holder 只在首次调用 getInstance() 时才加载并初始化,天然懒加载 + 线程安全。没有锁也没有 volatile,是最轻量的懒汉写法。

5. 枚举(最强防御)

java
public enum EnumSingleton {
    INSTANCE;
    public void doSomething() { /* ... */ }
}

《Effective Java》第一条推荐。枚举天然防反射(反射 Constructor.newInstance() 对枚举类抛异常),防反序列化(readObject() 返回同一个实例)。缺点:不够灵活,不能继承,启动时初始化。

java
// 反射攻击测试
Constructor<?> constructor = Singleton.class.getDeclaredConstructor();
constructor.setAccessible(true);
Singleton s2 = (Singleton) constructor.newInstance(); // 对枚举版抛 java.lang.IllegalArgumentException

单例的工程争议

GoF 单例模式在 Spring 时代被广泛讨论,核心争议两点:

  • 全局状态难测试:单例持有可变更状态时,测试用例间互相污染。典型反例:一个 CounterSingleton 混在多个测试里,每个测试跑完不重置,下一个测试读到的值就错了。
  • Spring 单例 Bean 不是 GoF 单例:Spring 容器保证一个类只创建一个 Bean 实例,但 JVM 层面可以 new 多次。这是"容器内单实例",不是类加载器级别的单例。Spring 单例 Bean 不需要你写 getInstance(),容器注入即可。

工程建议:需要 JVM 级别唯一(如 全局 ID 生成器、日志工厂)用枚举单例;需要容器管理(如 Service 层)用 Spring 单例 Bean,不需要自己手动实现单例。

建造者:对付多参数构造

当构造方法参数超过 4 个,或者有大量可选参数时,Java 的传统做法是"重叠构造器"(telescoping constructor):

java
public class ConnectionConfig {
    private String host;      // 必填
    private int port;         // 必填
    private String username;  // 可选
    private String password;  // 可选
    private int timeout;      // 可选,默认 5000
    private boolean ssl;      // 可选,默认 false

    // 组合爆炸:4 个可选参数就 2^4 = 16 个构造器
    public ConnectionConfig(String host, int port) { ... }
    public ConnectionConfig(String host, int port, String username) { ... }
    public ConnectionConfig(String host, int port, int timeout) { ... }
    // ...
}

这种代码开发时痛苦,调用时更痛苦——传参顺序靠记忆,IDE 提示不友好,少传一个就编译不过。

手写 Builder

java
public class ConnectionConfig {
    private final String host;
    private final int port;
    private final String username;
    private final int timeout;
    private final boolean ssl;

    private ConnectionConfig(Builder builder) {
        this.host = builder.host;
        this.port = builder.port;
        this.username = builder.username;
        this.timeout = builder.timeout;
        this.ssl = builder.ssl;
    }

    public static class Builder {
        private String host;
        private int port;
        private String username = "";
        private int timeout = 5000;
        private boolean ssl = false;

        public Builder(String host, int port) {
            this.host = host;
            this.port = port;
        }
        public Builder username(String val) { this.username = val; return this; }
        public Builder timeout(int val) { this.timeout = val; return this; }
        public Builder ssl(boolean val) { this.ssl = val; return this; }
        public ConnectionConfig build() { return new ConnectionConfig(this); }
    }
}

// 调用
ConnectionConfig config = new ConnectionConfig.Builder("db.example.com", 3306)
    .username("admin")
    .timeout(3000)
    .ssl(true)
    .build();

Builder 的核心好处:命名参数.username("admin") 比靠位置猜哪个参数是 username 好得多)和不变对象build() 后对象不可变,线程安全)。

LomBok @Builder 与手写对比

java
@Builder
public class ConnectionConfig {
    private String host;
    private int port;
    private String username;
    private int timeout;
    private boolean ssl;
}

Lombok 一行注解搞定,但生成的 Builder 有几个小差异:

  • 必填字段不强制:Lombok Builder 不提供 "必填参数放构造器" 的方式,所有字段都是 setter 式。手写可以用 Builder(String host, int port) 强制必填。
  • 默认值问题:Lombok 生成的 Builder 初始值全是 null/0/false,不会读取字段初始值,除非加 @Builder.Default

真实工程实证StringBuilder 就是 Builder 模式——一堆 append() 返回 this 链式调用,最后 toString() 就是 build()OkHttp Request.Builder 也是经典案例:.url() / .header() / .post() 链式构建不可变的 Request 对象。

常见误区与小结

  • 单例抛 MultiThread 问题:懒汉式不加锁在多线程下会创建多个实例,不是"概率性出问题"而是必然,用 Holder 或枚举一把解决。
  • DCL 省略 volatile:去掉 volatile 的 DCL 在低并发下可能一直不出错,但 JVM 指令重排是确定的,一旦触发就是隐式 bug,极难复现。
  • 单例 + 序列化破坏单例性:实现 Serializable 的单例类必须加 readResolve() 方法,否则反序列化生成新实例。枚举天然免疫。
  • Builder 不是万能:参数少于 3 个时用 Builder 反而累赘,IDE 生成的构造器就够用。
  • Builder 对象可变的陷阱:Builder 本身是可变的,调用 build() 后继续修改 Builder 不会影响已构建的对象——这是设计意图,但团队里有人没意识到的话会困惑。

小结:单例和建造者分别解决"创建唯一"和"创建复杂"两个极端问题。单例用枚举一把终结了 20 年的线程安全/反射/序列化争论;建造者用链式 API 解决了多参数构造的可读性危机。下一篇进入代理与装饰器,看看"包一层"的两种完全不同意图。

参考

参考:《Effective Java》第三版 Item 3(枚举单例)与 Item 2(Builder)。JDK 源码:java.lang.StringBuilderokhttp3.Request.Builder

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。
粤ICP备2026104257号-1