ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java原型模式与深浅克隆:clone契约到序列化对象图复制

Java原型模式与深浅克隆:clone契约到序列化对象图复制

简介:这份面向软件工程学习者与设计模式入门者的原型模式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()做的事情,是把这块实例数据区整体复制到新分配的内存里。基本类型字段复制后各不相干;引用类型字段复制过去的是指针值,两个对象里存着同一个地址。结果就是:你得到了两个外壳,但它们内部的引用指向同一份数据。

用文字描述这个过程:原对象 Email@0x1000 的 attachment 字段值是 0x2000;克隆出的 Email@0x3000 的 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 引用 B,B 又引用 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 且没防住 clone,Object.clone()会老老实实复制一份新对象,绕过私有构造函数。

解决:单例类不要实现 Cloneable;如果父类已经实现了,就在单例类里重写 clone 直接抛CloneNotSupportedException,或者返回INSTANCE本身。同理,序列化也会绕过单例,需要补readResolve()。

6. 设计取舍:原型、工厂、构造器各自的边界在哪

6.1 三张牌的选择顺序

原型模式最容易被误用的地方,是把它当成 new 的通用替代品。它的真实定位是「当构造过程的成本高于复制成本时,用复制换构造」。这句话反过来读就是:构造很便宜的时候,用它纯属自找麻烦。

场景特征优先选择判断依据
初始化要跑远程调用、复杂计算、多次查库原型复制已有状态比重新算一遍便宜
创建逻辑有多套配置组合,客户端不该关心细节工厂创建逻辑集中管理,扩展时只加不改
字段少、构造函数无副作用直接 new克隆的额外开销不划算,可读性也更好
需要保存对象的历史快照原型配合备忘录复制即快照,回滚成本低
对象图巨大且大部分数据共享享元或共享引用全量深克隆会让内存翻倍

还有一条常被忽略的判断:这个对象的状态变化频率。状态几乎不变、只是被大量复制的对象,适合原型;状态频繁变动、每次都要保存历史的对象,深克隆的开销会随调用次数线性增长,这时候该考虑的是状态模式或者事件溯源,而不是继续在 clone 上做优化。

6.2 一个可复用的验证习惯

设计模式的价值不在代码长什么样,在它逼你把边界想清楚。原型模式逼你想清楚的那件事是:这个对象的哪些部分是「身份」,哪些部分是「状态」。身份不该被复制,状态必须被复制。分不清这两者,clone 出来的对象就是个四不像。

我后来形成一个习惯,写 clone 之前先把类的字段列一遍,在每个字段后面标三个词之一:值、不可变引用、可变引用。标完这一列,用浅克隆还是深克隆、哪些字段要 transient,答案自己就浮出来了。这个过程花不了五分钟,但能省掉后面几小时的排查。

对照这份资料里的邮件复制案例,浅克隆版和深克隆版的差别只有几行代码,可行为差异是天壤之别。看懂这几行背后的对象图,比记住「深克隆用序列化」这句结论重要得多。要是你手头正好有类似的复制场景,不妨拿这份材料里的两版实现对着跑一遍,把==判断打印出来看一眼,很多模糊的地方会一下子清晰。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进