创建型模式
开篇:创建对象也有讲究
写 Java 的人每天都在 new 对象,但你有没有想过,有些对象真的不能随便 new?
- 数据库连接池——你总不能每来一个请求就
new一个连接吧?那服务器分分钟被撑爆。 - 一个参数有 20 个字段的配置对象——构造方法的参数列表长到换行三次,谁看得懂?
- 缓存中查出来的对象——直接返回原对象,万一调用方把它改了,所有人都拿到脏数据。
创建型模式就是来解决这些问题的。它们的核心思想是:把对象的创建过程和使用过程分离,让使用者不需要关心"对象是怎么造出来的",只需要关心"拿到手能怎么用"。
创建型模式一共有五种:
下面我们逐一拆解。
一、单例模式
什么是单例?
生活类比:一个国家只有一个总统,一个学校只有一个校长。不管你在哪里提到"校长",指的都是同一个人。不会每次有人找校长,就任命一个新校长出来。
在代码中,单例模式保证一个类在整个应用程序中只有一个实例,并提供一个全局访问点来获取它。
单例的三个核心要素:
- 构造器私有 —— 不允许外部
new - 自己创建实例 —— 类内部负责创建
- 对外提供访问方法 —— 通常是一个
static方法
使用场景:
- 多线程中的线程池、数据库连接池
- 系统配置信息(如
System.getProperties()) - Spring 中的 Bean 默认就是单例
五种经典实现方式
1. 饿汉式(静态成员变量)
"饿汉"的意思是"我很饿,一上来就要吃"——类一加载就创建实例,不管你用不用。
public class Singleton {
// 1. 私有构造
private Singleton() {}
// 2. 类加载时就创建实例
private static final Singleton INSTANCE = new Singleton();
// 3. 对外提供获取方法
public static Singleton getInstance() {
return INSTANCE;
}
}还有一种变体是用静态代码块,效果完全相同:
public class Singleton {
private Singleton() {}
private static Singleton instance;
static {
instance = new Singleton();
}
public static Singleton getInstance() { return instance; }
}优点:简单、线程安全(JVM 类加载机制天然保证)。
缺点:不管你用不用,对象都会被创建,如果初始化很重可能浪费资源。
2. 懒汉式(线程不安全)
"懒汉"的意思是"我很懒,你不叫我我就不动"——第一次调用 getInstance() 时才创建。
public class Singleton {
private Singleton() {}
private static Singleton instance;
public static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}问题:多线程环境下,线程 A 和线程 B 同时通过 if (instance == null) 判断,都认为是 null,于是各创建了一个实例——单例被破坏了。
3. 懒汉式(synchronized 加锁)
最直接的修复:给整个方法加锁。
public static synchronized Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}问题:虽然安全了,但每次获取实例都要抢锁。实例创建好之后,后续全是读操作,根本不需要加锁——锁粒度太粗了。
4. 双重检查锁(DCL,面试高频)
先判断一次是否为 null,只有真的为 null 时才进入同步块。同步块内再判断一次(防止两个线程都通过了第一次判断)。
public class Singleton {
private Singleton() {}
// volatile 禁止指令重排,保证可见性
private static volatile Singleton instance;
public static Singleton getInstance() {
if (instance == null) { // 第一次检查:避免不必要的加锁
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查:防止重复创建
instance = new Singleton();
}
}
}
return instance;
}
}为什么需要 volatile? new Singleton() 在 JVM 层面并非原子操作,大致分三步:
- 分配内存空间
- 在内存中初始化对象
- 将引用指向分配的内存
如果 JVM 做了指令重排,执行顺序变成 1→3→2,那么另一个线程可能在步骤 3 之后、步骤 2 之前读到一个"还没初始化完"的半成品对象。volatile 禁止了这种重排。
5. 静态内部类(推荐写法之一)
兼具懒加载和线程安全,写法还特别优雅。
public class Singleton {
private Singleton() {}
// 静态内部类——外部类加载时它不会被加载
private static class Holder {
private static final Singleton INSTANCE = new Singleton();
}
public static Singleton getInstance() {
return Holder.INSTANCE;
}
}原理:JVM 加载外部类 Singleton 时,不会加载静态内部类 Holder。只有第一次调用 getInstance() 访问 Holder.INSTANCE 时,JVM 才会加载 Holder 类。而类加载过程是由 ClassLoader 的 synchronized 保证线程安全的。
6. 枚举(Effective Java 推荐)
public enum Singleton {
INSTANCE;
public void doSomething() {
// 业务方法
}
}
// 使用
Singleton.INSTANCE.doSomething();为什么是最佳方式?
- 写法最简洁 —— 比双重检查锁省了十几行代码
- 天然线程安全 —— 枚举项本质是
static final成员,类加载时初始化 - 防止反序列化破坏 —— 枚举的反序列化不走
Unsafe,不会创建新实例 - 防止反射破坏 ——
Constructor.newInstance()内部会检查if (clazz.getModifiers() & Modifier.ENUM) != 0),直接抛异常
五种方式对比
| 实现方式 | 懒加载 | 线程安全 | 防反射 | 防反序列化 | 代码复杂度 |
|---|---|---|---|---|---|
| 饿汉式 | 否 | 是 | 否 | 否 | 低 |
| 懒汉式(加锁) | 是 | 是 | 否 | 否 | 低 |
| 双重检查锁 | 是 | 是 | 否 | 否 | 中 |
| 静态内部类 | 是 | 是 | 否 | 否 | 低 |
| 枚举 | 否 | 是 | 是 | 是 | 最低 |
单例的破坏与防御
普通单例(非枚举)可以被两种方式破坏:
1. 反射破坏
通过反射获取私有构造器,强行调用:
Constructor<Singleton> constructor = Singleton.class.getDeclaredConstructor();
constructor.setAccessible(true); // 取消访问检查
Singleton another = constructor.newInstance(); // 又创建了一个!防御:在构造器中检查实例是否已存在:
private Singleton() {
if (instance != null) {
throw new RuntimeException("不允许重复创建单例对象");
}
}2. 反序列化破坏
将单例对象序列化后再反序列化,ObjectInputStream 内部会通过 Unsafe.allocateInstance() 分配内存创建新对象,绕过私有构造器。
防御:定义 readResolve 方法,JVM 在反序列化时会优先调用它:
private Object readResolve() {
return instance; // 不管反序列化出什么,直接返回已有实例
}枚举方式天然免疫这两种攻击。这也是为什么 《Effective Java》 强烈推荐用枚举实现单例。
二、工厂模式
从一个痛点说起
假设你在写一个计算器程序,需要根据用户输入的运算符创建对应的运算对象。最直接的写法就是一长串 if-else。现在要加"取模"运算,改一次;再加"幂运算",又改一次。每次扩展都在修改已有代码——违反了开闭原则。
工厂模式就是来解决"根据不同条件创建不同对象"这个问题的。它有三个演进层次。
1. 简单工厂
生活类比:一家小餐馆,只有一个厨师,你告诉他要"鱼香肉丝"还是"宫保鸡丁",他都能做。但是菜品不能太多,否则厨师忙不过来。
// 抽象产品
public abstract class Operation {
protected double value1;
protected double value2;
public abstract double getResult();
}
// 具体产品
public class AddOperation extends Operation {
public double getResult() { return value1 + value2; }
}
public class SubOperation extends Operation {
public double getResult() { return value1 - value2; }
}
// 简单工厂——根据类型创建产品
public class OperationFactory {
public static Operation create(String op) {
switch (op) {
case "+": return new AddOperation();
case "-": return new SubOperation();
default: throw new IllegalArgumentException("不支持: " + op);
}
}
}
// 使用:调用方不需要知道 AddOperation 这个类名
Operation operation = OperationFactory.create("+");
operation.value1 = 10;
operation.value2 = 5;
System.out.println(operation.getResult()); // 15.0优点:创建逻辑集中管理,调用方只需要知道参数。
缺点:新增产品必须改 switch——违反开闭原则。
2. 工厂方法
生活类比:餐馆做大了,开了连锁分店。"川菜馆"只做川菜,"粤菜馆"只做粤菜。想加日料?开一家日料分店就行,不用改老店的菜单。
// 抽象工厂
public interface OperationFactory {
Operation create();
}
// 每个产品有自己的工厂
public class AddFactory implements OperationFactory {
public Operation create() { return new AddOperation(); }
}
public class SubFactory implements OperationFactory {
public Operation create() { return new SubOperation(); }
}
// 新增"取模"运算?只需新增 ModFactory,不改任何已有代码
public class ModFactory implements OperationFactory {
public Operation create() { return new ModOperation(); }
}
// 使用
OperationFactory factory = new AddFactory();
Operation operation = factory.create();优点:完全符合开闭原则。
缺点:类的数量成对增长——一个产品配一个工厂。
3. 抽象工厂
生活类比:一家汽车集团既造轿车也造 SUV。奔驰工厂造奔驰轿车和奔驰 SUV,宝马工厂造宝马轿车和宝马 SUV。每个工厂负责一个"产品族"。
// 抽象工厂——定义这个产品族能造什么
public interface CarFactory {
Sedan createSedan(); // 轿车
SUV createSUV(); // SUV
}
// 奔驰工厂——造一族奔驰产品
public class BenzFactory implements CarFactory {
public Sedan createSedan() { return new BenzSedan(); }
public SUV createSUV() { return new BenzSUV(); }
}
// 宝马工厂——造一族宝马产品
public class BMWFactory implements CarFactory {
public Sedan createSedan() { return new BMWSedan(); }
public SUV createSUV() { return new BMWSUV(); }
}新增产品族(如特斯拉):只需加一个 TeslaFactory,不改已有代码。
新增产品类型(如加个"跑车"):需要改 CarFactory 接口和所有实现——这就是"开闭原则的倾斜性"。
三种工厂对比
| 简单工厂 | 工厂方法 | 抽象工厂 | |
|---|---|---|---|
| 工厂个数 | 1 个 | 每产品 1 个 | 每产品族 1 个 |
| 新增产品 | 改工厂 switch | 加工厂类 | 加工厂类(族) |
| 开闭原则 | 违反 | 遵守 | 部分遵守 |
| 复杂度 | 低 | 中 | 高 |
| 适用场景 | 产品少且稳定 | 产品会增长 | 有产品族概念 |
实际项目中的选择:大多数场景用简单工厂就够了(尤其是配合 Spring 的 ApplicationContext.getBean())。当产品种类真的在持续增长,且每种产品的创建逻辑差异很大时,才需要升级到工厂方法。抽象工厂在业务开发中用得相对少,更常见于框架层面(如 JDBC 的驱动体系)。
三、建造者模式
什么问题需要建造者?
生活类比:你去定制一台电脑。CPU、内存、硬盘、显卡、电源、散热器都可以自己选。如果用构造方法传参:
new Computer("i9", "32GB", "1TB SSD", "RTX 4090", "750W", "水冷");六个参数还凑合,但如果有 15 个可选配置项呢?你根本记不住第 8 个参数是什么。更头疼的是,有些参数是必填的,有些是选填的,构造方法的重载版本能写出十几个。
建造者模式的解法是:把构建过程拆成一步一步的方法调用,每一步都有明确的名字,最后统一"组装"。
public class Computer {
private final String cpu; // 必填
private final String memory; // 必填
private final String disk; // 选填
private final String gpu; // 选填
private Computer(Builder builder) {
this.cpu = builder.cpu;
this.memory = builder.memory;
this.disk = builder.disk;
this.gpu = builder.gpu;
}
public static class Builder {
// 必填参数
private final String cpu;
private final String memory;
// 选填参数(给默认值)
private String disk = "256GB SSD";
private String gpu = "集成显卡";
public Builder(String cpu, String memory) {
this.cpu = cpu;
this.memory = memory;
}
public Builder disk(String disk) {
this.disk = disk;
return this; // 返回自己,支持链式调用
}
public Builder gpu(String gpu) {
this.gpu = gpu;
return this;
}
public Computer build() {
return new Computer(this);
}
}
}
// 使用——每个参数是什么一目了然
Computer gamingPC = new Computer.Builder("i9-13900K", "32GB DDR5")
.disk("2TB NVMe SSD")
.gpu("RTX 4090")
.build();
// 不需要显卡和大硬盘?省略即可
Computer officePC = new Computer.Builder("i5-13400", "16GB DDR5")
.build();你一定用过的建造者:
StringBuilder:.append("hello").append(" world").toString()- Lombok
@Builder:编译时自动生成上面这套代码 OkHttpClient.Builder:配置超时、拦截器、连接池StreamAPI:.filter().map().sorted().collect()也是类似思想
建造者 vs 工厂
| 建造者 | 工厂 | |
|---|---|---|
| 关注点 | 对象的构建过程(怎么一步步装配) | 对象的创建结果(给我什么类型的产品) |
| 适用场景 | 参数多、有可选项、构建步骤复杂 | 根据条件/类型创建不同产品 |
| 典型调用 | 链式调用,逐步设置 | 一次调用,直接返回 |
四、原型模式
什么问题需要原型?
生活类比:你写了一份简历模板,找工作时不会每次从头写,而是复印一份模板,改改目标公司名字就投出去。复印比重新写快得多,而且不会弄乱原稿。
在代码中也一样。假设你在写一个数据库查询框架,查出来的结果要缓存。如果有一万个线程查同一条记录,直接返回同一个对象会导致脏数据问题。原型模式的做法是:返回一个克隆副本,原对象永远不受影响。
public class User implements Cloneable {
private String name;
private int age;
@Override
protected User clone() throws CloneNotSupportedException {
User user = new User();
user.name = this.name;
user.age = this.age;
return user;
}
}
// 缓存层
public class UserCache {
private Map<String, User> cache = new HashMap<>();
public User getUser(String name) throws Exception {
if (!cache.containsKey(name)) {
User user = queryFromDB(name);
cache.put(name, user);
}
// 不返回原对象,返回克隆体
return cache.get(name).clone();
}
}浅拷贝 vs 深拷贝
这是原型模式中最关键的概念:
- 浅拷贝:基本类型(int、double 等)复制值,引用类型(对象、数组)复制引用地址——克隆体和原对象共用同一个引用对象。修改其中一方的引用对象,另一方也会受影响。
- 深拷贝:引用类型也创建全新的对象,两者完全独立。
深拷贝的两种实现:
// 方式一:手动递归 clone
@Override
protected User clone() throws CloneNotSupportedException {
User cloned = (User) super.clone();
cloned.address = this.address.clone(); // 引用类型也要 clone
return cloned;
}
// 方式二:序列化/反序列化(通用但性能稍差)
public User deepClone() throws Exception {
ByteArrayOutputStream bos = new ByteArrayOutputStream();
new ObjectOutputStream(bos).writeObject(this);
ByteArrayInputStream bis = new ByteArrayInputStream(bos.toByteArray());
return (User) new ObjectInputStream(bis).readObject();
}一句话总结:原型模式的核心就是"本体给外部提供一个克隆体使用"。注意根据场景选择浅拷贝还是深拷贝。
五、常见面试题精选
Q1:三种工厂模式怎么选?
简单工厂适合产品数量少且稳定的场景,一个方法搞定创建。工厂方法适合产品种类持续增长的场景,每个产品配一个工厂类,完全遵守开闭原则,但类数量会膨胀。抽象工厂适合有"产品族"概念的场景(比如不同品牌的一系列产品),新增产品族只需加一个工厂,但新增产品类型需要修改抽象工厂接口以及所有实现类。
Q2:不使用锁如何实现线程安全的单例?
有三种方式不需要显式使用 synchronized 或 Lock:饿汉式(静态变量在类加载时初始化)、静态内部类(首次使用时触发类加载)、枚举(枚举项本质是 static final 成员,由类加载机制保证初始化安全)。它们的底层都依赖 JVM 的类加载机制——ClassLoader.loadClass() 方法内部使用了 synchronized。此外还有一种思路是使用 CAS(基于 AtomicReference.compareAndSet),不过存在忙等待的风险,CPU 开销较大。
Q3:为什么说枚举是实现单例的最佳方式?
三个核心理由。第一,写法极简——一行代码就是一个完整的单例。第二,线程安全完全由 JVM 保证——枚举项是 static final 成员,在类加载时初始化,而类加载过程由 ClassLoader 的同步代码块保护。第三,天然防止破坏——枚举的反序列化不走 Unsafe.allocateInstance(),反射创建枚举对象时 Constructor.newInstance() 会直接抛出 IllegalArgumentException。
Q4:如何破坏单例?如何防御?
两种破坏手段。反射攻击:通过 getDeclaredConstructor() 获取私有构造器,再调用 setAccessible(true) 绕过访问检查。防御方法是在构造器中检查实例是否已存在。反序列化攻击:ObjectInputStream 在反序列化时通过 Unsafe.allocateInstance() 直接分配内存创建新对象,不经过构造器。防御方法是在类中定义 readResolve() 方法返回已有实例。枚举方式两种攻击都免疫。
小结
创建型模式的核心理念是把"创建"和"使用"分离——让调用方专注于业务逻辑,把创建细节交给专门的模块去管理。
| 模式 | 解决的核心问题 | 生活类比 | 经典应用 |
|---|---|---|---|
| 单例 | 全局只需要一个实例 | 一个国家只有一个总统 | Spring Bean、Runtime |
| 简单工厂 | 集中管理对象创建 | 一个厨师做所有菜 | Calendar.getInstance() |
| 工厂方法 | 可扩展地创建对象 | 连锁分店,各做各的 | LoggerFactory |
| 抽象工厂 | 创建一族相关产品 | 汽车集团旗下多品牌 | JDBC DriverManager |
| 建造者 | 分步构建复杂对象 | 定制电脑,逐项选配 | StringBuilder、Lombok |
| 原型 | 通过克隆快速创建 | 复印简历模板 | Object.clone() |
下一篇我们进入结构型模式,看看如何把类和对象像乐高积木一样灵活地组装在一起。