原型模式详解:从浅拷贝到深拷贝的避坑指南
2026/9/10 6:07:17 网站建设 项目流程

设计模式这个系列我已经写了很多篇,每次写都觉得有些模式属于"一看就会,一用就废",原型模式就是典型代表。很多人觉得原型模式不就是clone一下嘛,有什么好讲的,但真到了生产环境,浅拷贝、深拷贝、嵌套引用、循环依赖这些问题能把人折腾到怀疑人生。这篇就围绕原型模式好好聊聊,从设计思路到代码实现,从浅拷贝深拷贝的底层原理到实际项目里的避坑经验,尽量一篇讲透。

这篇内容适合谁看?准备面试的、正在复习设计模式期末考的、做大作业需要交代码的、以及在业务代码里想用原型模式但不确定怎么落地的朋友。原型模式本身不是什么高深的东西,但它是理解对象创建机制、引用传递、拷贝语义的一个很好的切入点,搞懂了它,你会发现后面的深拷贝框架、对象池、甚至DDD里的聚合复制这些概念都好理解了。

1. 原型模式到底在解决什么问题

1.1 从"new一个对象"的成本说起

先想一个最基本的问题:我们平时创建对象,最常用的方式是什么?显然是new,也就是调用构造函数,走一遍初始化流程。这个过程看起来没什么特别的,但如果这个对象构建起来非常重,情况就不一样了。

举一个我实际碰到过的例子,早些年在做数据中间件的时候,有一个基础配置对象,里面包含了连接池参数、线程工厂、动态路由规则、黑白名单等十几个字段,其中光路由规则就是一棵树形结构,加载时要解析配置中心的JSON、做校验、编译成内部表达式。这样一个对象初始化一次大概要花300毫秒左右。每次请求如果都去new一次,用户端的延迟根本扛不住。

所以当时的思路很简单:把已经初始化好的配置对象缓存下来,需要新对象的时候,不是重新走一遍加载流程,而是直接把缓存对象复制一份出来改改字段。这就是原型模式的核心思想——它让你绕过构造函数的开销,通过复制已有实例来创建新实例。

从设计模式的定义来讲,原型模式属于创建型模式,它用"克隆"代替"构造",用"已有对象"作为"原型"。它要解决的核心问题就是:当对象创建成本高、或者对象的初始化过程非常复杂、或者你需要的一组对象之间只有少量差异时,直接复制远比重新构建更划算。

1.2 原型模式的定位:复制而不是创建

我见过不少人对原型模式有个误解,觉得它就是个"拷贝工具",跟Object.clone()划等号。实际上原型模式是一种创建对象的策略,它的关键是"复制"这个动作本身代替了"从头构建"这个动作。

打个比方,你要做一批手工蛋糕。普通做法是,每次拿面粉、鸡蛋、奶油从头做,一个一个来,费时费力。原型模式的做法是:先把第一个蛋糕精雕细琢做好,后面的蛋糕直接拿第一个当模板脱模复制,只有需要区别的地方再手动调整。你看,这跟new之间的差别不是"怎么实现拷贝",而是"设计层面到底是选择重新构建还是选择复制原型"。

也因此,在使用原型模式时,你首先要回答一个问题:当前场景下,"复制已有对象"真的比"构建新对象"更合理吗?如果对象本身就是一个轻量级的POJO,new一下根本没什么开销,那搞原型模式纯属画蛇添足,反而还要处理深浅拷贝的问题,增加维护成本。原型模式的适用场景,是那些"创建代价高"、"对象状态复杂"、"需要保留某一时刻快照"的场景。

1.3 什么时候该用原型模式

结合上面的分析,我总结了几个比较典型的信号:

  • 构造函数里有昂贵的I/O操作,比如读数据库、拉取远程配置、解析大文件。
  • 对象的初始化流程依赖外部环境或运行时状态,比如需要从线程上下文、Spring容器、ConfigCenter里拿数据。
  • 你需要一批逻辑相同、但某些字段存在差异的对象,比如同一个订单模板生成了很多子订单。
  • 你希望保存某个对象的"历史快照",用于后续的撤销、比较、审计等操作。

2. 原型模式的核心细节:浅拷贝与深拷贝

2.1 浅拷贝:默认行为里的隐藏陷阱

有了"复制"的思路,动手实现的时候第一个要面对的就是浅拷贝和深拷贝的问题。这也是原型模式里最核心,也是最容易踩坑的地方。

