
如果要在23种设计模式里选一个“看起来鸡肋、用起来真香”的角色我投原型模式一票。很多同行对它的印象停留在面试题里的Cloneable和clone方法真到业务代码里还是老老实实new一个对象再挨个set。这个习惯本身没什么不对可一旦对象的创建链路很长、初始化很重、又需要反复生成相似副本时new出来的状态往往不是你要的那份“半成品”而是又走了一遍漫长构造过程的新品。原型模式解决的就是这件事把一个已经造好、处于理想状态的对象当模板通过复制快速生成新对象再在新副本上做差异化调整。这篇文章就把原型模式从概念、Java实现、深拷贝雷区到真实业务案例一次性讲透。1. 原型模式不是“再new一个”它的核心是复印半成品1.1 为什么创建型模式里偏偏它走“复制”路线说到创建型模式大家最熟悉的往往是单例、工厂、建造者。这些模式解决的核心问题都是“怎么把对象的创建过程封装起来”工厂把复杂构建逻辑收拢到一处建造者把一大堆必填参数和可选参数打散成链式调用。它们的共同点是每次要对象时仍然是从零开始创建。原型模式的想法不一样。它不再关心“怎么从零搭一个对象”而是直接关心“怎么把一个已经搭好的对象复制一份”。这个差异看起来很微小实际影响很大。比如一个营销规则对象构造时要根据活动类型去加载若干条规则、去远程接口拉黑白名单、还要做一堆默认值校验。你用工厂每新建一次这些开销就跑一遍。可如果你已经有一个配置正确、数据完整、状态刚刚好的模板对象直接复制它就能拿到一个状态几乎一样的对象再改其中一两个字段就能用于新场景。这就像你写一份合同。工厂方式是把所有字段从空开始填原型方式是把现成的标准合同复印一份然后在复印件上做修订。显然模板越复杂、越成熟复印的收益越明显。1.2 原型模式的三个角色模板、复印机、客户从设计模式的角度看原型模式通常有三个参与角色原型接口声明一个用于复制自身的方法Java中往往对应Cloneable标记加clone()。具体原型实现这个复制方法真正完成对象拷贝。客户持有一个原型实例通过调用复制方法获得新对象而不是通过new。用最简代码表示大概是这个样子public interface Prototype { Prototype copy(); } public class SearchCondition implements Prototype { private String keyword; private ListString categories; public SearchCondition(String keyword, ListString categories) { this.keyword keyword; this.categories categories; } Override public SearchCondition copy() { return new SearchCondition(keyword, categories); } }这里我写的copy()方法其实只是借用了原型的思想用构造器复制了一遍。Java里更“正统”的做法是让类实现Cloneable并重写clone()这样能利用JVM原生的对象复制能力不用手动拷贝每个字段。1.3 理解clone绕过构造函数的真正含义很多人第一次接触clone()时最不理解的一条规则是它不会调用构造函数。这一条往深了想很有意思。普通new一个对象会经过类加载、内存分配、构造函数执行这一连串过程。构造函数里可能会做参数校验、状态初始化、联动其他对象。而Object.clone()是native方法它直接在内存层面复制对象结构把所有实例字段的值原封不动地搬过去。也就是说哪怕你的构造函数里写了非常重的初始化逻辑克隆对象时这段逻辑也不会重新跑。这本来是原型模式的一大优势复制对象“快且真”快在省去了构造函数开销真在复制对象和原对象状态完全一致。但反过来也提醒你原型对象必须是“真正可用”的成品。如果你拿一个还没完成初始化、或者依赖构造函数补数据的对象去克隆克隆出来的对象也会同样处于残缺状态因为构造函数不会再帮你兜底。所以用原型模式前请先确认模板对象的状态是可靠、完整的。一旦建立这个认知后面所有坑都容易理解。2. 在Java里写原型模式先学会clone()这个祖宗留下的暗坑2.1 Cloneable和Object.clone到底是干嘛的Java里要实现原型模式绕不开一个历史包袱Cloneable接口本身是空的它没有声明任何方法。真正干活的是Object.clone()一个protected native方法。Object.clone()的逻辑是先判断当前对象是否实现了Cloneable没有就抛CloneNotSupportedException实现了就分配一块新内存把对象各个实例字段的值按位复制到新对象里。复制出来的对象基本类型字段是全新值引用类型字段只是复制了引用两个对象仍指向同一个底层对象。这就是“浅拷贝”。由于Object.clone()是protected方法用户自己的代码不能直接调obj.clone()。要暴露克隆能力必须在子类里重写把可见性改成public并调用super.clone()。public class User implements Cloneable { private String name; private Address address; Override public User clone() { try { return (User) super.clone(); } catch (CloneNotSupportedException e) { throw new AssertionError(e); } } }类声明时必须加上Cloneable否则super.clone()直接抛异常。clone()返回的Object类型在Java里支持协变返回类型所以这里可以写成User而不是Object客户端不用强转代码干净很多。2.2 浅拷贝翻车现场一个引用字段改崩两个对象浅拷贝最典型的翻车场景是对象里包含一个可变引用字段。很多第一次写原型的人会以为调用clone()就万事大吉了结果业务上一改连原对象都被改了。来看个例子。public class Address { private String city; public Address(String city) { this.city city; } public String getCity() { return city; } public void setCity(String city) { this.city city; } } public class User implements Cloneable { private String name; private Address address; // 省略构造方法和getter/setter Override public User clone() { try { return (User) super.clone(); } catch (CloneNotSupportedException e) { throw new AssertionError(e); } } } // 测试 User u1 new User(张三, new Address(北京)); User u2 u1.clone(); u2.getAddress().setCity(上海); System.out.println(u1.getAddress().getCity()); // 竟然输出上海问题就出在u1.address和u2.address两个引用指向同一个Address对象。super.clone()只是把address引用本身复制了一份没有创建一个新的Address。结果通过u2修改城市u1也跟着变。线上如果出现这种“一个活动配置改完另一个活动同步被改”的诡异现象多半就是浅拷贝引起的共享可变对象。2.3 手写深拷贝的通用姿势要解决上面的问题就不能只依赖super.clone()必须在重写的clone()方法里把引用字段也逐个复制并且嵌套对象最好也支持克隆。public class Address implements Cloneable { private String city; Override public Address clone() { try { return (Address) super.clone(); } catch (CloneNotSupportedException e) { throw new AssertionError(e); } } } public class User implements Cloneable { private String name; private Address address; private ListString tags; Override public User clone() { try { User copy (User) super.clone(); // 先处理可空引用 copy.address address ! null ? address.clone() : null; // String本身不可变新建容器即可 copy.tags tags ! null ? new ArrayList(tags) : null; return copy; } catch (CloneNotSupportedException e) { throw new AssertionError(e); } } }这里有几个细节值得注意。第一Address必须自己实现Cloneable并重写clone()否则父类User.clone()里根本没法深拷贝它。第二如果tags里放的也是可变对象比如ListRule只做new ArrayList(tags)依然不够需要遍历并逐个克隆元素。第三处理引用字段时一定要判空否则原对象某个引用字段为null克隆时直接NPE。深拷贝的核对标准是新对象与原对象之间不能再有任何一条可达路径能改到同一块可变内存。只要这个标准没达到就不能说自己完成了深拷贝。2.4 深拷贝还是浅拷贝取决于你希望共享什么深拷贝不是在所有场景里都是最优解。有些字段本来就不应该被复制或者复制了反而浪费。比如一个全局路由表、一个缓存客户端、一个Spring Bean这类字段通常是单例或者不可变配置原对象和克隆对象共享同一个引用反而更合理。如果你盲目把每个字段都递归深拷贝一遍不但性能变差甚至可能把本该全局共享的连接池复制成多个引发资源问题。我自己习惯先把对象里的字段分三类不可变或可安全共享的String、基本类型包装类、枚举、单例Bean浅拷贝共用没问题。对象内有嵌套可变对象、且业务上每个副本都需要独立修改的必须深拷贝。和外部资源强相关的比如Socket、Connection、锁对象一般不建议用原型复制要么保持共享要么在自定义clone()里重新初始化。想清楚这一点比背深拷贝代码更重要。3. 从浅拷贝到深拷贝四种复制方案实测对比3.1 手动重写clone最费手但最可控如果要拷贝的对象层级不深、字段数量有限手动重写clone()是最直白、最可控的方案。代码写起来没有黑魔法新增了字段也容易记得在clone()里补上因为它就写在同一个类里。但层级一深手动方案就变得很痛苦。比如一个订单对象里嵌套了多个明细明细里又嵌套商品快照和优惠信息。你需要在每一层对象上实现clone()还要在父对象里逐字段调用一旦漏了一个可变对象线上就埋雷。我建议在两种情况优先考虑手动重写这个对象就是原型模式的核心对象并且结构相对稳定。你要做的特殊处理很多比如克隆后强制重置自增ID、修改状态位、忽略某些上下文字段。手动写可以准确表达这些规则。3.2 用Java序列化偷懒省事的前提是类都Serializable序列化复制是很多人偷懒的首选。思路是先把对象写进字节流再从字节流里反序列化出一个新对象因为整个过程和原对象的内存引用链无关天然就是深拷贝。一个通用的序列化克隆工具类可以这样写import java.io.*; public class SerializationUtils { SuppressWarnings(unchecked) public static T T clone(T source) { try (ByteArrayOutputStream bos new ByteArrayOutputStream(); ObjectOutputStream oos new ObjectOutputStream(bos)) { oos.writeObject(source); try (ByteArrayInputStream bis new ByteArrayInputStream(bos.toByteArray()); ObjectInputStream ois new ObjectInputStream(bis)) { return (T) ois.readObject(); } } catch (IOException | ClassNotFoundException e) { throw new RuntimeException(克隆对象失败, e); } } }用起来倒是很省心Order copy SerializationUtils.clone(order);但在真实项目里这条路有很多门槛。所有涉及的类都必须实现Serializable而且它们的字段类型也要可序列化。transient修饰的字段不会被复制反序列化后这些字段默认走类型的零值比如null。static字段同样不会被复制因为静态字段根本不属于某个对象。反序列化不调用构造函数所以依赖构造函数做初始化的对象copy回来可能不完整。每次克隆的序列化和反序列化开销不小在高频调用场合要慎重。如果对象内部包含Connection、Thread、Lock这类不可序列化的资源序列化方案直接失败。3.3 JSON往返拷贝业务项目里的“土办法”还有一种在业务项目里非常常见的深拷贝方案先把对象转成JSON字符串再从JSON字符串反序列化成新对象。用Jackson写大概长这样ObjectMapper mapper new ObjectMapper(); Order copy mapper.readValue(mapper.writeValueAsString(order), Order.class);这个方案最大的优点是只要对象是普通DTO几乎不用加什么额外接口用起来太方便了。有些团队的代码里甚至把“JSON深拷贝”做成了工具方法需要快速复制对象时直接调用。但它的缺点同样明显。如果对象有循环引用比如A引用了BB又引用了A直接序列化会栈溢出。多态类型容易丢。父类字段实际存的是子类对象如果没有配置JsonTypeInfo反序列化时很可能变成父类实例子类字段白白丢失。日期类型、泛型嵌套、LocalDateTime这类复杂类型在反序列化时容易出现格式兼容问题。序列化过程中会丢失部分对象语义比如对象的equals方法可能依赖的内存地址特征经过字符串绕一圈后不可恢复。所以JSON方案更适合“对外传输的DTO”复制不适合内部复杂领域对象的深拷贝。3.4 工具类copyProperties你以为省事其实是半拷贝前端传参给后端时我见过很多人用Apache Commons或Spring的BeanUtils.copyProperties来做对象属性拷贝。这个工具确实能在两个对象之间“按属性名复制字段”但它的复制是浅拷贝。对象里的List和Map字段复制后两个对象依然指向同一个集合引用。如果原来的对象本身是新建的浅拷贝也能凑合但如果用在前文那种原型复制场景极大概率出事。我见过最经典的事故是用BeanUtils.copyProperties把一个配置对象复制给多个子对象结果子对象内部共用了同一个规则List业务人员在界面改了一个活动规则其他活动全跟着变了。工具类本身没有错错在很多人把它当成“深拷贝API”用。要记住属性拷贝只是“给一个已经存在的目标对象赋值”它不负责创建目标对象也不负责展开嵌套引用。3.5 四种方案选型对照方案拷贝深度是否需要额外接口性能主要风险手动重写clone可控实现Cloneable高层级深时容易漏字段Java序列化深拷贝实现Serializable较低外部资源字段、transient字段丢失JSON往返深拷贝基本无较低循环引用、多态丢失、类型转换问题BeanUtils属性拷贝浅拷贝无中等内部引用共享容易改一个动全部我自己在项目里的原则是核心领域对象用手动clone()把复制逻辑显式写在类里DTO对外传输或跨服务复制可以用JSON方案一涉及外部资源、锁、线程上下文干脆放弃“通用深拷贝”的念头老老实实按业务语义重建。4. 你以为它冷门源码和框架里却到处有它的影子4.1 从ArrayList到HashMap容器里的暗送秋波有些人对原型模式的理解停留在自己写的业务类里觉得实际项目用得少。其实只要翻开JDK源码就会发现容器类对这个模式很偏爱。比如ArrayList就实现了Cloneable并重写了clone()。它的复制逻辑并不是把所有元素对象都new一遍而是复制一份新的内部数组数组里的元素引用还是和原集合共享。这样做的好处是复制出来的ArrayList结构上完全独立你在新List里add或remove元素不会影响原List但如果修改某个元素对象内部的字段两个List都能看到。Java容器这种“浅外壳、深共享”的复制策略在大部分场景下是合理的因为它兼顾了性能和语义。HashMap也有类似的clone()实现不过要注意即使两个Map实例内部数组互不影响键和值对象本身仍然是共享引用。你曾以为复制了Map就能完全隔离数据实际上只是隔离了Map的桶结构并没有隔离里面的业务对象。4.2 Android源码里经常被拿出来讲的“复制状态”相关热搜词里频繁出现《Android源码设计模式解析与实战》这本书确实Android开发中有很多地方体现了原型思想。最典型的例子是Intent它实现了Cloneable并且提供了clone()方法允许通过复制已有Intent快速生成一个新Intent然后在副本上补上不同的Extra或Flag。这比每次重新new Intent()后逐个putExtra要方便也能避免遗漏原本已经组装好的那些公共参数。除了IntentAndroid里的很多“快照”类对象也有类似需求。比如页面状态恢复时系统会把已保存的Bundle或Parcel数据作为模板重新构造一个对象。Parcelable机制本质上是把一个对象的状态“写到缓冲区”然后从缓冲区恢复出一个新对象。这和序列化深拷贝的思想同源只是Android把它用在跨进程传参上。读源码时不见得每个类都写着“Prototype”字样但只要看到“一个对象如何复制自己”的设计基本就是在运用原型模式或其变体。4.3 原型注册中心从模板库中取模版原型模式在实践中还会演化出一个很有用的配套结构原型注册中心。简单说你用Map维护一堆原型对象给每个原型一个唯一key调用方只需要传入key就能从注册中心拿到对应原型的副本。public class PrototypeRegistry { private static final MapString, Prototype PROTOTYPES new ConcurrentHashMap(); public static void register(String key, Prototype prototype) { PROTOTYPES.put(key, prototype); } public static Prototype create(String key) { Prototype prototype PROTOTYPES.get(key); if (prototype null) { throw new IllegalArgumentException(未找到原型: key); } return prototype.copy(); } }在配置中心或者规则引擎里这种模式特别实用。把每种默认模板预先创建好注册进去业务方不用关心模板是怎么构造出来的只需要说“我要A类型模板的副本”语义非常清晰。它本质上是工厂模式和原型模式的结合但底层的“深拷贝能力”仍然来自原型对象自己。5. 原型模式别硬套四个边界条件用错就翻车5.1 final字段会让clone很尴尬Java里如果某个引用字段被声明为final在clone()方法里想把它重新指向一个新的深拷贝对象是做不到的因为final字段一旦赋值就不能再变。比如public class User implements Cloneable { private final Address address; // final字段 Override public User clone() { try { User copy (User) super.clone(); // 编译报错无法给final字段重新赋值 // copy.address address.clone(); return copy; } catch (CloneNotSupportedException e) { throw new AssertionError(e); } } }这种情况下copy.address和原对象的address仍然共享同一个可变引用深拷贝策略直接失效。解决方案有三种第一把需要深拷贝的字段改成非final第二不依赖Object.clone()改用专门的拷贝构造函数或工厂在新对象构造时传入副本第三从设计上让这个字段指向不可变对象共享引用本身也没问题。所以设计原型类时不要把所有字段都习惯性加final。不可变性虽好但和Java原生的克隆机制之间有一道天然的裂缝。5.2 单例类不该被clone出分身单例模式和原型模式在目标上是冲突的一个强调全局只有一份一个强调复制出多份。如果一个单例类不小心实现了Cloneable并且没有重写clone()调用者完全可以通过克隆拿到第二个、第三个实例单例约束直接崩溃。如果要让一个单例类同时支持克隆语义要么在clone()里直接返回this让所有克隆操作都拿到同一个实例要么明确抛出CloneNotSupportedException从接口层面阻断克隆。很多框架里的配置中心对象本身是单例但需要给业务返回副本这种场景通常不会在单例类本身上实现clone()而是由注册中心持有原型对外暴露“按key生成副本”的方法。这样既保证了注册中心单例又让业务方拿到独立副本。5.3 并发环境下clone快照也不是万无一失原型对象的复制过程本身是把一个“当前状态”固化下来。如果原型对象在A线程执行clone()的同时被B线程修改了某些字段那么A线程拿到的可能是修改前和修改后的混合状态。尤其浅拷贝时共享引用字段被改所有副本都会收到影响。面对这种并发问题我常用的手段是尽量把需要长期复用的原型设计成不可变对象要变更时直接替换整个原型而不是原地修改。如果一定要原地修改加锁或使用CopyOnWrite思路快照化。复制完成后新副本如果要被多个线程同时修改注意不要让各线程直接改共享的嵌套引用字段。在异步任务、线程池场景里很多人以为“反正复制出来了后续随便改”结果一调试发现打印出来的字段值飘忽不定。别怀疑基本是原型对象本身还被别的线程共用。5.4 这几种情况我劝你别用原型模式原型模式好用但不是万能药。遇到下面几种情况我更倾向不用对象创建成本极低就两三个字段用new比克隆更直观没必要引入Cloneable和维护成本。对象里带有明确的业务标识字段比如数据库自增ID、全局唯一编号。如果直接复制两个对象会带着同一个ID必须手工重置一旦忘记可能出现“同一份配置更新了两个活动”的事故。对象和外部上下文强关联比如持有某个用户的Session、请求TraceId、当前租户信息。直接复制容易把上下文也带过去这种业务语义用工厂或建造者重建更干净。记住一个判断标准如果你复制完对象后还要额外写一堆“修正”代码来重置字段、修复关联关系那说明复制思路不一定适合当前场景至少不该无脑套clone()。6. 一个完整业务场景从模板克隆出营销活动6.1 需求背景和类设计假设这样一个场景运营系统里有“活动模板”模板里配置好了默认的营销规则、屏蔽用户名单、扩展参数。每来一个新的活动运营不想从空页面开始填而是选择一套模板一键生成活动草稿再微调几个参数保存。这里的核心难点不是复制几个字符串而是模板内部有多个List和Map这些嵌套结构必须是独立的。活动A改了规则绝不能影响模板和其他从同一模板生成的活动。两个核心类可以设计为public class Rule implements Cloneable { private String type; private int threshold; Override public Rule clone() { try { return (Rule) super.clone(); } catch (CloneNotSupportedException e) { throw new AssertionError(e); } } } public class ActivityTemplate implements Cloneable { private Long id; private String name; private ListRule rules; private SetString blockedUserIds; private MapString, ConfigItem extraConfig; Override public ActivityTemplate clone() { try { ActivityTemplate copy (ActivityTemplate) super.clone(); copy.id null; // 新生成的草稿要有新ID copy.rules rules ! null ? rules.stream().map(Rule::clone).collect(Collectors.toList()) : null; copy.blockedUserIds blockedUserIds ! null ? new HashSet(blockedUserIds) : null; copy.extraConfig new HashMap(); if (extraConfig ! null) { for (Map.EntryString, ConfigItem entry : extraConfig.entrySet()) { copy.extraConfig.put(entry.getKey(), entry.getValue().clone()); } } return copy; } catch (CloneNotSupportedException e) { throw new AssertionError(e); } } }id字段我选择直接置为null因为新活动复制的是模板的“内容”而不是模板的主键。rules里的每个Rule都是可变对象所以逐个调用clone()。blockedUserIds里是String不可变新建一个HashSet即可。extraConfig里的ConfigItem如果内部还有可变字段它本身也需要实现深拷贝。6.2 实现细节绕不开的两个问题第一集合字段的类型要尽量使用接口类型。比如字段声明为ListRule但实际存储是ArrayList。在深拷贝时用流的collect(Collectors.toList())会得到一个ArrayList但如果原集合是LinkedList复制结果和原集合类型不一致会带来后续隐患。更严谨的写法是判断原集合类型或者保证原对象创建时统一使用同一实现类。第二调用关系要清晰。模板生成草稿草稿生成正式活动不要只有一套clone()方法到处复用。处理规则可能不同模板的clone()可以保留idnull的语义但正式活动再次复制时可能需要保留活动ID也可能需要清空审批时间等状态。把不同复制需求拆成不同方法比在同一个clone()里加一堆if要安全得多。6.3 我在这个场景里真实踩过的坑第一次实现这个功能时图省事模板的clone()只写了super.clone()以为模板内部那些List不会有人直接改。结果运营同学在活动A的规则列表里删了一条规则模板和其他活动里的规则也被删了。排查时最迷惑的是数据库里模板数据明明没变但页面展示的数据像“无规则”一样。后来定位到是对象在内存里共享了同一个ArrayList引用所有从模板复制出来的活动在Spring容器的Bean作用域内都指向同一个集合。改成深拷贝之后又踩了第二个坑忘记重置id字段。新的活动草稿继承了模板id落库时主键冲突但因为代码里id是手动set进去的报错信息非常具有迷惑性一开始还以为是缓存问题。这两个问题都让我更确信原型模式不是“override一个clone方法”那么简单。它需要你认真梳理对象里每个字段的复制语义哪些要深拷哪些要重置哪些要共享。这个梳理过程才是原型模式真正的价值所在。