原型模式大概是创建型模式里最容易被忽视、但实际应用又特别频繁的一个。很多人学设计模式的时候,工厂模式和单例模式讲得最多,原型模式往往一句话就带过去了——"用一个已经存在的对象作为模板,复制出一个新对象"。听起来简单,可真到用的时候,深浅拷贝、Cloneable接口、序列化实现,每个点都能把人绊一跤。我这些年面试过的候选人里,能准确说出"原型模式解决什么痛点"的,十个里面未必有两个;但一说到ArrayList的clone()、Spring的prototype作用域,大家又都在用。这个模式就是这么个状态:处处可见,却说不上名字。
今天这篇就把它彻底掰开揉碎。从原型模式的核心定义讲起,重点放在Java里Cloneable和clone()的实现细节、浅拷贝深拷贝的坑,再结合我实际项目中用过的场景——配置对象复制、运行时快照、游戏单位克隆之类的案例,把"什么情况下该用它、什么情况下千万别用"讲清楚。无论你是准备面试的开发新人,还是正在做代码重构、对象频繁创建性能瓶颈的老手,这篇应该都能给你一些可以直接拿去用的参考。
1. 原型模式到底在解决什么问题
1.1 重新理解"复制对象"这件事
先看一个最简单的代码片段:
User user = new User("张三", 28, new Address("北京市", "海淀区")); User copy = user; // 这真的算复制吗?这段代码的问题很明显:copy 和 user 指向的是同一个对象,你改 copy 的属性,user 也跟着变。这不算复制,只是多了一个引用。
真正的复制,是创建一个独立的、内容和原对象一致的新对象。而原型模式干的事情,就是把这个"复制"的动作标准化、模板化——对象自己定义怎么被复制,外部调用方不需要关心它的创建过程有多复杂。
在设计模式的定义里,原型模式属于创建型模式,核心在于"通过已有实例创建新实例"。它和工厂模式最大的区别是:工厂模式关注"怎么封装 new 的过程",原型模式关注"怎么绕过 new、直接复制已有实例"。这个区别是理解原型模式的钥匙。
1.2 原型模式的三块核心价值
我用了几年原型模式之后,觉得它的价值可以归纳成三点:
**第一,省掉重复的初始化开销。**假如你的对象创建时要查数据库、要远程调用、要做一堆格式校验,每次 new 一次都是真金白银的耗时。如果这个对象的结构稳定,直接从内存里复制一份,成本低到可以忽略。举个数据库连接配置的例子,加载一次配置后把配置对象缓存起来,后续要创建新连接配置时直接克隆,能省掉一大半IO时间。
**第二,保留对象的运行时状态。**new 出来的对象是从"空白状态"开始的,但很多时候你想要的是"当前这个状态下的另一个实例"。比如游戏里克隆一个士兵单位,士兵当前的血量、装备、Buff状态都要保留,你重新 new 一个再逐个赋值,纯属给自己找麻烦。
**第三,对外隐藏创建细节。**调用方只需要知道"给我复制一个一模一样的"就行,至于这个对象内部有多少字段、多少嵌套结构、复制时要做哪些处理,调用方一概不知。这也符合设计模式通用的"面向接口编程"原则。
1.3 一个简单类比:文件复制和文件新建
Windows里复制粘贴文件,和右键新建空白文件,你选哪个?大部分人会按 Ctrl+C、Ctrl+V,因为复制出来的文件内容、结构都是现成的,你只需要改需要改的部分就行;新建一个空白文件,所有内容都得从头填。原型模式就是程序世界里的"Ctrl+C、Ctrl+V",它把"复制"这一步封装好,让调用方的效率成倍提升。
但注意,"复制"也有不同的复制法。你复制一个Word文档到U盘,是完整的一份新文件;但如果你复制一个快捷方式,点开还是原文件。程序里的浅拷贝深拷贝,其实对应的就是"复制文件"和"复制快捷方式"的区别。这一块是原型模式的重头戏,下面单独展开。
2. Java里的原型模式:Cloneable和clone()的完整拆解
2.1 最基础的实现方式:实现Cloneable接口
Java里实现原型模式的标准姿势是:实现 Cloneable 接口,并重写 Object 类的 clone() 方法。先看一段最基础的代码:
public class User implements Cloneable { private String name; private int age; private Address address; public User(String name, int age, Address address) { this.name = name; this.age = age; this.address = address; } @Override public User clone() throws CloneNotSupportedException { return (User) super.clone(); } // getter/setter 省略 }这里有几个新手必踩的坑,我一个个说。
**坑一:Cloneable 是个标记接口,里面什么方法都没有。**它的作用只是告诉 JVM:"这个类的对象允许被 Object.clone() 复制。"如果你不实现 Cloneable,直接调用 super.clone(),JVM 会毫不留情地抛 CloneNotSupportedException。我第一次写的时候就没注意,还纳闷"Object 不是有 clone() 方法吗?怎么不让用"——有方法不等于能用,你得先拿到许可证。
**坑二:Object.clone() 是 protected 方法,默认只能在java.lang包或子类里访问。**你要让外部调用方克隆你的对象,必须把访问权限提升为 public,同时重写方法。上面代码里的 @Override 和 public 缺一不可。
**坑三:重写 clone() 时可以把返回类型改成具体类。**这是Java的协变返回类型机制,Java 5开始支持。返回值写成 User 而不是 Object,调用方就能省掉强制类型转换,代码清爽很多。
2.2 浅拷贝与深拷贝:原型模式最大的分水岭
上面那段代码有一个隐藏的大坑:它做的是浅拷贝。super.clone() 会把基本类型字段(name、age)直接复制,但 address 这个引用类型字段,复制出来的新对象和原对象共享同一个 Address 实例。
画个图理解一下:
原对象 User1 ──> name: "张三" age: 28 address ──> Address("北京市", "海淀区") 克隆对象 User2 ──> name: "张三" age: 28 address ──> 同一个 Address("北京市", "海淀区")User2 和 User1 的 name、age 是独立的值,但两者的 address 指向同一块内存。这时候你修改 User2.address.city = "上海市",User1.address.city 也会变成"上海市"。如果你的业务逻辑里有"复制出来的对象可以随意改,不能影响原对象"的需求,浅拷贝就是一颗定时炸弹。
具体的验证代码也很简单:
User original = new User("张三", 28, new Address("北京市", "海淀区")); User clone = original.clone(); System.out.println(original == clone); // false,是两个不同的对象 System.out.println(original.getAddress() == clone.getAddress()); // true,但共享引用 clone.getAddress().setCity("上海市"); System.out.println(original.getAddress().getCity()); // 输出"上海市"?原对象被改了!结果确实会输出"上海市"。这个坑我在开发一个订单导出功能时踩过:复制的订单对象去改收货地址,结果原订单的地址也被改了,查了好久才发现是浅拷贝导致的。
2.3 深拷贝的三种实用实现方案
解决上面那个问题,就需要深拷贝。深拷贝的核心要求是:对象里的所有引用类型字段,都要递归地复制一份,最终得到一个完全独立的对象。这里有三种主流做法,我按实际项目的使用频率来讲。
方案一:重写 clone() 方法,逐层手动克隆。
@Override public User clone() throws CloneNotSupportedException { User cloned = (User) super.clone(); cloned.address = this.address.clone(); // address 也要实现 Cloneable return cloned; }这种方式要求嵌套的 Address 类也得实现 Cloneable,并且正确重写 clone()。好处是性能好、逻辑直白;坏处是如果对象嵌套层级很深,或者字段特别多,你要写一堆手工克隆代码,而且以后每加一个引用类型字段,都得记得在这里补上,容易漏。
方案二:通过序列化实现深拷贝。
这是很多框架底层在用的方案,原理是把对象序列化成字节流,再从字节流反序列化出一个新对象。因为序列化走的是IO流,中间的过程天然就是"复制"。
public User deepClone() { try { // 写出去 ByteArrayOutputStream bos = new ByteArrayOutputStream(); ObjectOutputStream oos = new ObjectOutputStream(bos); oos.writeObject(this); // 读回来 ByteArrayInputStream bis = new ByteArrayInputStream(bos.toByteArray()); ObjectInputStream ois = new ObjectInputStream(bis); return (User) ois.readObject(); } catch (Exception e) { return null; } }注意序列化方式的硬性要求:对象及其所有引用类型的字段,都必须实现 Serializable 接口,否则会抛 NotSerializableException。另外,字段如果被 transient 修饰,序列化时会被跳过,反序列化出来就是 null,这既可以是优点(不想复制的字段正好不用管)也可能变成坑(你明明想复制结果丢了值)。
序列化方式的优点是通用性好,不用逐层写 clone() 代码;缺点是性能相对差一些,毕竟要走一遍字节流。如果对象不复杂、克隆频率不高,我比较推荐这种方式,省事。
方案三:使用现成工具类做属性拷贝。
比如 Spring 的org.springframework.beans.BeanUtils.copyProperties,或者 Apache Commons 的PropertyUtils,可以把源对象的属性复制到目标对象中。但说实话,这类工具做的是"属性复制",不是真正的克隆:你得先 new 一个空对象,然后把属性拷进去。而且它默认是浅拷贝,遇到嵌套对象同样要小心。它适合的场景是"两个不同类的对象字段互相赋值",比如 DTO 转 VO,不太适合做严格意义上的原型克隆。
我在实际操作中的倾向是:对象层级浅、字段少,用方案一手动重写 clone(),性能最好;对象层级深、字段多、克隆频率低,用方案二序列化,省心;两个不同类型的普通JavaBean之间要复制字段,直接用 BeanUtils,别自己造轮子。
3. 原型模式的适用场景与真实案例拆解
3.1 适合使用原型模式的四个典型场景
我总结过自己项目里真正用到原型模式的地方,主要有四类:
**场景一:重复创建成本高的对象。**这类对象创建时要查数据库、调远程接口、做复杂计算。比如一个报表查询条件对象,需要从配置中心加载一堆默认值、做权限校验,整个初始化链条很长。把这个对象做一次完整初始化后缓存起来,之后每次要新条件时直接克隆,再改其中个别字段,能省掉80%以上的时间。
**场景二:需要保留对象当前状态的副本。**游戏开发里的单位克隆、回合制战斗里的战前状态备份、工作流引擎里的流程快照,都需要"保留这一刻的状态,复制一份出来单独用"。这时候用 new 重建状态很难,用原型模式直接复制是最自然的方案。
**场景三:运行时才知道具体要创建什么类型的对象。**比如一个文档编辑器,用户复制粘贴的可能是文本框、图片、表格中的任何一种,它们在对象树里的类型各不相同。你不可能为每种类型写一套复制逻辑,更合理的做法是让每个图形对象自己实现 clone(),编辑器的复制逻辑只需要调用一个统一的 clone() 接口方法就行。这就是多态配合原型模式的典型玩法。
**场景四:需要维护一系列"半成品"模板对象。**比如项目中有一组不同环境(开发环境、测试环境、生产环境)的配置模板,每个模板长得差不多,只有几个字段不同。把模板对象注册到一个原型管理器里,根据 key 取出模板后克隆,再覆盖个别参数,比每次从头组装要灵活得多。
3.2 案例:订单对象的复制与编辑
我在做订单中台时遇到过一个真实需求:用户提交订单后,运营人员可能要对订单进行"调整",但系统要求调整前后的订单都必须留痕,仓库那边要看到调整后的版本,财务要看到原始版本和调整版本。
第一种思路是全字段复制,用 BeanUtils 硬拷。但订单对象嵌套了商品列表、配送地址、优惠明细、发票信息,整个对象树有五六层,BeanUtils 的浅拷贝根本撑不住,改了嵌套商品的价格,原订单也变了。
第二种思路是数据库读取两次,从库里分别加载原始快照和调整后数据。但性能太差,一个订单的完整加载链路要经过多次数据库查询和缓存拼接,耗时接近500ms,完全不能接受。
最后用了原型模式:订单加载完成后,调用 deepClone() 复制一份到内存里。调整操作只修改克隆体,原对象纹丝不动。两个版本都存在内存里,操作完成后再统一落库。这么做有两个好处:一是性能好,克隆一个订单对象只需要不到2ms;二是逻辑清晰,原订单和副本的隔离是天然的,不需要处处小心"别把原数据改了"。
这个案例里我还学到一个教训:**克隆对象时,如果有生成时间戳、操作人编号这类"每次创建都要重新生成"的字段,要记得在 clone() 方法里特判重置。**否则运营人员一看,咦,调整订单的时间怎么和原始订单一模一样,又是一通排查。
3.3 在Spring中的影子:原型模式无处不在
Spring 框架里虽然没有逼着你用 Cloneable,但"原型"这个概念到处都是。
最直观的是 Bean 的作用域:@Scope("prototype")。默认情况下 Spring 的 Bean 是单例的,同一个 Bean 在容器里只有一份;但当你把作用域设为 prototype 时,每次 getBean() 都会从 BeanDefinition 这个"模板"出发,创建出一个新实例。从设计模式的角度看,BeanDefinition 就是原型,每次创建 Bean 都是对原型的一次"复制"。
另一个典型是ObjectProvider<T>,比如:
@Autowired private ObjectProvider<OrderService> orderServiceProvider; // 每次获取都得到一个独立的实例 OrderService orderService = orderServiceProvider.getObject();Spring 的官方文档里虽然很少提"原型模式"这四个字,但它在底层大量应用了"模板 + 复制"的思想。你理解了原型模式,再看 Spring 的 Scope 机制,会通透很多。
3.4 原型管理器(Prototype Registry)的写法
当原型对象的种类比较多时,可以把它们组织成一个注册表,用 key 来区分不同类型。这里分享一个简化版的实现:
public class PrototypeRegistry { private Map<String, Prototype> prototypes = new HashMap<>(); // 注册原型对象 public void register(String key, Prototype prototype) { prototypes.put(key, prototype); } // 根据 key 获取克隆对象 public Prototype create(String key) throws CloneNotSupportedException { Prototype prototype = prototypes.get(key); if (prototype == null) { throw new IllegalArgumentException("Unknown prototype key: " + key); } return prototype.clone(); } }注意一个问题:注册表里存的是"原型模板",但外部通过 create() 拿到的是克隆体。**模板对象本身不能参与业务流转,否则认知会混乱。**我在代码评审时见过有人直接把这个注册表里的对象返回给调用方,结果多个线程同时改它,数据互相污染,问题非常隐蔽。正确做法是,注册表是工厂,create() 是工厂方法,永远返回克隆体,模板对象不对外暴露。
4. 理解原型模式的几个关键边界
4.1 为什么 clone() 不调用构造函数
这是一个高频面试题,也是一个很多人没想明白的原理:**Object.clone() 创建新对象时,不会调用任何构造函数。**它是直接在内存层面做对象复制,分配了一块新内存,然后把原对象的内容按位拷过去。
这带来几个重要影响。
第一,你在构造函数里做的初始化逻辑,在 clone() 时不会执行。比如构造函数里生成了一个 UUID 作为业务编号,克隆出来的对象不会有新的 UUID,它会保留原对象创建时那个 UUID。从某种角度说这是特性(保留状态),从另一种角度说是坑(如果你想每次克隆都有新编号,必须手动处理)。
第二,子类调用 super.clone() 能正确返回子类类型。这个机制和 C++ 里的拷贝构造函数有本质区别:Java 的克隆不依赖类型系统的虚函数表,不会在克隆过程中调用子类的重写方法,因此相对安全,不容易出现"复制一半被重写逻辑干扰"的情况。
第三,如果有字段在构造函数里做了防御性复制,比如把传入的 List 重新拷贝一份,这份"防御"在 clone() 里不会生效。所以如果对象里有需要防御性复制的集合字段,clone() 必须自己重新处理。
4.2 原型模式、工厂模式和单例模式的关系
创建型模式里,原型、工厂、单例三者经常一起出现,我把它们放到一张表里做对比:
| 维度 | 原型模式 | 工厂模式 | 单例模式 |
|---|---|---|---|
| 创建方式 | 复制已有实例 | 封装 new 的过程 | 全局只有一个实例 |
| 对象状态 | 保留原对象当前状态 | 通常是全新初始状态 | 共享同一状态 |
| 对外接口 | clone() 返回新对象 | 工厂方法返回新对象 | getInstance() 返回同一个对象 |
| 典型问题 | 深浅拷贝、构造函数不参与 | 类型爆炸、代码冗余 | 线程安全、序列化破坏单例 |
这三个模式可以配合使用:工厂方法内部用原型对象进行克隆,工厂对外隐藏克隆细节;原型管理器(注册表)本质上就是一个"基于原型的工厂"。
需要注意协同使用的边界:**单例和原型在语义上是冲突的。**如果一个类被要求实现成单例,同时又重写 clone() 返回新对象,那这个单例就名存实亡了。如果一个类是纯工具类、状态完全不可变,那实现 Cloneable 意义也不大。我一般会在设计类的时候想清楚:这个类到底代表"一组状态",还是代表"一套能力"。代表状态的对象适合做原型,代表能力的对象适合做单例。
4.3 原型模式不是"万能复制工具"
这是我想强调的一个认知边界:原型模式解决的是"对象本身的复制",不是"把数据搬到另一个结构的对象里"。
很多刚接触的人会把 BeanUtils.copyProperties 也叫成"原型模式",其实不是。BeanUtils 是属性复制工具,目标是"把 A 的同名字段值赋给 B 对象"。它不要求 B 和 A 是同一个类,也不要求 B 是从 A 克隆出来的。原型模式则要求"被克隆对象自己克隆自己",返回的是和原对象相同类型的实例。
再比如用 Gson/Jackson 把一个对象先转成 JSON 字符串、再反序列化成新对象,也能做到深拷贝,但这同样不是原型模式,它是序列化/反序列化的应用。这类方案可以用,但你要知道自己在用什么,别给面试官讲原型模式的时候说"我平时用 JSON 序列化做原型"——那是用另一个工具曲线救国,不是模式的本质。
5. 实战排坑:我在原型模式上踩过的那些坑
5.1 只实现 Cloneable 却没有重写 clone()
见过一个同事写的代码:
public class Config implements Cloneable { // 一堆字段 // 没有重写 clone() 方法! }然后调用config.clone(),编译期直接报错。原因很简单:Object.clone() 是 protected,不重写的话你只能在子类内部调用,外部根本没有访问权限。而且就算在类内部想调用super.clone(),也必须处理受检异常 CloneNotSupportedException。
正确做法是重写并且声明为 public,同时返回类型可以收窄为当前类:
@Override public Config clone() { try { return (Config) super.clone(); } catch (CloneNotSupportedException e) { throw new RuntimeException("Config 对象克隆失败", e); } }5.2 深浅拷贝问题最难排查的场景:嵌套集合
单个对象的嵌套引用还好理解,真正容易出问题的是集合嵌套结构。比如:
public class SaleOrder implements Cloneable { private List<List<Promotion>> promoMatrix; // 促销矩阵:每个商品项都对应一组促销活动 // ... }这种"集合里的集合"做浅拷贝时,super.clone() 只会复制外层 List 的引用,内层 List 还是共享的。你修改克隆体的内层 List,原对象的促销矩阵照样跟着变。解决这个问题最稳妥的方式还是序列化深拷贝,因为手写递归克隆这种嵌套集合结构,代码会丑陋到怀疑人生。
我在实战中的建议是:凡对象里有超过两层嵌套的引用结构,直接用序列化深拷贝方案,别和自己较劲。
5.3 clone() 方法里的new不是不可以
有一种偏见认为:"原型模式就是调 clone(),不能 new,new 了就不是原型模式了。"这不对。
在重写 clone() 时,对一些特殊字段用 new 来"重建"是完全合理的。比如一个日期字段,你希望克隆出来的对象用当前时间重新生成,那直接在 clone() 里手动 set 新日期就行。原型模式的本质是"以原对象为基础生成新对象",不是禁止 new。它禁止的是"外部无脑 new",内部具体怎么实现,是你自己的自由。
5.4 线程安全:共享原型对象的并发复制
原型管理器在并发场景下的线程安全问题,很容易被忽略。
原型对象本身如果是可变的,那当多个线程同时调用 clone() 时,clone() 读取的是原型对象的当前字段值,而读到的值可能正被另一个线程修改。这会导致克隆出来的对象状态不确定。我处理这个问题的方式是:要么把原型对象设计成不可变对象(所有字段用 final,不提供 setter),要么在克隆时给原型对象加锁,要么干脆每次在克隆前先同步快照一份。
这三种方案里,"不可变对象"最优雅。如果你的原型模板在注册之后不需要任何修改,强烈建议把它做成不可变对象,既安全又省心。
5.5 在 HashMap 这类 JDK 容器里看见原型模式的影子
其实 JDK 的很多源码里都有原型模式的影子。比如HashMap.clone()的实现,并不是简单地调用super.clone()就完事了,它内部会把所有桶里的键值对重新放入新 Map 里:
@Override public Object clone() { HashMap<K,V> result; try { result = (HashMap<K,V>)super.clone(); } catch (CloneNotSupportedException e) { throw new InternalError(e); } result.reinitialize(); // 遍历原 Map 的所有 entry,重新放入 result 中 return result; }这是一个值得好好读的源码:它展示了"JDK 官方是怎么处理深浅拷贝"的。因为 HashMap 内部的 Node 数组是引用类型,直接 super.clone() 会让新旧 Map 共享同一个 table 数组,所以必须重建整个数组。但 Node 对象本身没有被复制,新 Map 里的 Node 还是原来那些。所以严格来说,HashMap.clone() 是一个"浅拷贝",只是外层数组独立了。如果你往 HashMap 里放了一个可变的自定义对象作为 value,克隆出来的新 Map 里 value 还是同一个对象。
读这种源码,比看一百篇博客都有用。
6. 原型模式与相关模式的"配方"参考
6.1 原型 + 工厂:创建逻辑彻底对调用方隐藏
纯粹使用原型模式时,调用方还是要显式调用 clone(),如果外部还知道"我需要调 clone()",模式的信息封装就不够彻底。更常见的设计是把原型塞进工厂里:
public class DocumentFactory { private Map<String, Document> prototypes = new HashMap<>(); public DocumentFactory() { // 初始化一批原型 prototypes.put("report", new ReportDocument()); prototypes.put("invoice", new InvoiceDocument()); } public Document createDocument(String type) { try { return prototypes.get(type).clone(); } catch (CloneNotSupportedException e) { throw new RuntimeException("文档创建失败", e); } } }调用方只管factory.createDocument("report"),它拿到的就是一个基于原型复制出来的新对象,不需要知道原型模式的存在。这种做法的好处是:将来新增文件类型,只需要增加原型注册,工厂代码不用改,符合开闭原则。
6.2 原型 + 备忘录:复制状态快照的最佳拍档
备忘录模式(Memento)用于保存和恢复对象状态,和原型模式搭配起来效果非常棒:操作前先 clone() 一份对象作为快照,操作失败时直接丢弃当前对象、用快照还原即可。
比如一个表单编辑器,每次自动保存时,把当前表单对象 clone() 一份放到历史栈里,栈顶就是最近一次的快照,undo 的时候用快照覆盖当前对象。这样"撤销"功能不需要记录每一次操作的详细指令,只需要保存状态副本,代码简洁很多。细节上要注意深拷贝:如果快照和当前对象共享内部引用,那"还原"的时候共享的那部分数据早就被污染了,快照就不准确了。
6.3 从"C++ 拷贝构造函数"对比理解 Java 原型模式
如果大家有其他语言的背景,比如 C++,那理解原型模式会容易很多。C++ 的拷贝构造函数ClassName(const ClassName& other)本身就是一种"从现有对象复制出新对象"的机制,而且 C++ 的默认拷贝构造函数会自动做深拷贝逐成员复制。Java 里没有拷贝构造函数的语法糖,所以才用 Cloneable + clone() 来模拟,这也是为什么 Java 原型模式的实现看起来比 C++ 繁琐。
如果你是做 C++ 的,在看 Java 原型模式时要特别注意"浅/深拷贝"的差异:C++ 的默认拷贝是会逐成员复制的,Java 的 Object.clone() 是按位复制,引用类型字段就是浅拷贝。两边的默认行为不一样,千万别两套经验混淆着用。用 C++ 实现的古代经典例子是 "游戏单位克隆":单位类有复杂状态,每次出兵要从兵营模板克隆,而不是重新从白板状态初始化。
7. 一个完整的原型模式实战示例
7.1 需求描述与设计思路
假设我们要开发一个报表系统。报表Report嵌套了Header(表头)、List<Row>(多行数据)、Footer(总计信息),同时包含一些元数据字段,比如报表编号和生成时间。每次从数据库加载一张完整报表,需要通过多次 SQL 查询,成本很高。现在用户对"某一张报表"执行"复制并调整"操作,要求复制出来的报表不能影响原报表。
这个需求最合适的方案就是:加载原始报表后,将其缓存为原型;每次"复制并调整"时,从原型 deepClone() 出一个新对象,再修改新对象的内容。
7.2 完整代码与注意事项
public class Header implements Serializable { private String title; private String period; // 统计周期 // getter/setter 省略 } public class Row implements Serializable { private String itemName; private BigDecimal amount; // getter/setter 省略 } public class Footer implements Serializable { private BigDecimal totalAmount; private int rowCount; // getter/setter 省略 } public class Report implements Serializable { private String reportId; private Date generateTime; private Header header; private List<Row> rows; private Footer footer; public Report(String reportId, Header header, List<Row> rows, Footer footer) { this.reportId = reportId; this.generateTime = new Date(); this.header = header; this.rows = rows; this.footer = footer; } // 深拷贝:序列化实现 @SuppressWarnings("unchecked") public Report deepClone() { try { ByteArrayOutputStream bos = new ByteArrayOutputStream(); ObjectOutputStream oos = new ObjectOutputStream(bos); oos.writeObject(this); oos.flush(); ByteArrayInputStream bis = new ByteArrayInputStream(bos.toByteArray()); ObjectInputStream ois = new ObjectInputStream(bis); return (Report) ois.readObject(); } catch (Exception e) { throw new RuntimeException("报表深度克隆失败", e); } } public void resetReportIdAndTime() { this.reportId = UUID.randomUUID().toString().replace("-", ""); this.generateTime = new Date(); } // getter/setter 省略 }这段代码里有几个我特别想提醒的细节。
细节一:所有参与深拷贝的类都必须实现 Serializable。这里Header、Row、Footer、Report都加了implements Serializable,List<Row>这个字段用的 ArrayList 本身实现了 Serializable,所以能顺利序列化。如果有任何一个类忘记实现,就会抛异常。
细节二:报表的业务编号和生成时间需要重置。这就是我在上一个案例里强调过的"克隆后特判字段"。深拷贝之后,reportId和generateTime还是原对象的,如果不重置,系统里会出现两个相同编号的报表。所以我专门写了resetReportIdAndTime()方法,在克隆后主动更新。
细节三:深拷贝抛出的异常要包装成语义清晰的运行时异常。SerializationException不是运行时异常,直接抛出去调用方还得处理受检异常,很烦。我在 deepClone() 里用 try-catch 包了一层,对外只抛 RuntimeException,调用方可以安心使用。
7.3 调用方的使用方式
// 从缓存获取原型 Report prototype = reportCache.get("report_2024_001"); // 基于原型深拷贝一个新报表 Report adjustedReport = prototype.deepClone(); // 重置业务编号和生成时间 adjustedReport.resetReportIdAndTime(); // 修改需要调整的内容 adjustedReport.getRows().get(0).setAmount(new BigDecimal("99.90")); // 原报表不受任何影响 System.out.println(prototype.getRows().get(0).getAmount()); // 还是原始值整个调用过程非常清爽,调用方不需要知道 Report 内部有几个嵌套类、克隆时做了哪些深拷贝处理,它只知道"我拿到的是一份独立的新报表"。这就是原型模式封装性的价值。
8. 面试验货:我常问的几个原型模式问题
如果大家准备面试,原型模式的考点其实不算多,但很经典。我整理几个我面试候选人或被面试时遇到过的典型问题,附一个简要的参考答案方向。
问:Object.clone() 为什么不调用构造函数?答:clone() 是在内存层面按位复制对象,不经过构造函数。它的设计目标是"保留原对象的当前状态",而不是"重新走一遍初始化流程"。因此构造函数里的逻辑不会在克隆时执行。
问:实现 Cloneable 接口的作用是什么?答:Cloneable 是标记接口,没有任何抽象方法。它的作用是通知 JVM 允许调用 Object.clone()。如果对象没有实现 Cloneable 而调用 clone(),JVM 会抛 CloneNotSupportedException。
问:浅拷贝和深拷贝的区别,怎么实现深拷贝?答:浅拷贝只复制对象本身和基本类型字段,引用类型字段共享原对象;深拷贝会递归复制所有引用类型字段,最终得到完全独立的对象。实现深拷贝有三种主流方式:逐层重写 clone()、序列化反序列化、属性拷贝工具配合手动处理嵌套字段。
问:原型模式用在什么场景?什么时候不用?答:对象创建成本高、需要保持运行时状态、运行时才能确定具体类型时,适合用原型模式。反过来,对象结构简单、直接 new 成本很低,或者对象有大量不可复制的外部资源(比如数据库连接、线程池),就不适合用原型模式。
问:原型模式和工厂模式的关系?答:原型模式强调"复制已有实例",工厂模式强调"封装创建过程"。二者经常结合使用:工厂方法内部依赖原型对象的 clone() 来生产新对象,对外完全隐藏"复制"这个过程。
问:一个类同时是单例和原型会怎样?答:语义冲突。单例要求全局只有一个实例,而 clone() 会创建新实例。如果单例类重写 clone() 返回新对象,单例就不成立;如果不重写,则 clone() 不能正常工作。实际设计时要避免这种"既要又要"的类设计。
9. 最后的几点个人体会
原型模式在创建型模式里属于"看起来简单、用起来烫手"的类型。它的核心,也就是克隆接口,五分钟能讲完;但它的工程复杂性,全在深浅拷贝和对象生命周期管理里。我个人的经验是:**如果你确定要用原型模式,先把对象的复制语义定义清楚——哪些字段要共享,哪些字段要独立,哪些字段要重新生成。**把这个理清楚了,代码怎么写都顺;理不清楚,再花哨的深拷贝方案也救不了你。
最后再分享一个小技巧:给所有需要克隆的实体类,在注释里写清楚"克隆后哪些字段会被重置、哪些字段是共享引用"。这行注释的价值,在三个月后的代码维护期里会体现得淋漓尽致——因为复制对象时的"隐性约定"如果不写下来,迟早会有人踩进深浅拷贝的坑里,然后一脸懵地来问你"我明明克隆了,为什么改原对象也会变"。把这件事做好,你的原型模式才算真正落地。