先说浅拷贝。Java里Object提供了一个clone()方法,它做的事情很简单:在当前对象的内存上按位复制一份,然后把引用指向复制后的对象。对于基本类型字段(int、long、boolean等),值会被正确复制;但对于引用类型字段,复制后的对象和原对象指向的是同一个内存地址。

可以理解为:浅拷贝只是复制了"一级"数据,里面嵌套的对象并没有被真正复制。

直接看代码会更清晰。比如这个类:

public class Order implements Cloneable { private Long id; private String orderNo; private Address address; @Override protected Object clone() throws CloneNotSupportedException { return super.clone(); } }

当你对这个Order对象执行clone()之后,新的order.orderNo是一个独立的字符串吗?是,也不是。字符串由于不可变性,看起来是"安全"的,你改了新的orderNo,原对象不受影响。但order.address是同一个对象的引用,新订单改了address里的城市,原订单的地址也会跟着变。这就是浅拷贝的坑。

很多刚接触原型模式的人,代码写出来测试也没问题,因为测的是基本类型,一放到生产环境,线上出现"改一个订单,另一个订单也跟着变了"这种诡异问题,排查半天才发现是浅拷贝导致的引用共享。

2.2 深拷贝:三种常见实现

解决上面那个问题的方案就是深拷贝。深拷贝不仅复制对象本身,还要递归复制它引用的所有对象,使得新对象和原对象完全不共享任何可变对象。

在Java里,深拷贝的实现方式主要有三种,我分别说一下它们的优缺点和适用场景。

第一种是重写clone方法,在clone里手动复制引用类型字段。这是最"正统"的做法,性能也很高,因为你自己控制了复制逻辑,不会做无用功。缺点也很明显:如果对象的层级很深,嵌套对象很多,你得一层一层手动克隆,代码会很繁琐,而且后续增加字段时容易漏掉。

@Override protected Object clone() throws CloneNotSupportedException { Order clone = (Order) super.clone(); clone.address = (Address) address.clone(); return clone; }

注意,Address类也要实现Cloneable接口并重写clone方法。这里就要求整条对象链上的所有类都支持克隆,这是硬性前提。

第二种是序列化方式,把对象写到字节流,再读回来。

public Order deepCopy() { try { ByteArrayOutputStream bos = new ByteArrayOutputStream(); ObjectOutputStream oos = new ObjectOutputStream(bos); oos.writeObject(this); ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(bos.toByteArray())); return (Order) ois.readObject(); } catch (Exception e) { throw new RuntimeException(e); } }

这种方式的优点很明显:写起来简单,不需要逐个重写clone,不管对象嵌套多深都能复制。但它有几个硬性要求:对象及其所有引用对象必须实现Serializable接口;性能相对较差;同时序列化会执行构造函数之外的机制,如果有敏感字段不想被序列化,需要加transient。还有一个隐藏坑:如果对象里有不能序列化的资源,比如线程池、Socket连接、文件句柄,这种方案会直接失败。

第三种是使用JSON序列化工具,比如Jackson、Gson、Fastjson。把一个对象转成JSON字符串,再反序列化成新对象。这种方式最灵活,不要求实现Serializable,只需要有无参构造函数和getter/setter(或其他反序列化配置)即可。缺点是JSON序列化有性能开销,而且对泛型类型、循环引用、某些复杂类型(比如非静态内部类)支持不好。

2.3 一个综合案例:配置对象

把几种方式用在一个完整的场景里看看。假设我们有一个系统配置对象,里面有基础配置、线程池配置、路由规则列表,其中路由规则是嵌套结构。

public class SystemConfig implements Cloneable { private String appName; private int port; private ThreadPoolConfig threadPool; private List<RouteRule> routes; @Override protected SystemConfig clone() { try { SystemConfig cloned = (SystemConfig) super.clone(); cloned.threadPool = this.threadPool.clone(); cloned.routes = new ArrayList<>(); for (RouteRule r : this.routes) { cloned.routes.add(r.clone()); } return cloned; } catch (CloneNotSupportedException e) { throw new RuntimeException(e); } } }

这里我们选择了重写clone的方式,因为这是配置对象,复制频率高,性能敏感。ThreadPoolConfig和RouteRule都需要实现Cloneable并完成自己的clone方法。可以看到,这种手动克隆确实繁琐,但也最可控。

如果这个配置对象里面字段非常多,重写clone维护起来太累,我通常建议用JSON工具做深拷贝,比如这样:

