主题
单例与建造者:对象创建的两个极端
本文是设计模式系统学习系列的 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.StringBuilder、okhttp3.Request.Builder。