简介这份PPT围绕软件设计模式中的原型模式展开面向正在学习面向对象设计、准备课程汇报或复习创建型模式的学生与开发者帮助厘清通过复制原型对象创建新实例的核心思路。内容从概念与意图切入延伸到适用场景、优缺点权衡并重点区分浅克隆与深拷贝的实现差异结合Java中clone()方法、Cloneable接口及CloneNotSupportedException异常说明落地要点。课件还梳理客户类、抽象原型类与具体原型类的角色关系给出ConcretePrototype与Client的代码示例便于对照理解复制过程、异常处理以及对象状态一致性等注意点。压缩包共1个pptx文件约5.93MB以幻灯片形式组织理论、对比与示例适合课堂展示或自学查阅。目前已有192人学习可作为设计模式专题的轻量参考资料也便于面试前快速梳理创建型模式的要点。1. 原型模式复制一个对象为什么反而比 new 更快第一次接触原型模式多数人的直觉是反的复制一个对象怎么可能比 new 一个更快new 只是分配一块干净内存复制还得把字段一个个抄过去。这个直觉在对象只有三五个字段时成立但落到真实系统里常常反过来——真正贵的从来不是那块内存而是构造过程读配置、查缓存、算校验码、拼装子对象、灌一堆默认值。原型模式解决的正是这件事。它是设计模式里的一种创建型模式思路朴素到有点作弊把一个已经构造好的对象当作图纸后续实例都从它身上抄而不是重新描述一遍怎么造。这套课件把原型模式的角色结构、浅克隆与深克隆的内存差异、以及一份完整的邮件复制代码摆在一起恰好覆盖了知道它会用到明白它为什么这么设计之间那段最容易卡住的路。所以下面就从 clone() 这个看起来平平无奇的方法说起。2. clone() 的契约为什么复制对象可以绕开构造器2.1 Cloneable 是个空接口凭什么能拦住一次 clone 调用翻开 JDK 源码Cloneable里面什么都没有没有方法没有常量纯得像个占位符。而Object.clone()的声明是protected native Object clone() throws CloneNotSupportedException;native说明它在 Java 层面看不到实现真正的逻辑在虚拟机里。那么类必须实现 Cloneable 才能被克隆这条规则由谁保证答案是运行时检查不是编译期检查。虚拟机在进入克隆逻辑之前会先判断当前对象的类型是否实现了Cloneable没实现就直接抛CloneNotSupportedException。这是一个相当特别的设计取舍。换成合理的做法应该是把 clone 定义成接口方法让编译器替你检查——但那样所有类都被迫重写它。标记接口的方案让不需要克隆的类什么都不用做代价是把一部分错误从编译期推迟到了运行期。血泪经验通常就诞生在这一次推迟上代码编译通过跑起来才炸。课件里的抽象原型类是这样的// 抽象原型类给出所有具体原型类所需的接口 abstract class Prototype implements Cloneable { Override protected Prototype clone() throws CloneNotSupportedException { // super.clone() 是 Object 的 native 方法返回的是 Object必须强转 return (Prototype) super.clone(); } }这里有两件事值得单独说清。第一super.clone()返回类型是Object强转不是可选项第二可见性是protected这意味着跨包调用时Client根本看不到这个方法实际项目里通常在具体原型类中把可见性提升到publicJava 5 之后还允许覆盖方法返回子类型协变返回类型于是ConcretePrototypeA.clone()可以直接声明返回ConcretePrototypeA调用方连强转都省了public class ConcretePrototypeA extends Prototype { Override public ConcretePrototypeA clone() throws CloneNotSupportedException { // 协变返回类型覆盖时收窄返回类型调用方无需强转 return (ConcretePrototypeA) super.clone(); } }2.2 三条契约判断一次克隆是否还算个克隆课件的概述部分列了三条约定这三条不是形式主义它们是判断clone()有没有被写歪的标准契约含义被破坏的典型原因x.clone() ! x克隆对象与原对象不是同一块内存重写 clone 时直接return thisx.clone().getClass() x.getClass()克隆对象类型与原对象一致手工 new 一个再逐字段赋值x.clone().equals(x)equals定义恰当时两者应当相等只复制了部分字段漏字段第二条尤其容易被忽略。如果用新建一个空对象再手动 set 一遍的方式实现 clone一旦父类被继承、子类没跟着改返回的就会是父类型的实例getClass()立刻对不上。而super.clone()走的是虚拟机路径返回的对象和原对象属于同一个运行时类这条契约天然成立——包括子类的额外字段因为整块内存是被原样复制的。课件里 Client 的验证代码就是把这三条拉开看一眼public class Client { public static void main(String[] args) throws CloneNotSupportedException { Prototype prototypeA new ConcretePrototypeA(); Prototype copyA1 prototypeA.clone(); Prototype copyA2 prototypeA.clone(); // 三个引用打印出来的哈希段不同说明是不同的实例 System.out.println(原对象A prototypeA 类型 prototypeA.getClass()); System.out.println(克隆A1 copyA1 类型 copyA1.getClass()); System.out.println(克隆A2 copyA2 类型 copyA2.getClass()); } }客户类在这里只依赖抽象原型类Prototype它不知道ConcretePrototypeA构造时做了哪些准备工作也不需要知道。这正是对扩展开放的落点——新增一种原型只需要新增一个实现类Client 一行不改。2.3 Object.clone() 内部到底做了什么克隆之所以比 new 便宜秘密在于它跳过了构造器。虚拟机在克隆时做的动作大致是按原对象的大小分配一块新内存然后把原对象的字段逐个复制过去整个过程不执行构造方法、不执行实例初始化块、不重跑字段的默认赋值。这解释了为什么克隆能绕开昂贵的构造过程也解释了后面所有坑的来源如果构造器里做过某些登记性质的副作用往缓存里写一份、把自己挂到监听器列表、生成一个唯一 ID那么克隆出来的对象就完全没参与过这些流程——它存在于内存里却不在系统的账本上。这类故障的表现非常玄学对象明明打印得出、字段全对就是不生效。再看字段复制这一步。基本类型int、long、boolean复制的是值本身彼此独立引用类型复制的是引用也就是地址。也就是说克隆完成后两个对象的引用字段指向堆上的同一块区域。这个行为有个正式名字叫浅拷贝它是Object.clone()的默认行为也是一个必须由使用者自己决定要不要改的默认值。还有一点常被忽略clone()不区分字段是不是final也不走任何访问控制它复制的是内存布局不是你觉得该复制的那些字段。所以一个类只要有一个可变引用字段浅克隆就会把它带过来共享。3. 浅克隆与深克隆引用字段的两份内存到底怎么算3.1 浅克隆值类型复制一份引用类型只抄地址课件对浅克隆的定义很干脆原型对象的成员变量是值类型时复制一份给克隆对象是引用类型时把引用对象的地址复制一份两个对象的成员变量指向同一块内存。把这句话画成内存视图大致是这样栈 堆 emailA ──▶ [ Email 实例 A ] sender → aroma字符串常量池共享 subject → 原型设计 attachment ─┐ ▼ emailB ──▶ [ Email 实例 B ] [ Attachment 实例 ] sender → aroma download() subject → 原型设计 只有一份 attachment ─┘两个 Email 实例是两块独立内存这是x.clone() ! x保证的但attachment这个字段抄过去的是地址两个箭头落到了同一个 Attachment 对象上。于是修改克隆对象的附件和修改原对象的附件其实是同一件事。这个特性在某些场景下是优点——共享一份只读的大对象省内存又省时间在另一些场景下就是灾难。判断标准只有一条这个引用字段会不会被改动。只读就共享可写就必须断开。3.2 深克隆两条路线手写递归和序列化深克隆的要求是无论值类型还是引用类型都复制一份克隆对象与原对象不共享任何子对象。常见实现思路有两条。第一条是手工递归每个引用字段都在 clone 里显式clone()一次再 set 回新对象。Override public Email clone() throws CloneNotSupportedException { // 先让虚拟机复制本对象的字段此时 attachment 仍是共享的 Email copy (Email) super.clone(); // 再把引用字段单独克隆一份挂到副本上 copy.attachment (Attachment) this.attachment.clone(); return copy; }第二条是序列化把整个对象写进字节流再从字节流读出来读出来的那棵树天然与原对象没有任何引用关系。课件采用的是第二条理由很实在——对象层级一深手工递归的代码量和出错概率都是指数级上升的而序列化只需要一行writeObject。序列化的代价也很明确参与序列化的每一个类包括引用链上的所有子对象都必须实现Serializable任何一个环节不支持整条链路立刻断掉transient修饰的字段不会被写进流里读回来就是默认值静态字段本来就不属于对象实例不参与性能比手工克隆差一个数量级。注意Serializable和Cloneable一样是标记接口区别在于前者由序列化机制在运行时检查检查失败抛的是NotSerializableException通常要等到运行到那一行才暴露。3.3 循环引用手写深克隆会栈溢出序列化却不会这是深克隆里最容易被忽视的一个分水岭。假设 A 引用 BB 又反过来引用 A手写递归的clone()会陷入 A.clone → B.clone → A.clone 的无限循环最后抛StackOverflowError。序列化不会掉进这个坑因为ObjectOutputStream内部维护了一张引用表handle table同一个对象在一次序列化过程中只会被写一次第二次遇到时写的只是一个引用编号。反序列化时按编号还原读出来的对象图里 A 和 B 的互相引用关系被原样保留而且仍然是同一对实例。这也顺带解释了为什么序列化深克隆有时反而更正确它复制的是对象图的结构不是一棵被手工展开的树。对象图里有共享节点两个字段指向同一个子对象时序列化后这种共享关系依然存在手工递归如果每个字段都 clone 一次共享关系就被悄悄拆成了两份语义已经变了。4. 邮件复制案例浅克隆的翻车点与序列化深克隆的完整链路4.1 浅克隆版 Email一行 super.clone() 埋下的共享课件的场景很贴合真实需求邮件对象字段多发送者、接收者、标题、正文、日期、附件构造一次成本不低现在要基于已有邮件快速产出一封新邮件只改其中几个字段。public class Attachment { public void download() { System.out.println(下载附件); } } public class Email implements Cloneable { private String sender; private String receiver; private String subject; private String body; private String date; private Attachment attachment; // 引用类型字段 public Email(String sender, String receiver, String subject, String body, String date, Attachment attachment) { this.sender sender; this.receiver receiver; this.subject subject; this.body body; this.date date; this.attachment attachment; } Override public Email clone() throws CloneNotSupportedException { // 浅拷贝attachment 字段只是把地址抄了一份两个 Email 共享同一个附件 return (Email) super.clone(); } public void display() { System.out.println(发送者: sender 接收者 receiver 标题 subject 内容 body 日期 date 附件 attachment); } }客户类的写法就是课件里的那段public class Client { public static void main(String[] args) throws Exception { Attachment attachment new Attachment(); Email original new Email(aroma, kira, 原型设计, 邮件复制, 2025, attachment); Email cloned original.clone(); // 改普通字段String 是不可变对象重新赋值只影响副本 cloned.setBody(浅克隆 - 邮件复制); // 危险操作把副本的附件换掉实际改的是共享变量的指向 Attachment another new Attachment(); cloned.setAttachment(another); original.display(); cloned.display(); } }String字段之所以看起来安全是因为字符串不可变对副本重新赋值只是让副本的字段指向另一个常量原对象完全不受影响。真正的坑在attachment如果代码没有走setAttachment换新对象而是调用cloned.getAttachment().download()或修改附件内部状态改动会立刻反映到原邮件上。这就是 shallow clone 最典型的翻车现场——验证时只改了 String 字段测试全绿上线后附件互相污染。4.2 深克隆版 Email序列化链路的每一步深克隆版本把两个类都改成Serializable克隆方法换成流操作import java.io.*; public class Attachment implements Serializable { public void download() { System.out.println(下载附件); } } public class Email implements Serializable { private static final long serialVersionUID 1L; // 版本号避免序列化后类结构变化导致读取失败 private String sender; private String receiver; private String subject; private String body; private String date; private Attachment attachment; // 省略构造方法与 getter/setter public Email deepClone() throws IOException, ClassNotFoundException { // 1. 内存字节数组作为中转避免落盘 ByteArrayOutputStream bos new ByteArrayOutputStream(); ObjectOutputStream oos new ObjectOutputStream(bos); // 2. 把 this 以及它引用到的整张对象图写进流里 oos.writeObject(this); oos.close(); // 3. 从字节数组读回得到一个全新对象图 ByteArrayInputStream bis new ByteArrayInputStream(bos.toByteArray()); ObjectInputStream ois new ObjectInputStream(bis); Email copy (Email) ois.readObject(); ois.close(); return copy; } }几个参数与步骤的用意值得逐条拆开ByteArrayOutputStream是内存缓冲区用它代替FileOutputStream就不需要真的写磁盘深克隆全程在堆内完成速度比落盘快得多也不会留下临时文件。ObjectOutputStream.writeObject(this)是整条链路的核心。它不仅写当前对象还会沿着引用关系递归写出所有可达子对象。这也意味着附件类必须实现Serializable否则执行到这一行直接抛NotSerializableException。bos.toByteArray()把缓冲区里的字节取出来交给ByteArrayInputStream。这一步是必须的——ByteArrayOutputStream本身不支持读必须重新包一个输入流。ois.readObject()会调用类的无参构造吗对于实现了Serializable的类反序列化不走构造器而是由虚拟机直接创建实例再灌字段。这一点和clone()绕过构造器是同一个性质带来的坑也一模一样。4.3 两版对比什么时候浅克隆就够了维度浅克隆深克隆序列化实现成本一行 super.clone()所有相关类实现 Serializable写流操作引用字段与原对象共享完全独立性能高接近内存拷贝低一个数量级与对象图大小成正比对类的要求实现 Cloneable实现 Serializable且整条引用链都能序列化循环引用不涉及由流内部的引用表自动处理适用场景对象只含不可变字段或共享是刻意设计对象含可变子对象且副本必须独立演化选型的判断点不是深克隆更安全所以一律用深的而是这个引用字段会不会在副本生命周期里被改动。附加一个现实约束如果对象图里混进了第三方库的类而它没有实现Serializable序列化路线就直接死了这时候只能回到手工递归或者干脆用拷贝构造器重新造一个。5. 排查原型模式最容易踩的五个坑5.1 共享与异常两个第一次就会撞上的问题坑一改了克隆对象的字段原对象跟着变现象拿到克隆对象后调用getAttachment().download()或者修改附件内部属性原邮件的附件同步变了日志里两条记录的附件 ID 一模一样。原因super.clone()只做字段级的赋值引用字段复制的是地址两个对象指向同一个 Attachment 实例。如果只验证了 String 这类不可变字段问题不会暴露测试看起来一切正常。解决判断该字段是否可写。可写就实现深克隆不想改类结构就加拷贝构造器把引用字段也 new 一份。验证方法很简单在克隆后打印original.getAttachment() cloned.getAttachment()返回 true 就说明还在共享。坑二明明实现了 Cloneable还是抛 CloneNotSupportedException现象类声明里写了implements Cloneable运行到clone()那行依然抛异常。原因异常不一定来自没实现接口。如果继承链中间某一层重写了clone()却没有调用super.clone()或者显式throw new CloneNotSupportedException()链就断在那里了。另一种情况是调用方通过一个没暴露 clone 的父类型引用来调用编译器直接拒绝报的是另一个错。解决沿着继承链逐层检查每一处clone()是否都调用了super.clone()。断链的典型形态是某个中间类顺手重写了一遍 clone 又忘了转发。补上一行super.clone()整条链就通了。5.2 构造器与性能不报错但结果不对的那类问题坑三序列化深克隆之后transient 字段变成了 null现象原对象里有个transient修饰的字段比如一个锁对象或者缓存句柄构造时初始化过克隆出来却是 null后续调用直接空指针。原因transient的语义就是不参与序列化ObjectOutputStream写的时候会跳过它读回来自然只有默认值。这不是 bug是设计约定但很容易在改造旧类时被忽略。解决如果这个字段必须随对象复制就别用序列化这条路线改成手工深克隆或在反序列化后重新初始化。常见做法是实现readObject私有方法在流读完之后手动把 transient 字段补上。坑四克隆出来的对象存在但不生效现象对象字段全对打印正常但业务上就是不发挥作用——比如它没有被注册进缓存、没出现在监控列表里、事件监听不到。原因clone()和反序列化都不执行构造器。如果构造器里做过登记性质的副作用把 this 塞进静态集合、生成全局唯一编号、绑定资源克隆对象完全没参与过这些流程。这是原型模式最隐蔽的一类故障排查时看代码完全合理。解决把这类副作用从构造器里抽出来放进一个显式的init()或工厂方法克隆完成后手动调用。或者反过来接受克隆对象不走初始化在设计上就不要依赖构造器副作用。坑五深克隆比 new 还慢甚至直接栈溢出现象压测时发现克隆路径反而成了瓶颈或者大对象图克隆时抛StackOverflowError。原因序列化要写字节流涉及反射和流对象创建对象图越大开销越明显小对象上确实可能不如直接 new。栈溢出则出现在手工递归版本里对象图存在循环引用时递归没有出口。解决先用简单基准测一下两条路径的耗时别凭感觉选。循环引用的场景放弃手工递归改用序列化流内部有引用表能处理环对象图特别大且只需复制一层的考虑拷贝构造器把复制范围控制在必要字段上。6. 设计取舍Cloneable 这个空接口值不值得用把整条链路走完会发现原型模式真正的争议点不在深浅克隆怎么选而在Cloneable这套机制本身。它的优点很清晰绕开构造器创建成本低运行时可动态替换具体类型客户端只依赖抽象原型对大量相似对象、只改少量字段的场景几乎是零成本的解。课件里那个邮件复制就是典型例子构造一封邮件要拼六个字段外加附件装配克隆只要一行。它的代价同样清晰。空接口把类型检查推到了运行期编译通过不等于能跑clone()绕过构造器一切依赖构造器副作用的逻辑都会失效默认的浅拷贝语义与复制这个词给人的直觉不符第一次用几乎必踩深克隆要么递归写到吐要么全链路Serializable。这几条叠在一起就是为什么很多团队在代码规范里直接禁用clone()改用拷贝构造器或静态工厂。我的看法是分场景。对象结构稳定、字段少、复制频繁比如游戏里的子弹、报表里的行、消息队列里的模板消息原型模式非常合适深克隆用手写递归比序列化更可控。对象图深、含第三方类、还要处理循环引用那Cloneable基本可以放弃老老实实写序列化工具或者映射式的对象拷贝。从那以后我每次给一个类加clone()都会先强制走一遍三件事打印确认引用字段是否共享、检查继承链上每一层有没有转发super.clone()、确认构造器里没有登记类副作用。这三步花不了五分钟但能挡掉上面五个坑里的四个。这份课件把角色结构、内存示意和两版邮件复制代码放在一处对着看比零散搜代码更容易把这条链路一次串起来想深挖的话值得存一份在手边。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?