写了这么多年代码,每天和new关键字打交道,你有没有想过一个问题:当一个对象的创建逻辑变得复杂,或者系统里到处都在散弹式地new同一个类时,后续的维护会有多痛苦?我当年重构一个订单系统时,光是把各处直接new出来的支付对象收敛到统一的创建入口,就梳理了三天。那种酸爽,经历过的人才懂。创建型模式,就是专门解决这类问题的。
创建型模式是 GoF 设计模式五大分类中的第一类,核心关注点是对象的创建机制。它不是为了让你“多写几个类”而存在的,而是为了解决一个非常现实的问题:如何让对象的创建过程与使用过程解耦,让系统在扩展时不需要改动原有代码。
这篇内容我会把创建型模式里最核心的五个模式(工厂方法、抽象工厂、单例、建造者、原型)逐个拆开,不讲那些教科书式的定义,直接讲它们各自解决什么痛点、代码怎么写、什么场景下选哪个,以及我踩过的一些坑。适合正在学设计模式的同学,也适合工作两三年、想系统梳理创建逻辑的开发者参考。
1. 创建型模式到底在解决什么问题——从“直接new”说起
1.1 直接new的代价
先看一段再常见不过的代码:
OrderService { public void pay(Order order, String channel) { if ("alipay".equals(channel)) { AlipayClient client = new AlipayClient(); client.pay(order); } else if ("wechat".equals(channel)) { WechatClient client = new WechatClient(); client.pay(order); } } }这段代码有什么问题?如果新增一个unionpay渠道,你得打开这个类,加一个else if,再new一个对象。如果AlipayClient的构造参数从两个变成三个(比如加了config,里面又依赖appId、privateKey、notifyUrl),那所有new AlipayClient()的地方都要跟着改。
这里的问题本质是:调用方对对象的具体类型和构造过程产生了强依赖。new不仅仅是一个语法,它代表了三层耦合:
- 依赖具体类,而不是抽象接口
- 依赖构造参数,构造参数一变,调用方就要跟着改
- 依赖创建逻辑,对象创建前的初始化、缓存、校验等逻辑被分散到各处
创建型模式的核心思路,就是把这层耦合从调用方手里接管过来。你不关心对象到底怎么来的,你只关心拿到的对象能干什么。这就像你去餐厅吃饭,你只需要跟服务员说“来一份招牌菜”,而不需要知道后厨用的是哪口锅、哪把勺、什么火候。
1.2 五种模式的本质差异
很多初学者把五种创建型模式混在一起,觉得“都是new对象的,有什么区别”。我换个角度帮你去理解,其实它们对应了五个不同维度的痛点:
| 模式 | 核心痛点 | 一句话本质 |
|---|---|---|
| 简单工厂 / 工厂方法 | 创建逻辑集中在调用方,扩展要改老代码 | 把“创建哪个类”的判断交给工厂 |
| 抽象工厂 | 多个相关对象必须配套使用,不能混搭 | 把“产品族”的一致性约束交给工厂 |
| 单例 | 某些对象只需一个实例,多了就出事 | 从构造层面掐死多实例的可能性 |
| 建造者 | 对象参数太多、组合顺序复杂,直接构造易错 | 把复杂构造过程拆成可控的步骤 |
| 原型 | 创建对象成本高,且对象之间差异小 | 用克隆代替新构造 |
看到没有,这五个模式没有一个是为了“炫技”而存在的,它们全是被真实工程问题逼出来的。下面我一个一个展开讲。
2. 工厂三兄弟:从简单工厂到抽象工厂
2.1 简单工厂不是GoF模式,但它是理解一切的入口
很多资料把简单工厂排除在 GoF 23 种设计模式之外,因为它不是一个“模式”,而更像一种“编程习惯”。但我觉得学创建型模式必须从它开始,因为它是理解工厂方法的最好跳板。
简单工厂的核心做法是:把创建逻辑抽到一个独立的类里,调用方不再直接new,而是调用工厂的静态方法,传入一个参数,工厂根据参数决定返回哪个产品。
public class PayClientFactory { public static PayClient create(String channel) { if ("alipay".equals(channel)) { return new AlipayClient(); } else if ("wechat".equals(channel)) { return new WechatClient(); } throw new IllegalArgumentException("不支持的支付渠道: " + channel); } }调用方的代码就变成了:
PayClient client = PayClientFactory.create("alipay"); client.pay(order);好处是显而易见的:调用方和具体的AlipayClient、WechatClient解耦了,后续增加渠道,只需要改PayClientFactory一个类。但坏消息是:这个工厂类本身成了一个“万能类”,每次新增渠道都要改它,违反了开闭原则(对扩展开放,对修改关闭)。
简单工厂只适合产品种类不多、且不经常变化的场景。如果产品数量多、还在持续膨胀,就得升级到工厂方法。
2.2 工厂方法模式:把“创建”的决定权交还给子类
工厂方法模式对简单工厂的改进是:不再用一个大工厂里的if-else去判断,而是把工厂本身也抽象化。每种产品对应一个工厂子类,子类负责创建对应的产品。
public interface PayClientFactory { PayClient create(); } public class AlipayFactory implements PayClientFactory { @Override public PayClient create() { return new AlipayClient(); } } public class WechatFactory implements PayClientFactory { @Override public PayClient create() { return new WechatClient(); } }调用方拿到的是PayClientFactory接口,具体是哪个工厂实例,由上层通过配置或依赖注入来决定。这时候你再新增一个银联渠道,只需要新增一个UnionpayFactory,完全不用碰已有的工厂类和调用方代码。这就是工厂方法和简单工厂之间最本质的区别——扩展从“改代码”变成了“加类”。
我实际项目里的经验是,工厂方法模式特别适合那些产品类型容易膨胀、并且每个产品的创建逻辑各有不同的场景。比如我们做过一个消息推送系统,短信、邮件、App Push、站内信,每种推送的初始化逻辑差别很大,短信要加载签名配置,邮件要初始化 SMTP 连接池,App Push 要设置厂商证书。如果全塞到一个工厂类里,那个类会变成上千行的怪物。拆成独立工厂后,每个工厂各管各的,清晰得多。
2.3 抽象工厂模式:产品族的一致性才是灵魂
抽象工厂模式比工厂方法更进一步,它解决的是一系列相关对象必须配套使用的问题。工厂方法关注“一个产品”怎么创建,抽象工厂关注“一个产品族”怎么保证一致性。
举个例子,假设你在做一个跨平台的 UI 组件库,需要支持暗色主题和亮色主题。每个主题下都有一组配套组件:按钮、输入框、对话框。你不能让暗色主题下冒出一个亮色按钮,那样视觉上就乱套了。
public interface UIFactory { Button createButton(); Input createInput(); Dialog createDialog(); } public class DarkUIFactory implements UIFactory { @Override public Button createButton() { return new DarkButton(); } @Override public Input createInput() { return new DarkInput(); } @Override public Dialog createDialog() { return new DarkDialog(); } } public class LightUIFactory implements UIFactory { // 同理,返回亮色系列组件 }这里的关键在于,调用方只面对UIFactory接口,它拿到的组件天然就是一个主题内的配套组合。如果某个组件忘了实现或者实现错了主题,编译器在创建工厂时就暴露了问题,不会等到运行时 UI 乱套。
抽象工厂的问题也很明显:要新增一个产品维度(比如加一个“滑块组件”),所有工厂实现类都要改。所以抽象工厂适合产品族结构相对稳定、不频繁增加新维度的场景。如果产品族还在快速演进,抽象工厂反而不合适。
3. 单例模式:最熟悉也最容易翻车
3.1 从懒汉到饿汉:几种常见写法的取舍
单例模式可能是五个创建型模式里使用频率最高、但讨论声也最大的一个。它的核心诉求很简单:某个类在整个系统生命周期内只需要一个实例。典型的场景包括:配置管理器、数据库连接池、线程池、日志组件、Spring 中的默认 Bean 等等。
最基础的写法是懒汉式,也就是第一次使用时才创建实例:
public class ConfigManager { private static ConfigManager instance; private ConfigManager() { // 私有构造,外部无法 new } public static synchronized ConfigManager getInstance() { if (instance == null) { instance = new ConfigManager(); } return instance; } }在getInstance方法上加synchronized,保证了多线程下的安全,但代价是每次获取实例都要经历一次锁竞争。虽然现代 JVM 对锁竞争做过优化,但在高并发场景下这依然是不必要的开销。
饿汉式的写法更简洁,利用类加载机制天然保证了线程安全:
public class ConfigManager { private static final ConfigManager instance = new ConfigManager(); private ConfigManager() { } public static ConfigManager getInstance() { return instance; } }类加载时就创建实例,没有线程安全问题,也不存在锁竞争。缺点是如果这个类没有被用到,它也白白占了一份内存,而且初始化时机被提前了。如果类的构造过程依赖一些外部配置(比如系统启动后才加载的配置项),饿汉式可能是致命的,因为类加载时配置还不存在,会直接抛异常,白屏启动。
3.2 双重检查锁定(DCL)的实现细节
如果你既想懒加载,又不想每次获取实例都有锁竞争,那就得用双重检查锁定。这里是最容易翻车的地方,写错了就是线上事故,而且不一定会立刻暴露,只会在高并发时才偶发。
public class ConfigManager { private static volatile ConfigManager instance; private ConfigManager() { } public static ConfigManager getInstance() { if (instance == null) { // 第一次检查,避免不必要的锁竞争 synchronized (ConfigManager.class) { if (instance == null) { // 第二次检查,防止多个线程同时创建 instance = new ConfigManager(); } } } return instance; } }这个volatile是绝对不能少的。原因要从 JVM 的指令重排序说起:instance = new ConfigManager()这行代码在底层并不是原子操作,它大致分了三步:分配内存空间、调用构造方法初始化对象、把引用赋值给instance。在不加volatile的情况下,第 2 步和第 3 步可能被重排序。也就是说,线程 A 可能先把引用赋值给了instance(但对象还没完成构造),这时线程 B 进来,看到instance != null,直接返回了一个半初始化状态的对象,一用就出事。
3.3 单例的边界:什么时候不该用
聊完怎么写单例,我想聊聊比“怎么写”更重要的问题:什么时候不该用单例。
我对单例的态度是越来越谨慎了。很多开发者把单例当成“全局变量的遮羞布”,任何地方想用就直接Singleton.getInstance(),结果就是系统里充满了隐式的全局状态。两个模块都用同一个单例对象,一个在改字段,另一个读出了脏数据,排查起来让人崩溃。
我的建议是:单例适合用来管理无状态的服务或本质上就是共享的资源(连接池、线程池、缓存),不适合用来保存业务上的可变状态。如果你的单例类里有一堆setXxx()方法、一堆可变字段,那它迟早会成为团队协作的噩梦。
另一个问题是单例和依赖注入的关系。在 Spring 框架里,默认的 Bean 就是单例的,但你不需要自己写getInstance(),把实例交给容器管理就行。写框架代码的时候,尽量让单例对调用方透明,别在业务代码里满天飞地调用getInstance()。
4. 建造者模式与原型模式:两个极端场景的对症下药
4.1 建造者模式:当构造参数多到让人怀疑人生
我在项目里见过一个非常离谱的类,构造函数有 18 个参数。你根本分不清第 11 个参数是干啥的,传参顺序错了也不会报编译错,运行的时候行为诡异,排查半天才发现是参数传反了。
建造者模式就是解决这个问题的。它把对象的构造过程拆成一步步的“设置操作”,每一步都有明确的方法名,最后通过build()生成对象。
public class HttpClientConfig { private String baseUrl; private int connectTimeout; private int readTimeout; private boolean enableRetry; private int retryTimes; private HttpClientConfig(Builder builder) { this.baseUrl = builder.baseUrl; this.connectTimeout = builder.connectTimeout; this.readTimeout = builder.readTimeout; this.enableRetry = builder.enableRetry; this.retryTimes = builder.retryTimes; } public static class Builder { private String baseUrl; private int connectTimeout = 3000; // 默认值 private int readTimeout = 5000; private boolean enableRetry = false; private int retryTimes = 0; public Builder baseUrl(String baseUrl) { this.baseUrl = baseUrl; return this; } public Builder connectTimeout(int millis) { this.connectTimeout = millis; return this; } public Builder retry(boolean enable, int times) { this.enableRetry = enable; this.retryTimes = times; return this; } public HttpClientConfig build() { // 这里可以做参数校验,提前暴露错误 if (baseUrl == null || baseUrl.isEmpty()) { throw new IllegalStateException("baseUrl 不能为空"); } return new HttpClientConfig(this); } } }使用时的体验完全不一样:
HttpClientConfig config = new HttpClientConfig.Builder() .baseUrl("https://api.example.com") .connectTimeout(5000) .readTimeout(10000) .retry(true, 3) .build();整个过程读起来像一句自然语言,每个参数的含义一目了然。更重要的是,Builder可以在build()方法里集中做参数校验和默认值填充,把那些“忘了传参就用了默认值”的隐患在上线前就暴露出来。
建造者模式的优点是代码可读性高、参数灵活、校验集中,它非常适合参数多、参数存在依赖关系(比如开启重试后必须指定重试次数)的对象。缺点是代码量明显增加,如果参数只有三四个,直接用构造函数加@Builder(Lombok)就够了,没必要手工写一堆 Builder 类。
4.2 原型模式:克隆与浅拷贝陷阱
原型模式的思路非常直白:与其重新创建一个对象,不如克隆一个已有对象作为模板,然后修改差异部分。它适合那些创建成本高(比如涉及大量 IO、网络请求加载默认数据)且对象之间差异不大的场景。
Java 里实现原型模式有两个途径:实现Cloneable接口重写clone(),或者自己写一个复制构造方法。这里有个天坑是浅拷贝 vs 深拷贝的问题。
public class Order implements Cloneable { private String orderId; private List<OrderItem> items; @Override public Order clone() { try { Order copy = (Order) super.clone(); // 如果不做这个,copy 和原对象会共享同一个 items 列表 copy.items = new ArrayList<>(this.items); return copy; } catch (CloneNotSupportedException e) { throw new RuntimeException(e); } } }如果不手动深拷贝items,克隆出来的对象和原对象会指向同一个ArrayList。你在副本上往列表里加一个商品,原对象的商品列表也变了,这种问题在测试时极难发现,因为它不报错,只是数据诡异地“串”了。
我个人在实际编码里,其实更喜欢用“拷贝构造方法”而不是Cloneable,因为clone()是Object的方法,返回类型是Object,用起来要强转,而且CloneNotSupportedException是个受检异常,烦得很。拷贝构造方法写起来更直观:
public Order(Order source) { this.orderId = source.orderId; this.items = new ArrayList<>(source.items); }原型模式用的场景没有前面几个那么高频,但它在批量生成相似对象时非常有用。比如报表系统里要生成一份基础报表模板,然后基于模板生成各个部门的不同版本,原型模式就能省掉重复的初始化代码。
5. 创建型模式的选型决策与重构落地
5.1 一张表看懂五模式选型
学完五种模式之后,最容易犯的毛病就是“手里拿着锤子,看什么都是钉子”。所以我每次做技术分享都会强调:模式是工具,不是目的,选型的核心依据是你正在面对的痛点。
| 判断条件 | 推荐模式 | 原因 |
|---|---|---|
| 产品种类少,创建逻辑简单,几乎不变 | 简单工厂 | 代码量最少,够用就好 |
| 产品种类多,且在持续增加,每种创建逻辑不同 | 工厂方法 | 新增产品只加类,不改旧代码 |
| 多个产品必须成套出现,不能用混 | 抽象工厂 | 保证产品族一致性,编译期约束 |
| 全局只需一个实例,且是共享资源 | 单例 | 从构造上杜绝多实例 |
| 对象参数多,有校验逻辑,可读性差 | 建造者 | 链式调用可读性好,校验集中 |
| 创建成本高,对象差异小,需要复制 | 原型 | 克隆替代重建,省成本 |
有一件事我想多说几句,那就是不要把设计模式变成“为了用而用”。我见过有人为了展示自己会用抽象工厂,在一个只有两种产品、且永远不会有第三种的系统里硬套抽象工厂,结果就是多出一堆接口、一堆实现类,维护起来还不如直接new。模式的核心是解决痛点,没有痛点的模式应用,就是在给代码注水。
5.2 实战重构:从一堆new到工厂
聊完了选型,我拿一个真实的重构案例来串一遍。之前我们做一个多租户系统,每个租户的短信供应商不一样,代码刚上线时只有阿里云一家,大家都是直接new AliyunSmsClient(),写起来挺爽。后来接第二家供应商时,问题就来了,所有业务代码里散落了十几个new AliyunSmsClient(),而且每个新来的同事都习惯性再复制一段。
我当时做的第一步不是急着写工厂,而是先做了一件事:把创建逻辑的使用点全部列出来。数了一下,总共有 10 处直接 new,分布在 5 个类里。这个动作很重要,它能帮你评估重构的波及范围。
第二步才动手写工厂。考虑到后续还会接第三、第四家供应商,我直接选了工厂方法模式,而不是简单工厂。核心思路是:定义一个SmsClientFactory接口,各家供应商各自实现,至于系统里当前激活的是哪家工厂,通过配置中心下发。
第三步是把那 10 处new AliyunSmsClient()全部替换为调用SmsClientFactory,并把工厂实例通过依赖注入的方式传递,而不是在业务代码里自己 new 工厂。这一步非常关键,否则你只是把“散弹式 new 产品”变成了“散弹式 new 工厂”,问题根本没解决。
重构完成后,新增供应商的工作量从“改动 5 个类、10 处代码”变成了“新增工厂实现类 + 注册”。系统上线运营一年,我们顺利接入了另外两家供应商,期间没有改动过任何业务代码。这个效果,就是创建型模式价值的最好证明。
5.3 框架视角:为什么 Spring 里很少手写工厂
稍微有经验的开发者可能会发现,在 Spring 项目中,你很少需要手写工厂类。这不是说工厂模式没用了,而是Spring 容器本身就是一个大工厂。
ApplicationContext就是那个最顶层的工厂,你通过@Autowired注入一个PayClient接口,Spring 在启动时已经把具体的实现类实例化好,并放入容器中了。你需要切换实现类时,只要改注解标注的@Qualifier,或者改配置类里的@Bean方法。
但这不意味着你就不需要懂创建型模式了。恰恰相反,理解创建型模式能让你更好地理解框架的设计意图。比如:
- Spring Bean 默认单例,就是单例模式的应用
@ConfigurationProperties配合 Builder 风格的数据绑定,有建造者的影子- Spring 的
FactoryBean接口,本质上是工厂方法模式的一种封装
看懂框架背后的模式,你在排查问题时会有一种“开天眼”的感觉。遇到奇怪的 Bean 初始化顺序问题、单例失效问题,你能很快定位到是哪个环节出了问题,而不是一头雾水地乱试。
6. 常见问题排查与实操经验笔记
6.1 高频问题速查表
我在带团队和做代码审查的过程中,总结了一些创建型模式使用中最常踩的坑,整理成了一张速查表,方便你对照自查。
| 问题现象 | 根因分析 | 解决方案 |
|---|---|---|
| 单例拿到的是不同实例,状态不同步 | 没有用 volatile,或序列化破坏了单例 | DCL 加 volatile;单例类实现readResolve() |
工厂里if-else越来越多,每次加类型都改工厂 | 工厂设计退化成简单工厂,产品膨胀 | 升级为工厂方法,用子类工厂或注册表 |
| 克隆对象修改列表,原对象跟着变 | 浅拷贝,内部引用类型未复制 | 对内部可变对象做深拷贝 |
| 建造者模式里参数漏传,用了默认值引发问题 | build()没有做必填项校验 | 在build()中做完整校验 |
| 抽象工厂新增一个产品维度,改动波及所有实现 | 产品族结构不稳定 | 谨慎使用抽象工厂,或考虑用组合替代继承 |
| 反序列化时绕过单例构造方法 | readObject()会创建新实例 | 实现readResolve()返回现有实例 |
6.2 创建型模式的代码评审心得
最后想分享几个我在代码评审中总结的心得,都是实战中一点点磨出来的。
第一,关注构造函数的个数和参数个数。如果一个类的构造函数超过六七个参数,几乎可以确定它需要建造者模式或者拆分类。我在评审时会把“构造函数参数个数”当成一个信号,参数越多,直接 new 的散弹式影响越大。
第二,警惕“魔法工厂”。我看到某些工厂类里大量使用字符串判断创建什么产品,而且字符串常量在业务代码里满天飞,就知道这个系统迟早要出问题。建议把产品类型设计成枚举或使用注册表,工厂只需要在注册表里查一下就能找到对应的创建器,不需要写一堆if-else。
第三,把“校验提前”贯彻到底。创建型模式给你提供了一个天然的校验收口点:单例在初始化时校验、建造者在build()时校验、工厂在创建时校验。我遇到的线上事故里,有很大一部分都是因为对象在构造时缺少必要校验,导致一个非法参数在系统里传递了好几层,最后在一个八竿子打不着的地方爆出来。把校验放在“创建”这一步,是从源头堵住问题,比在使用的每一处都做防御要靠谱得多。
第四,别让创建逻辑和业务逻辑混在一起。有一次我接手一个老项目,发现一个对象的初始化过程里居然嵌套了一个长达 50 行的业务判断逻辑,这个对象的“创建”和“使用”严重耦合在一起。我后来把它拆成了两个部分:创建部分用工厂逻辑收敛,业务部分挪到调用的地方。代码可读性上升了一个量级。
6.3 一点个人建议
如果你正在系统学习设计模式,我的建议是从创建型模式入手是对的,因为它离实际代码最近,几乎天天用得到。但学习方法上,别只看书上的 UML 图和示例代码,那都是抽象的玩具。更好的做法是把手头项目里“到处 new”的地方找出来,尝试用工厂方法收敛;把那些参数爆多、看一眼就头晕的构造方法找出来,尝试用建造者改造。在真实代码上做一次,顶得上你看十遍理论。
我个人的经验是,创建型模式的学习曲线并不陡,但它带来的收益是长期的。它不像某个新框架那样能给系统带来立竿见影的性能提升,但它决定了你的系统在半年后、一年后,新增需求时是“改两行代码就行”还是“改一坨代码还不一定对”。搞清楚对象是怎么创建的,很多看似玄学的 bug 其实从一开始就不会存在。
你手头的项目里,如果也有那种改一次就要牵连十几个文件的创建逻辑,不妨试试用创建型模式收敛一下。不用一次全改完,挑一个最痛的地方下手,跑顺了再推下一个。模式的落地从来不是一口气的事,而是日拱一卒的持续优化。