简介这份面向软件工程学习者与设计模式入门者的原型模式PPT围绕创建型模式中“复制已有对象生成新对象”的核心思想帮助读者理解其适用场景、优缺点与实现要点尤其适合创建成本较大、初始化逻辑复杂或需要保留公共信息仅做少量个性化设置的场景。压缩包内仅含1个pptx文件大小约5.93MB以概述、使用场景与优缺点、浅克隆和深克隆、代码示例等模块组织内容。课件结合Java中的clone()方法、Cloneable接口、抽象原型类、具体原型类及客户类调用示例说明浅拷贝与深拷贝的差异并提示CloneNotSupportedException等实现细节。已有192人学习适合用于课堂汇报、专题自学或快速搭建原型模式知识框架便于对照代码理解对象复制过程与设计取舍。1. 原型模式的真相省下的不是 new而是对象图的一次完整重建多数人第一次看到原型模式Prototype Pattern是在设计模式资料里读到「用一个原型实例指定创建对象的种类并通过复制这个原型创建新对象」。听起来像给 new 换了个写法甚至有点多余。但在实际项目中踩过问题的人都清楚这个模式的难点从来不是「怎么把对象复制一份」而是「复制到哪一层为止」。一个内部挂着引用类型字段的对象浅克隆只是把地址抄了一遍修改克隆体里的附件原对象会跟着变。这个现象背后是 Java 的 clone() 在字段级复制和对象图复制之间做的一次刻意取舍。搞懂这条边界比背下优缺点清单有用得多。2. Cloneable 与 clone 契约一个空接口凭什么决定对象能不能被复制2.1 Object.clone() 的三条契约java.lang.Object上挂着一个protected native Object clone()。native 意味着它不在 Java 层实现由 JVM 直接按对象的内存布局完成复制对象头照抄实例字段逐个复制——基本类型复制值引用类型复制指针。这个过程不会调用任何构造函数也不会执行字段初始化语句。Java 语言规范给 clone() 定了三条必须满足的约定写进注释里也写进了无数面试题// 约定一克隆对象和原对象不是同一个对象 x.clone() ! x // 约定二克隆对象和原对象的运行时类型相同 x.clone().getClass() x.getClass() // 约定三如果 equals 定义得当克隆对象应该和原对象相等 x.clone().equals(x)第一条决定了 clone 出来的东西必须占一块新内存而不是返回 this。第二条决定了super.clone()返回的是运行时类型所以在子类里做(ConcretePrototypeA) super.clone()是安全的——哪怕这个 clone 方法声明在父类上实际复制出的仍然是子类的完整字段布局。第三条是个软约定只有当类正确重写了 equals 时才有意义一旦你手写 clone 时漏了某个字段这条约定就会失败而且失败得很隐蔽。这里有个容易忽略的细节Object.clone()是protected的。这意味着包外的代码无法直接调用someObj.clone()必须在子类里把可见性提升为public才能让客户端调用。Java 允许子类在重写时放大可见性但反过来不行。原型模式里所有具体原型类都写public Xxx clone()原因就在这里不是风格问题是能否被调用的问题。2.2 为什么 Cloneable 是一个没有方法的空接口Cloneable接口里一行代码都没有它只负责标记。类没有实现它却调用clone()JVM 会抛出CloneNotSupportedException。这个设计在今天看有点别扭能力开关在接口上能力实现在 Object 上两者分离编译器完全帮不上忙。你完全可以写出一个实现了 Cloneable 但根本没重写 clone 的类编译通过运行时行为却取决于父类的实现。常见的实现思路是把它理解成一种「运行时的类型检查」Object.clone()内部会判断this instanceof Cloneable是就复制不是就抛异常。对比一下Serializable你会发现同一个套路标记接口 运行时判断。这类设计的好处是零侵入任何类只要加一个 implements 就能获得能力不需要继承某个特定的基类代价是把编译期能发现的问题推迟到了运行期。这也解释了为什么原型模式在 Java 里落地时会多出一层抽象原型类。既然 clone 方法得靠人写那就把这份重复劳动收敛到一处。2.3 抽象原型类把 clone 的重复代码收敛掉标准结构里有三个角色客户类提出创建请求抽象原型类声明 clone 接口具体原型类实现自己那一份复制逻辑。落到代码上抽象原型通常写成抽象类而不是接口// 抽象原型把「可复制」这件事收敛成一个类型 abstract class Prototype implements Cloneable { Override protected Prototype clone() throws CloneNotSupportedException { // 这里只做浅拷贝具体原型类通常不需要再重写 return (Prototype) super.clone(); } } // 具体原型 A class ConcretePrototypeA extends Prototype { private String tag A; Override public ConcretePrototypeA clone() throws CloneNotSupportedException { return (ConcretePrototypeA) super.clone(); } } // 具体原型 B class ConcretePrototypeB extends Prototype { private int weight 1; Override public ConcretePrototypeB clone() throws CloneNotSupportedException { return (ConcretePrototypeB) super.clone(); } }客户端的用法很直接public class Client { public static void main(String[] args) throws CloneNotSupportedException { Prototype prototypeA new ConcretePrototypeA(); Prototype copyA1 prototypeA.clone(); Prototype copyA2 prototypeA.clone(); // 三个对象的运行时类型都是 ConcretePrototypeA但内存地址各不相同 System.out.println(copyA1.getClass().getSimpleName()); System.out.println(prototypeA ! copyA1); // true } }用抽象类而不是接口有两个实际原因。其一接口在 Java 8 之前不能有方法体clone 的实现得每个具体类抄一遍即便现在能写 default 方法super.clone()在接口里也调不到 Object 的实现。其二抽象类可以强制子类继承 Cloneable 语义接口做不到强制。抽象类里super.clone()调用的是Object.clone()返回的却是运行时类型所以子类那一层强转是安全的——这个链条保证了「声明在父类、复制出子类」的效果。参数和异常也要说清楚clone()声明抛出CloneNotSupportedException这是个受检异常调用方要么 try-catch 要么往上抛。很多团队图省事直接在具体类里 catch 掉转成 RuntimeException短期没事长期会让「这个类到底能不能克隆」这件事彻底失去信号。3. 浅克隆为什么改一个附件字段会影响到原对象3.1 一次 clone 到底复制了什么要理解浅克隆得先看对象在堆里长什么样。一个 Java 对象由对象头和实例数据组成实例数据区按字段顺序排列int、long 这类基本类型直接存值String、Attachment 这类引用类型存的是一个指向堆上另一块内存的指针。Object.clone()做的事情是把这块实例数据区整体复制到新分配的内存里。基本类型字段复制后各不相干引用类型字段复制过去的是指针值两个对象里存着同一个地址。结果就是你得到了两个外壳但它们内部的引用指向同一份数据。用文字描述这个过程原对象 Email0x1000 的 attachment 字段值是 0x2000克隆出的 Email0x3000 的 attachment 字段值也是 0x2000。0x2000 上那份 Attachment 只有一份谁改谁生效。这就是「浅」的含义——只复制到第一层再深就不管了。3.2 邮件复制案例里的引用共享把场景落到邮件对象上一封邮件包含发送者、接收者、标题、正文、日期和附件。前五个都是 String唯独附件是一个独立对象。public class Attachment { private String fileName; public Attachment(String fileName) { this.fileName fileName; } public void download() { System.out.println(下载附件 fileName); } public void setFileName(String fileName) { this.fileName fileName; } }浅克隆版本的邮件类长这样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; } public Attachment getAttachment() { return attachment; } Override public Email clone() throws CloneNotSupportedException { // super.clone() 完成字段级复制attachment 字段复制的是引用地址 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(需求文档.pdf); Email original new Email(发送者A, 接收者B, 原型模式, 邮件复制, 2025, attachment); Email cloned original.clone(); // 修改克隆对象的附件文件名 cloned.getAttachment().setFileName(需求文档-副本.pdf); System.out.println(original.getAttachment() cloned.getAttachment()); // true cloned.getAttachment().download(); // 输出下载附件需求文档-副本.pdf original.getAttachment().download(); // 输出下载附件需求文档-副本.pdf原对象被污染 } }判断为 true说明两个 Email 里的 attachment 指向同一份内存。改克隆体的文件名原对象看到的是同一个文件。这类问题在单测里很难暴露因为单测往往只断言克隆对象的字段值不去回头检查原对象。3.3 String 为什么不怕共享而 Date 必须小心有人会问sender、subject 也是引用类型为什么修改克隆对象的标题不会影响原对象答案藏在不可变性里。String 内部是一个 final 的字符数组任何「修改」操作比如setSubject(新标题)都是把字段重新指向一个新的 String 对象原对象里的那个引用纹丝不动。克隆体和原对象共享同一个 String 实例只是只读共享没有写入路径所以安全。包装类 Integer、Long 同理而且它们还有一处缓存-128 到 127 之间的值会复用同一批对象这也是为什么Integer a 127; Integer b 127; a b为 true而 128 就为 false。真正危险的是可变引用类型Date、ArrayList、HashMap、自定义的实体对象。判断标准很简单——这个字段指向的对象有没有会改变自身状态的方法。有浅克隆就会出问题没有全部字段 final、方法只读共享就是安全的。浅克隆的适用边界可以归成三条字段全是基本类型或不可变对象浅克隆够用对象本身是无状态的服务类或配置类浅克隆够用只要对象图里存在一个可变引用就必须往深克隆走。第三条没有例外别赌。4. 深克隆序列化为什么能把整张对象图复制一遍4.1 序列化的对象图遍历机制深克隆要求把引用链上的每一层对象都复制一份让克隆体和原对象不共享任何可变状态。手写递归 clone 是可行的但遇到循环引用A 引用 BB 又引用 A会直接栈溢出。Java 给出的标准解法是序列化。ObjectOutputStream.writeObject()从根对象出发做一次图遍历每遇到一个对象先检查它是否已经写过写过就只写一个句柄引用没写过才写完整的类描述和字段值。这个「已写对象表」在 JDK 里叫 HandleTable它同时解决了两件事——循环引用不会无限递归被多处共享的同一个对象在字节流里只会出现一次反序列化后仍然是共享的一份语义和原对象图保持一致。反序列化走的是另一条路ObjectInputStream.readObject()从字节流里重建对象先按类描述分配内存再填字段。关键在于这个过程不调用任何构造函数所以类不需要有无参构造字段初始化语句也不会执行。这一点经常被误解成「反序列化会走构造函数」实际不会这也是它能绕过某些校验逻辑的原因。4.2 内存管道的逐层拆解邮件类的深克隆版本需要把整条引用链上的类都标记为可序列化import java.io.Serializable; public class Attachment implements Serializable { private static final long serialVersionUID 1L; private String fileName; public Attachment(String fileName) { this.fileName fileName; } public void setFileName(String fileName) { this.fileName fileName; } public void download() { System.out.println(下载附件 fileName); } }import java.io.*; 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; 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; } public Attachment getAttachment() { return attachment; } public Email deepClone() throws IOException, ClassNotFoundException { // 第一段序列化把 this 出发的整张对象图写进内存字节数组 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 (Email) ois.readObject(); } }四个流对象各司其职。ByteArrayOutputStream是内存里的字节缓冲区默认 32 字节写满自动扩容不需要提前算大小。ObjectOutputStream负责把对象翻译成字节它内部维护 HandleTable。ByteArrayInputStream把上一步攒出来的字节数组包成输入流。ObjectInputStream读字节、建对象、填字段。serialVersionUID不能省。反序列化时会拿字节流里的版本号和当前类的版本号比对不一致就抛InvalidClassException。不显式声明的话编译器会根据类结构自动生成一个类里加个字段、改个修饰符这个值就变了历史数据全部读不回来。血泪经验是只要这个类有可能被序列化就把 serialVersionUID 写死。改完客户端再跑一次original.getAttachment() cloned.getAttachment()会变成 false两边互不影响。4.3 深克隆的代价与替代方案序列化方案的代码量小、能处理循环引用、不用逐层手写但它不是免费的。一次深克隆要做两次完整遍历加两次字节搬运对象图大的时候 CPU 和临时内存的消耗都不低。还有一个经常被忽略的场景如果对象本身很小、引用层级也很浅序列化的固定开销写类描述、建流对象反而比直接 new 一个还慢。原型模式「比 new 快」这个说法有前提前提是构造过程本身昂贵。transient修饰的字段不会被序列化深克隆后是默认值对象为 null基本类型为 0。像连接、锁、监听器这类不该被复制的字段用 transient 排除掉是对的但如果业务字段被误标克隆出来的对象就是残缺的而且不会报错。其他可选路径有三条。手写逐层 clone性能最好但要处理循环引用且每加一个字段都得记得同步修改。拷贝构造函数new Email(other)显式列出每个字段可读性好缺点是字段多了容易漏。JSON 序列化借助第三方库把对象转成 JSON 再转回来代价是类型信息丢失Date 变成字符串、泛型擦除、循环引用直接爆栈只适合结构简单的 DTO。选哪条取决于对象图的复杂度和调用频次。高频调用的热路径上手写 clone 更稳低频的管理类操作序列化方案的可维护性更好。5. 常见问题排查clone 用错时的五个典型翻车现场5.1 先分清是「没复制」还是「复制错层」排查克隆问题第一步不是看代码是看现象属于哪一类。改克隆体影响了原对象说明复制深度不够八成是浅克隆扛了深克隆的活。克隆体字段莫名丢失或为 null说明复制过程中有东西被跳过了往 transient 和自定义序列化逻辑上找。调用就抛异常说明能力开关没打开或者对象图里混进了不可序列化的类。这三类现象对应三条完全不同的排查路径混在一起查只会浪费时间。一个快速定位手段在克隆前后分别打印System.identityHashCode()它能给出对象的内存身份比默认的 toString 靠谱。对每个可疑字段打一次哪一层的哈希值相同问题就出在哪一层。5.2 五条踩坑记录第一条调用 clone() 直接抛 CloneNotSupportedException现象代码编译通过运行到obj.clone()就抛异常堆栈指向 Object.clone()。原因类没有实现Cloneable接口。注意是java.lang.Cloneable不是某个框架里的同名接口导错包的情况真实存在。解决在类声明上加implements Cloneable。如果这个类是继承来的、改不了源码就在子类里重写 clone 方法手动 new 一个再逐字段赋值绕开 Object.clone() 的检查。第二条改了克隆对象的附件原对象的附件跟着变现象克隆后修改引用类型字段的内部状态原对象读出来是新值。用比较两个字段返回 true。原因super.clone()只做字段级复制引用类型字段复制的是地址两个对象共享同一份数据。解决换成序列化深克隆或者在该字段上手动再 clone 一层。判断依据是「这个字段指向的对象可变吗」可变就必须复制不可变String、包装类、全 final 的类可以共享。第三条深克隆出来的对象某些字段是 null现象序列化深克隆成功返回但部分字段值为 null 或 0源对象上明明有值。原因字段被transient修饰。transient 的语义就是「不参与序列化」反序列化时自然拿不到值。解决确认这个字段是否真的不该被复制。该复制就去掉 transient确实不该复制比如数据库连接、线程池就在克隆后手动补上或者在该字段的 getter 里做懒初始化。第四条深克隆抛 NotSerializableException现象writeObject时抛异常堆栈里能看到具体类名。原因对象图里某个引用类型的类没有实现 Serializable。常见于第三方库对象、匿名内部类隐式持有外部类引用、集合里混进了不可序列化的元素。解决沿异常堆栈找到那个类要么让它实现 Serializable要么用 transient 排除并在克隆后重建。匿名内部类基本无解只能改成静态内部类或独立类。第五条单例类被 clone 出了第二个实例现象本该全局唯一的对象克隆之后出现了两个状态开始不一致。原因单例类如果实现了 Cloneable 且没防住 cloneObject.clone()会老老实实复制一份新对象绕过私有构造函数。解决单例类不要实现 Cloneable如果父类已经实现了就在单例类里重写 clone 直接抛CloneNotSupportedException或者返回INSTANCE本身。同理序列化也会绕过单例需要补readResolve()。6. 设计取舍原型、工厂、构造器各自的边界在哪6.1 三张牌的选择顺序原型模式最容易被误用的地方是把它当成 new 的通用替代品。它的真实定位是「当构造过程的成本高于复制成本时用复制换构造」。这句话反过来读就是构造很便宜的时候用它纯属自找麻烦。场景特征优先选择判断依据初始化要跑远程调用、复杂计算、多次查库原型复制已有状态比重新算一遍便宜创建逻辑有多套配置组合客户端不该关心细节工厂创建逻辑集中管理扩展时只加不改字段少、构造函数无副作用直接 new克隆的额外开销不划算可读性也更好需要保存对象的历史快照原型配合备忘录复制即快照回滚成本低对象图巨大且大部分数据共享享元或共享引用全量深克隆会让内存翻倍还有一条常被忽略的判断这个对象的状态变化频率。状态几乎不变、只是被大量复制的对象适合原型状态频繁变动、每次都要保存历史的对象深克隆的开销会随调用次数线性增长这时候该考虑的是状态模式或者事件溯源而不是继续在 clone 上做优化。6.2 一个可复用的验证习惯设计模式的价值不在代码长什么样在它逼你把边界想清楚。原型模式逼你想清楚的那件事是这个对象的哪些部分是「身份」哪些部分是「状态」。身份不该被复制状态必须被复制。分不清这两者clone 出来的对象就是个四不像。我后来形成一个习惯写 clone 之前先把类的字段列一遍在每个字段后面标三个词之一值、不可变引用、可变引用。标完这一列用浅克隆还是深克隆、哪些字段要 transient答案自己就浮出来了。这个过程花不了五分钟但能省掉后面几小时的排查。对照这份资料里的邮件复制案例浅克隆版和深克隆版的差别只有几行代码可行为差异是天壤之别。看懂这几行背后的对象图比记住「深克隆用序列化」这句结论重要得多。要是你手头正好有类似的复制场景不妨拿这份材料里的两版实现对着跑一遍把判断打印出来看一眼很多模糊的地方会一下子清晰。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?