资讯动态

原型模式详解:从浅拷贝到深拷贝的避坑指南

发布时间:2026/9/10 6:07:26 来源:尧图企业网站定制
设计模式这个系列我已经写了很多篇每次写都觉得有些模式属于一看就会一用就废原型模式就是典型代表。很多人觉得原型模式不就是clone一下嘛有什么好讲的但真到了生产环境浅拷贝、深拷贝、嵌套引用、循环依赖这些问题能把人折腾到怀疑人生。这篇就围绕原型模式好好聊聊从设计思路到代码实现从浅拷贝深拷贝的底层原理到实际项目里的避坑经验尽量一篇讲透。这篇内容适合谁看准备面试的、正在复习设计模式期末考的、做大作业需要交代码的、以及在业务代码里想用原型模式但不确定怎么落地的朋友。原型模式本身不是什么高深的东西但它是理解对象创建机制、引用传递、拷贝语义的一个很好的切入点搞懂了它你会发现后面的深拷贝框架、对象池、甚至DDD里的聚合复制这些概念都好理解了。1. 原型模式到底在解决什么问题1.1 从new一个对象的成本说起先想一个最基本的问题我们平时创建对象最常用的方式是什么显然是new也就是调用构造函数走一遍初始化流程。这个过程看起来没什么特别的但如果这个对象构建起来非常重情况就不一样了。举一个我实际碰到过的例子早些年在做数据中间件的时候有一个基础配置对象里面包含了连接池参数、线程工厂、动态路由规则、黑白名单等十几个字段其中光路由规则就是一棵树形结构加载时要解析配置中心的JSON、做校验、编译成内部表达式。这样一个对象初始化一次大概要花300毫秒左右。每次请求如果都去new一次用户端的延迟根本扛不住。所以当时的思路很简单把已经初始化好的配置对象缓存下来需要新对象的时候不是重新走一遍加载流程而是直接把缓存对象复制一份出来改改字段。这就是原型模式的核心思想——它让你绕过构造函数的开销通过复制已有实例来创建新实例。从设计模式的定义来讲原型模式属于创建型模式它用克隆代替构造用已有对象作为原型。它要解决的核心问题就是当对象创建成本高、或者对象的初始化过程非常复杂、或者你需要的一组对象之间只有少量差异时直接复制远比重新构建更划算。1.2 原型模式的定位复制而不是创建我见过不少人对原型模式有个误解觉得它就是个拷贝工具跟Object.clone()划等号。实际上原型模式是一种创建对象的策略它的关键是复制这个动作本身代替了从头构建这个动作。打个比方你要做一批手工蛋糕。普通做法是每次拿面粉、鸡蛋、奶油从头做一个一个来费时费力。原型模式的做法是先把第一个蛋糕精雕细琢做好后面的蛋糕直接拿第一个当模板脱模复制只有需要区别的地方再手动调整。你看这跟new之间的差别不是怎么实现拷贝而是设计层面到底是选择重新构建还是选择复制原型。也因此在使用原型模式时你首先要回答一个问题当前场景下复制已有对象真的比构建新对象更合理吗如果对象本身就是一个轻量级的POJOnew一下根本没什么开销那搞原型模式纯属画蛇添足反而还要处理深浅拷贝的问题增加维护成本。原型模式的适用场景是那些创建代价高、对象状态复杂、需要保留某一时刻快照的场景。1.3 什么时候该用原型模式结合上面的分析我总结了几个比较典型的信号构造函数里有昂贵的I/O操作比如读数据库、拉取远程配置、解析大文件。对象的初始化流程依赖外部环境或运行时状态比如需要从线程上下文、Spring容器、ConfigCenter里拿数据。你需要一批逻辑相同、但某些字段存在差异的对象比如同一个订单模板生成了很多子订单。你希望保存某个对象的历史快照用于后续的撤销、比较、审计等操作。 提示还有一类场景也很常见——对象对外暴露时不想被外部修改可以复制一份防篡改副本返回给调用方。这个在DDD中也很常见聚合根返回给外部时往往返回的是一个副本避免外部直接改掉内部状态。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 ListRouteRule 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实现拷贝构造与虚cloneC里做原型模式有个天然的便利——拷贝构造函数和赋值运算符本身就是复制创建对象的机制。所以实现起来比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_ptrOrder clone() const { return std::make_uniqueOrder(*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 MapString, 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, ConsumerSystemConfig 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方法里传入一个IdentityHashMappublic class Order { protected Order clone(IdentityHashMapObject, 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序列化、手动克隆几种策略都放进去通过一个枚举参数来切换这样调用方不需要关心具体的深拷贝方案出问题时也方便统一排查。后面如果项目性能紧张也只需要改一个方法不用到处动代码。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价