ObjectMapper mapper = new ObjectMapper(); SystemConfig cloned = mapper.readValue(mapper.writeValueAsBytes(original), SystemConfig.class);

一句话搞定,缺点就是性能比手动clone差。在配置变更不频繁、复制频率不高的场景下,完全够用。

3. 实操:Java和C++的实现

3.1 Java实现:Cloneable还是拷贝构造函数

Java实现原型模式,第一个分岔路口就是选择Cloneable还是拷贝构造函数。

Cloneable是JDK里一个标记接口,配合Object.clone()使用。但编程规范里普遍不推荐直接依赖它,原因是Object.clone()是protected方法,用起来麻烦;而且Cloneable接口本身不强制实现clone方法,属于"标记但不约束",很容易出现"实现了Cloneable但没重写clone,调用时抛CloneNotSupportedException"这种尴尬局面。

我个人的习惯是:在业务代码里优先使用拷贝构造函数。它的意图更明确,不会抛出受检异常,也不用类型强转。写起来大概是这个样子:

public class Order { private Long id; private String orderNo; private Address address; public Order(Order source) { this.id = source.id; this.orderNo = source.orderNo; this.address = new Address(source.address); } }

使用的时候直接new Order(sourceOrder)即可。这种方式比Cloneable更直观,而且编译器会帮你检查字段是否完整,后面加字段时如果忘了在拷贝构造函数里补上,IDE会有提示,比clone方法里的"默默缺失"要安全很多。

但如果你的项目倾向于标准设计模式的实现方式,那么实现Cloneable也是可以的,重点是把clone方法处理好,尤其是深拷贝逻辑。两种方式没有绝对的对错,关键看团队规范。

3.2 C++实现:拷贝构造与虚clone

C++里做原型模式有个天然的便利——拷贝构造函数和赋值运算符本身就是"复制创建对象"的机制。所以实现起来比Java更直接,不需要额外的Cloneable接口。

class Address { public: Address() = default; Address(const Address& other) : city(other.city), street(other.street) {} private: std::string city; std::string street; }; class Order { public: Order(const Order& other) : id_(other.id_), order_no_(other.order_no_), address_(other.address_) {} virtual ~Order() = default; virtual std::unique_ptr<Order> clone() const { return std::make_unique<Order>(*this); } private: int64_t id_; std::string order_no_; Address address_; };

注意这里的关键点:用std::make_unique (*this)调用拷贝构造函数来完成复制,返回unique_ptr作为多态载体。为什么要搞一个clone虚函数而不是直接用拷贝构造?因为C++的拷贝构造没有多态性——用基类指针拷贝时,如果基类不是虚函数,就只会拷贝基类部分,派生类的字段会丢失。这就是经典的对象切片问题。

所以在C++里做原型模式,最稳妥的办法是每个类都实现一个虚clone(),在内部调用自己的拷贝构造,返回类型是基类的unique_ptr。调用方拿到这个指针后,实际的动态类型是完整派生类。

这个设计在Java里对应的是让每个子类重写clone方法。本质思路是一样的:把创建对象的职责从调用方转移到对象自身,调用方只依赖抽象,不依赖具体类型。

3.3 原型管理器:给原型一个注册中心

实际项目里,原型往往不止一个。如果有一组"标准原型"需要重复使用,可以引入原型管理器(Prototype Registry),也叫原型注册表。

它的思路很简单:用一个Map把"原型名称"和"原型对象"对应起来,需要的时候通过名称获取原型的克隆。

public class PrototypeRegistry { private final Map<String, SystemConfig> prototypes = new ConcurrentHashMap<>(); public void register(String key, SystemConfig config) { prototypes.put(key, config); } public SystemConfig createFrom(String key) { SystemConfig prototype = prototypes.get(key); if (prototype == null) { throw new IllegalArgumentException("unknown prototype: " + key); } return prototype.clone(); } public SystemConfig createFrom(String key, Consumer<SystemConfig> modifier) { SystemConfig config = createFrom(key); modifier.accept(config); return config; } }

注意这里的第11行,返回的是clone对象,不是原型本身。这是很多初学容易犯的错误——如果不clone直接返回,外部把返回对象改一下,注册表里的原型就被污染了,下次再从那里面创建的"新对象"全都带着上一次的修改痕迹。所以在原型管理器的实现里,永远要保证"原型对象"是被保护起来的,对外只暴露克隆体。

这个createFrom(String key, Consumer modifier)方法是我后来加的,使用场景是:需要基于某个原型创建对象,同时又想对几个字段做定制。这样调用方就不用先clone再手动set,一行代码搞定:

