资讯详情

原型模式实战:深浅拷贝与clone的正确打开方式

📅 2026/10/7 4:21:39 | 华诺云谱 👁 阅读
原型模式实战:深浅拷贝与clone的正确打开方式
前几天在处理一个消息推送平台的需求时我被一个看似简单的问题折磨了一下午。同一批活动消息要推给短信、App Push、邮件三个渠道每个渠道的文案长短、链接参数、附件信息都不一样但90%的字段是共用的。第一版我老老实实写了三套创建逻辑每条渠道二十多行getter/setter。后来加了两个字段改得我想摔键盘。就是在那个节点上我重新认真翻了原型模式Prototype Pattern才发现这个被很多人当成“就是clone一下”的模式里面藏的坑比想象中多得多。如果你正在复习23种设计模式或者在准备设计模式期末、设计模式大作业又或者工作中正被“复制对象”这件事恶心过这篇应该能给你一些书本之外的经验。原型模式的核心概念其实一句话就能讲完不通过构造函数创建新对象而是通过复制一个已有的对象来得到新对象。但“复制”这两个字在Java里能延伸出一整条深浅拷贝的鄙视链。我接下来会从“什么时候需要复制对象”讲起然后重点拆解深浅拷贝的区别和坑再把我实际遇到的一个报表模板数据串号问题完整复盘一遍最后聊聊性能对比和原型模式怎么跟工厂模式配合使用。这些都是原始文档里不会写、但真正遇到问题时能救命的细节。1. 复制对象这个需求从哪来从一个消息推送的改动说起1.1 二十多行setter让我开始认真研究复制对象先回到开头说的那个场景。假设有一个PushMessage对象字段包括消息标题、正文、跳转链接、渠道标识、优先级、定时时间、扩展属性Map等一共二十多个字段。改造前我的代码长这样PushMessage smsMsg new PushMessage(); smsMsg.setTitle(originMsg.getTitle()); smsMsg.setContent(trimToLength(originMsg.getContent(), 50)); smsMsg.setLink(originMsg.getLink()); smsMsg.setChannel(SMS); // 后面还有20行类似的set每个渠道都来一遍字段一多就开始漏漏了还不好排查。而且一旦消息模型新增字段所有渠道都要同步改属于典型的“低质量重复劳动”。我当时接手这段代码时光是核对三个渠道有没有漏set字段就花了半小时。后来我改成原型模式整体逻辑变成维护一个基础消息对象每个渠道拿着这个对象复制一份复制完只改自己关心的差异字段。// 基础消息 PushMessage smsMsg (PushMessage) originMsg.clone(); smsMsg.setContent(trimToLength(originMsg.getContent(), 50)); smsMsg.setChannel(SMS); PushMessage appMsg (PushMessage) originMsg.clone(); appMsg.setLink(buildAppLink(originMsg.getLink())); appMsg.setChannel(APP);代码量直接砍掉一大半后续加字段只需要在基础消息里加所有渠道自动继承。这就是原型模式最典型的业务价值当创建对象的成本完全可控但“复制一份再改改”的成本远低于“重新组装一份”时复制就是最合理的选择。1.2 原型模式和“new一个对象”的本质差异很多人觉得原型模式不就是clone()吗从表面看没错但底层逻辑完全不一样。new创建对象时构造函数一定会执行所有字段都要重新赋值而原型模式复制对象时走的是Object.clone()这个native方法它在内存层面直接复制二进制数据不调用构造函数。这意味着两件事复制出来的对象所有字段的初始值都和原型一样不用一个个重新赋值对象创建过程中如果有附加逻辑比如计数器累加、资源注册、默认值校验这些逻辑在复制时不会执行。第二点很多人会忽略我在第4章会专门展开说。这里先记住一个结论原型模式适合“对象本身初始化成本高但复制成本低”的场景比如需要加载配置、查询数据库、解析文件才能拿到完整数据的对象。这种对象每次 new 一次就要重新走一遍初始化流程而 clone 一次就相当于直接拿现成的。1.3 什么场景该用、什么场景别硬用我实际用下来比较适合原型模式的有这几类模板类对象比如报表模板、消息模板、通知模板。用户每次新建报表实际是复制一份模板再改内容。多分支差异化对象像开头说的多渠道消息90%字段相同少量字段不同。对象状态快照比如要回滚某个复杂对象的历史状态复制一份当前状态留底。避免重复查询的场景对象构造需要查库、调接口复制可以绕开这些开销。不适合的场景也很明确对象结构极简两三个字段直接 new对象持有外部资源数据库连接、文件句柄、Socket复制逻辑本身比初始化还复杂。这些时候硬套原型模式属于给自己找麻烦。2. 浅拷贝和深拷贝原型模式里绕不开的坎原型模式最大的坑不在clone()会不会用而在复制完之后两个对象到底是不是真的“互不干扰”。很多初学者第一次写原型模式复制出来的对象改一个字段结果原型也跟着变当场懵。这就是深浅拷贝的问题。2.1 浅拷贝到底发生了什么先给结论Object.clone()默认是浅拷贝。什么叫浅拷贝基本类型字段int、long、boolean这些确实是各复制一份互不影响但引用类型字段对象、List、Map这些复制的是地址不是对象本身。我用一个生活化的类比解释你去档案室复印一份档案复印机把档案封面、每一页文字都复制出来了这是浅拷贝能覆盖的部分。但如果档案里夹了一张原始照片复印机只是告诉你“照片在这里”并没有真正把照片洗印一份新的。这时候你手里的复印件和档案室的原件看起来都有这张照片但其实是同一张。对应到代码里就是public class Address { private String city; private String street; // getter/setter } public class Person implements Cloneable { private String name; private int age; private Address address; // 引用类型 Override public Object clone() throws CloneNotSupportedException { return super.clone(); // 浅拷贝 } }当你Person copy (Person) person.clone()之后copy.address和person.address指向的是同一个Address对象。你改copy.address.setCity(上海)原对象的 city 也跟着变成上海。这就是浅拷贝的经典陷阱。2.2 深拷贝的三种实现路径要彻底解决引用共享问题就得做深拷贝也就是把对象图里所有可变对象都复制一份新的。我常用的有三种方案按复杂度和侵入性排序第一种手动级联克隆。在clone()方法内部对每个引用字段也调用它自己的拷贝逻辑Override public Object clone() throws CloneNotSupportedException { Person copy (Person) super.clone(); if (this.address ! null) { copy.address (Address) this.address.clone(); // 要求Address也实现Cloneable } return copy; }这种方式的优点是性能最好、逻辑最透明——你想拷贝哪些字段、不拷贝哪些字段自己说了算。缺点是嵌套多了以后手写量很大而且每个嵌套类都要实现Cloneable。第二种序列化复制。把对象写到流里再读出来整个过程会重新生成一个完整的新对象天然就是深拷贝。JDK里没有直接的API但Apache Commons Lang的SerializationUtils.clone()就是干这事的SomeClass copy SerializationUtils.clone(original);这种方式要求对象以及所有嵌套对象实现Serializable接口而且复制过程中有序列化和反序列化的开销性能是几种方案里最差的。但对于结构很深、又不想一个个手写克隆方法的对象它是最省事的。第三种JSON/二进制序列化代理。用Jackson、Gson把对象转成JSON字符串再转回来本质上也是深拷贝只是走文本格式。优点是依赖库一般项目里都有不用额外引缺点是性能更慢而且对象里如果有Date、BigDecimal这种类型反序列化时要注意格式配置否则容易踩坑。2.3 不是所有字段都必须深拷贝先界定边界这里有必要点破一个误区深拷贝不是说所有字段都得重新创建。以下三类字段可以放心共享引用不用管不可变对象String、Integer、BigDecimal、枚举它们的值一旦创建就不可变多个对象共享同一个引用也不会出现“一个改了另一个变”的问题。显式共享的元数据比如只读的排行配置、全局字典表深拷贝它们纯粹是浪费内存。业务上本来就该共享的字段比如多个订单共用一个商品快照这个快照本来就不允许单独修改。所以说深拷贝真正要关注的是可变引用字段——List里的元素、自定义对象、Map的value这些才是事故高发区。做设计时先列一张表哪些字段需要独立副本、哪些字段允许共享比无脑全部深拷贝要靠谱得多。3. 一次线上报表模板串号完整的排查链路纸上谈兵的东西太多说说我真实遇到的一个生产事故。那次是关于报表模板的现象很简单运营同学往模板A里加了一个新指标保存的时候系统提示修改成功。然后他去打开模板B发现模板B里也莫名其妙多了这个指标。用户第一反应是程序写错了把A的修改写到了B上。3.1 初步怀疑缓存、静态变量、还是共用同一个对象我当时的第一反应也是怀疑写操作串了。于是按常规思路排查先查 service 层是不是用了缓存模板A和模板B是不是命中了同一个缓存key再看模板对象是不是被某个静态变量持有最后查数据库确认修改SQL里的where条件是不是没用模板ID过滤。这三步查下来全都没问题缓存配置正确SQL也是按模板ID精确更新的。数据库里A和B的记录也确实是两条独立数据。也就是说持久层的对象是正确的问题出在内存里的对象引用共享。但这时候我还没意识到是原型模式的锅因为这块逻辑用的确实是clone操作。3.2 用identityHashCode定位共享引用继续往下查我在代码里看到了类似这样的逻辑ReportTemplateBO bo templateService.getById(templateId); ReportTemplateBO copy (ReportTemplateBO) bo.clone(); copy.setName(副本); // 然后对copy里的指标列表做增删改ReportTemplateBO的clone()方法当时长这样Override public Object clone() throws CloneNotSupportedException { ReportTemplateBO copy (ReportTemplateBO) super.clone(); if (this.indicators ! null) { copy.indicators new ArrayList(this.indicators); // 只new了外层List } return copy; }问题就出在new ArrayList(this.indicators)这一行。它确实创建了一个新的List对象但List里装的元素——IndicatorBO——还是原来那批对象。我打了个System.identityHashCode()一看原模板和副本里的indicator.get(0)是同一个对象。复制出来的副本只是拿了一个新篮子篮子里的鸡蛋还是原来那一盒。运营同学往A模板添加的新指标本质上是往共享的IndicatorBO对象列表里追加数据由于A和B的副本共享了同一个内层对象B那边自然也就“看到”了新指标。3.3 修复方案链路里所有可变对象都要逐层复制修复思路就一句话从模板对象到指标体系一条链路里所有可变对象都要重建。Override public Object clone() throws CloneNotSupportedException { ReportTemplateBO copy (ReportTemplateBO) super.clone(); if (this.indicators ! null) { copy.indicators this.indicators.stream() .map(indicator - { IndicatorBO indCopy new IndicatorBO(); indCopy.setId(indicator.getId()); indCopy.setName(indicator.getName()); indCopy.setFormula(indicator.getFormula()); // 如果IndicatorBO里还有嵌套对象继续深拷贝 return indCopy; }) .collect(Collectors.toList()); } return copy; }改完后我加了一个单元测试专门断言原模板和副本的indicators.get(0)不是同一个引用。从那以后这类问题回归测试第一时间就能暴露不用再靠线上用户体验来发现了。这个事故给我的教训很深刻写原型模式的时候一定要对每一个引用字段问一句它是“值语义”还是“引用语义”。值语义的字段复制值引用语义的字段要思考要不要复制对象。很多人只new了最外层容器就以为万事大吉其实只是把事故推迟到了运行期。4. clone() 绕过构造函数这件事到底会带来什么前面提到Object.clone()不调用构造函数这个特性带来的影响比大多数人想象中严重。它不是一句“哦我记得有这么回事”就能带过的实际项目里它真的会引发看起来完全不相干的bug。4.1 Cloneable 接口与 Object.clone() 的隐藏门槛先说最基础的门槛。Java里想用clone()你的类必须实现Cloneable接口。这个接口里没有任何方法它只是一个标记用来告诉JVM“这个类允许被克隆”。如果不实现直接调clone()运行期会抛CloneNotSupportedException。之所以这么设计是因为Object.clone()是protected native方法它的实现逻辑在JVM底层不经过你写的任何Java代码。JVM在底层复制完对象后默认把所有引用字段直接复制地址这就是浅拷贝的根源。我见过不少同学为了图简单直接在类上写implements Cloneable然后clone()方法里写return super.clone()完事。这样代码能跑但只要你这个类里有任何一个可变引用字段早晚出事故。实现Cloneable不等于实现了深拷贝这俩是两码事。4.2 一个被掩盖的计数器bug构造函数没执行会怎样讲一个我后来才遇到的坑。有个类叫WorkOrder构造函数里维护了一个全局计数器和一份创建日志public class WorkOrder implements Cloneable { private static AtomicInteger createCount new AtomicInteger(0); private ListOperationLog logs; public WorkOrder(String orderNo) { createCount.incrementAndGet(); this.logs new ArrayList(); logs.add(new OperationLog(CREATED)); } Override public Object clone() throws CloneNotSupportedException { return super.clone(); } }某个版本里产品要求统计工单创建次数结果上线后发现数字总是不对。大家一开始怀疑计数器并发有问题排查了很久最后才发现罪魁祸首是某个逻辑用了clone()生成工单副本而clone()完全不经过构造函数createCount根本没有增加logs里的“CREATED”日志也没写。这类问题非常隐蔽因为它不会报错只会让你统计出来的数据悄悄变少或关键初始化状态缺失。**只要构造函数里有任何逻辑——默认值初始化、校验、注册、计数、发事件——你都要明确地知道复制出来的对象没有执行这些逻辑。**想要复制品也具备这些状态必须在clone()方法里手动补上。4.3 final字段、static字段和不可变对象的特例处理clone()这块还有三个边界情况常被人忽略第一final引用字段的坑。如果一个引用字段被声明为final浅拷贝时它指向的还是旧对象而且因为final不能重新赋值你在clone()里想给它换一个新对象会编译不通过。遇到这种情况要么把这个字段改成非final要么在深拷贝设计时直接别让这种字段出现在需要独立副本的路径上。第二static字段根本不会被复制。static字段属于类不属于实例clone()是在实例层面的操作它不会帮你复制类的静态成员。这其实是好事静态字段本来就是全局共享的如果被复制才奇怪。第三不可变对象和基本类型字段浅拷贝完全够用。String虽然也是引用类型但它的不可变性决定了多个实例共享同一个String引用不会产生任何状态污染。Integer、Long、BigDecimal类似。所以深拷贝设计时这类字段直接跳过不用浪费性能。5. 深拷贝方式性能实测与我的选型逻辑前面聊了各种拷贝方案这里直接聊性能。说一个我自己的测试结论在低频率业务场景下方案之间的性能差距根本感知不到但如果你要在一个高并发路径里反复复制对象选错方案真的会成为性能瓶颈。5.1 我跑过的简易对比测试我当时用一个中等复杂度的对象——嵌套两层、含List和自定义类型——做了100万次复制分别用手写级联克隆、clone浅拷贝、反射BeanUtils、JSON序列化、二进制序列化五种方式本机测试的大致表现如下拷贝方式相对耗时是否深拷贝代码量备注手写级联克隆1x完全可控较多性能最好嵌套深时维护量递增clone浅拷贝1-1.5x否最少性能最高但只适合无可变引用字段的场景反射BeanUtils5-10x否很少通用性高但只会拷一层且反射有额外开销JSON序列化20-50x是很少依赖Jackson/Gson注意日期格式二进制序列化50-100x以上是很少SerializationUtils.clone()安全性要留意这是相对量级的参考不同JVM版本、不同对象复杂度下会有波动但大方向不会变手动拷贝 浅clone 反射 JSON 二进制序列化。5.2 性能与代码量的取舍大部分项目应该选哪个如果看绝对性能手写级联克隆吊打一切但它有两个副作用一是每个被拷贝的类都得写一遍逻辑改动时容易漏二是嵌套层次深了以后代码很长很啰嗦可读性下降。反过来看二进制序列化虽然慢了一两个数量级但如果你只是在用户点按钮的时候复制一个模板对象——这个操作一天几千次多花那几毫秒完全无感——它的“代码量最少、全深拷贝、逻辑统一”就成了压倒性优势。所以我的选型逻辑是这样的对象简单两三层字段手写克隆清清楚楚。对象复杂但复制频率低优先序列化方式省心最重要。对象复杂且复制频率高手写级联克隆并用单元测试锁住“每个嵌套对象都是新引用”这个约定。只想要浅拷贝直接super.clone()但你必须确定所有引用字段都是不可变对象。5.3 一个需要警惕的小细节序列化深拷贝的安全性问题用SerializationUtils.clone()虽然方便但它在底层会用到反射和不安全操作如果你的项目对反序列化来源有安全性要求比如对象可能来自不可信数据源那要谨慎使用防止构造出恶意对象。这点在官方文档里不太起眼但在安全审查时会被专门问到的。同理JSON反序列化也要配置好允许的类型范围避免多态反序列化漏洞。6. 原型模式怎么跟工厂搭配一个复制注册中心的实践最后说一个原型模式在实践中很常见的搭配思路——原型注册表Prototype Registry。这不算新概念GoF书里也提过但很多教程只是把它当名词念一遍没讲清楚到底为什么要这样搞。6.1 为什么需要注册中心而不是每次重新创建我们项目里有几十种模板分别对应不同报表类型、不同消息渠道。如果每次需要模板都从数据库加载一遍再手动构造代价高还容易出错。这时候我把所有模板预先加载到内存放在一个Map里Map的value就是一个个原型对象。要使用某个模板时从Map里取出原型调用clone()生成副本副本随便改原型永远保持初始状态。这个做法等于把“创建对象”这件事标准化成“从库里取原型复制一个新实例”。业务代码不需要关心模板内部怎么构造、有哪些默认值它只需要知道模板的key就行。6.2 工厂类的完整示例我当时实现了一个TemplateFactory大概长这样Component public class TemplateFactory { private MapString, ReportTemplateBO prototypeMap new ConcurrentHashMap(); PostConstruct public void init() { // 从数据库或配置加载基础模板放进prototypeMap prototypeMap.put(DAILY_REPORT, buildDailyTemplate()); prototypeMap.put(MONTHLY_REPORT, buildMonthlyTemplate()); } public ReportTemplateBO getTemplate(String type) { ReportTemplateBO prototype prototypeMap.get(type); if (prototype null) { throw new IllegalArgumentException(unknown template type: type); } return (ReportTemplateBO) prototype.clone(); // 总是返回副本 } }使用方拿到的永远是副本不是共享的原型这就从根本上避免了“改副本把原型污染了”的问题。这个模式用在商品规格、消息模板、权限模型上都很好用。6.3 为什么我只会在特定场景推荐这套组合拳最后说几句真心话。原型模式加上注册中心确实能解决很多复制问题但它是有适用条件的。如果模板对象内部嵌套特别深加上序列化又慢又要实现Serializable那不如直接保留工厂方法每次new一个再设置默认值。我个人的判断标准是看“复制一份再改”和“重新造一个再初始化”之间的成本差有多大。成本差大原型模式就是明显优势成本差不多就别为了用模式而用模式。设计模式本身是工具不是教条。用了半年之后我的体会是原型模式最舒服的使用体验不是来自少了多少行代码而是来自**“业务方完全不关心对象是怎么造出来的”**。他们从工厂拿到的对象天然是独立的、可随意修改的不需要担心串号、污染、共享这些破事。当你把复制这个底层问题解决好了上层的业务代码会变得非常干净这是这个模式真正值钱的地方。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