SystemConfig config = registry.createFrom("default", c -> c.setPort(9090));

4. 原型模式的实战应用与选型

4.1 实战场景:Spring、游戏、AI Agent里的原型思想

原型模式在真实项目里的应用比很多人想象中要广,只是有时候不是以"设计模式"这个名头出现。

Spring框架就是一个典型例子。Spring中Bean的作用域(Scope)里有一个prototype作用域,每次从容器中获取都会创建一个新的Bean实例,这与原型模式的思想是一脉相承的。不过Spring底层多半是通过反射创建新实例,而不是真的调用clone,但应用层表现出来的行为就是"每次获取都拿到一个新对象"。如果你用Spring的@Lookup注解,其实就是在做类似原型工厂的事情。

游戏开发里原型模式用得更多。一个敌人的原型,一个子弹的原型,一个UI界面的原型。因为游戏中创建对象频率高,尤其是帧率敏感的实时场景,“从原型复制”比重新加载资源再初始化要快得多。Unity里的Prefab系统可以说是原型模式在商业引擎中最广为人知的一种落地。你做一个怪物,把它存成Prefab,然后运行时从Prefab实例化出无数个怪物,每个怪物可以有不同的位置、血量、外观。这不就是"原型+克隆"吗。

再往近了说,现在AI Agent的开发里也能看到原型思想的影子。比如很多团队会维护一批"Agent模板",每个模板是一套已经调好的Prompt、工具列表、模型参数、上下文策略的组合。要并发跑一批相似的任务时,不是从零构造每个Agent,而是基于模板复制一个出来,再调整任务相关的参数。这和原型管理模式非常像——所以你看,设计模式这种东西,它不是在课本里存在的,它是在一类问题反复出现后沉淀下来的解决套路。技术栈换了、名字换了,底层的思想并没有变。

还有比较典型的应用是消费MQ消息的时候,如果你需要在多个消费者上下文里使用同一份消息快照,或者对消息做多路拆分处理,这个时候每个处理链路拷贝一份消息对象再加工,能避免链路之间互相污染。

4.2 应用边界的判断

原型模式虽然好用,但不是万灵药。我来说说它的适用边界,帮你判断什么时候该上、什么时候该收手。

不适合用的情况有这么几种。对象本身特别轻量,new一下就是一眨眼的事,那直接用构造函数就好,加一层拷贝反而多此一举。对象里有无法复制的外部资源,比如持有Socket连接、数据库连接、文件句柄、某个单例的引用,这类对象复制出来以后资源管理会非常混乱,你没法确定新对象上的连接到底该不该关。对象在创建过程中有明确的业务语义,比如构造函数里做了参数校验、依赖注入、事件发送,这种一旦跳过构造函数直接复制,等于把整个初始化逻辑全绕过了,很容易埋雷。

适合用的情况,就是对象创建成本高或者对象代表了一组"预设状态"。比如前面说的配置对象、复杂DTO的模板化复制、需要保留历史快照等。这里有一个实践经验:做原型模式之前,先确认你要复制的对象以及它的整个对象图,是否有一个明确的"复制边界"。复制边界就是指哪些字段要被克隆,哪些字段可以共享,哪些字段不能被复制。把这个边界想清楚了再动手,代码会清爽很多。

4.3 与其他创建型模式的对比

很多人在学创建型模式的时候会混淆原型模式、工厂模式和建造者模式。其实它们的核心目的不同。

工厂模式把"创建哪个对象"这个决策集中起来,调用方只需要告诉工厂自己要什么类型,工厂负责怎么new。建造者模式把"复杂对象的组装过程"拆解出来,适合那种参数非常多、参数之间有依赖关系的对象。原型模式则专注于"我怎么拿到一个和已有对象状态一致的新对象",它不关心类型怎么创建,更关心初始状态怎么复制。

对比下来,原型模式的最大优势是什么?是它让"创建新对象"变成了"复制现有对象",从而绕过了构建过程中可能存在的复杂依赖、耗时逻辑和外部环境约束。这在某些场景下是降维打击,但代价就是你必须管理好深拷贝的复杂度。

我记得有个项目里同时用了工厂模式和原型模式:工厂从配置中心读取配置,构建出第一份"种子配置对象",然后注册到原型管理器里;后续所有的地方需要配置,就走原型管理器克隆。落地以后,代码量大幅减少,性能也提升了不少,因为你真正执行完整加载流程的次数只有一次。

5. 常见问题与排查技巧实录

5.1 改坏了原对象

这是浅拷贝导致的最典型问题。症状是:通过克隆对象修改了一个字段,结果"原型"对象里对应的字段也变了。排查思路很简单,查看这个字段的类型,如果是引用类型,看一下克隆实现里是否对这一层做了递归复制。用断点看字段的引用地址也能快速定位。

解决方式我上面已经说过,要么手动深拷贝,要么用序列化/JSON工具。这里我想说一个经验:在修改克隆对象的字段时,建议先判断这个字段是基本类型、不可变对象,还是可变对象。基本类型和不可变对象(比如String、Integer、LocalDate)在修改时通常不会影响原对象,但可变对象(比如List、Map、数组、自定义Class)就一定要小心。

5.2 循环引用导致栈溢出

当对象图里有循环引用时,如果你用的是递归式深拷贝,比如手动clone里A引用B、B又引用A,那就会无限递归,最终抛出StackOverflowError。这个在对象图比较复杂的领域模型里非常常见。

解决方式是引入"已访问集合",在递归过程中记录已经复制过的对象,遇到重复直接返回已有的克隆对象。用序列化方式的深拷贝也有循环引用的坑,不过ObjectOutputStream通常能处理同一次序列化里的循环引用,JSON工具就分情况了,Jackson配置了对应能力也能处理好。

我自己在实现递归克隆时,会在clone方法里传入一个IdentityHashMap:

public class Order { protected Order clone(IdentityHashMap<Object, Object> visited) { if (visited.containsKey(this)) { return (Order) visited.get(this); } Order clone = (Order) super.clone(); visited.put(this, clone); clone.address = this.address == null ? null : this.address.clone(visited); return clone; } }

这样遇到循环引用时,第二次访问同一个对象就会发现它已经被克隆过了,直接返回已有克隆体,打破循环。

5.3 克隆出来的对象状态不一致

还有一种问题不是编译报错,也不是运行时异常,而是"复制出来的对象状态不完整"。常见的原因是在clone方法里漏掉了某些字段的复制,或者复制的时机不对,导致新对象内部的一部分数据来自原对象、一部分数据是默认值,整个对象处于一种"半初始化"状态。

比如我之前遇到过一个场景,订单对象里有一个List字段,是订单创建时从外部系统拉取的明细。我的克隆方法只复制了orderNo和address,漏掉了明细List。结果新订单里明细为空,调用方以为订单没有明细,直接走了退款逻辑。这种问题最难排查,因为编译器不会给你任何提示。

怎么避免?一个很土但有效的办法:类的字段如果有新增,手动克隆的代码里检查一下有没有漏掉;如果有条件,写个单元测试,克隆完之后用反射把原对象和新对象的字段值都打印出来做对比,一眼就能看出谁丢了。另一个思路就是前面说的,如果对象层级不深、复制频率不高,果断用JSON序列化深拷贝,把这一类问题从根源上杜绝。

5.4 原型池的心跳问题

如果你把原型对象存在注册表里长期使用,要注意一个潜在问题:原型对象可能携带一些"运行时的心跳状态"或者过期的缓存数据。比如一个配置对象包含一个远程配置的加载时间戳,如果它一直留在注册表里,过了一段时间再克隆出来的新对象也会带着这个旧的时间戳,跟当下系统的真实状态不一致。

解决方式是给原型提供刷新机制,定期把"种子对象"重新加载一遍,更新到注册表中。这其实不是原型模式本身的问题,而是任何"缓存模板"都会有的一致性问题。在做设计的时候就要想清楚:哪些状态允许从原型复制,哪些状态必须在复制后重新从外部获取。

最后再分享一个实用小技巧

原型模式做到最后,你会发现真正费时间的不是"如何实现克隆",而是"画出哪些字段要深拷贝、哪些字段可以共享、哪些字段必须在克隆后重置"的复制清单。我每次在新项目里用原型模式,都会先画一个简单的表:字段名、类型、是否深拷贝、克隆后是否需要重置、备注。画完之后再写代码,基本不会出问题。

另外,如果项目里频繁做深拷贝,建议封装一个通用的DeepCopyUtils,把JDK序列化、JSON序列化、手动克隆几种策略都放进去,通过一个枚举参数来切换,这样调用方不需要关心具体的深拷贝方案,出问题时也方便统一排查。后面如果项目性能紧张,也只需要改一个方法,不用到处动代码。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询