☰
Java原型模式从原理到实战:Cloneable、浅拷贝与深拷贝全解析
2026/10/3 14:46:29 网站建设 项目流程

先说一个我自己踩过的坑。前两年做一个内部报表系统,有个规则配置对象,每次构建都要查七张表,再做一轮折扣和权限计算,单次构建接近一秒钟。并发一上来,每个请求都 new 一次,接口直接被打垮。当时第一反应是加缓存,但缓存放的是共享对象,A 请求改了一个属性,B 请求就会看到被改坏的数据。

后来我才想明白,这其实就是一个典型的原型模式场景:程序里已经有一个构建好的模板对象,我们真正需要的是它的副本,而不是把整个对象重新构建一遍,也不是把原对象直接拿出去共享。这篇文章不打算把 GoF 的定义再抄一遍,而是从 Java 的Cloneable、Object.clone()、浅拷贝和深拷贝开始,把原型模式在 Java 里落地时会遇到的 final 字段、循环引用、继承链问题一个个讲清楚。你可以把它当成实战笔记,也可以直接作为 Java 面试前突击原型模式的复习材料。

1. 原型模式想解决什么问题:从“new 一个对象代价太大”说起

1.1 创建对象的成本并不只在 new 那一瞬间

很多人对创建对象有个误解,觉得new无非就是分配一块内存,再执行一下构造器。放在简单 POJO 上确实如此,但在真实业务系统里,一个对象的构造链可能长到离谱。

比如你要构建一个RuleConfig,构造函数里可能需要:

  • 根据租户 ID 查询数据库里的费率配置;
  • 从 Redis 拉取黑名单和灰度开关;
  • 调用一个 RPC 服务同步最新的额度策略;
  • 对一堆规则做合法性校验和排序。

这些操作如果都放在构造函数或初始化阶段,那每次new都不只是内存开销,而是把数据库、缓存、远程服务全部重新打一遍。这种对象一旦构建完,后续大量请求如果还是要“从零开始”创建,性能自然扛不住。可如果直接把同一个实例共享出去,又会遇到我开头说的那种问题——有人改字段,其他人全被影响。

1.2 复制一个已有实例,比重新构建一次便宜得多

原型模式的思路非常朴素:既然source对象已经处于一个正确的、完整的初始状态,那就让新对象从它身上“复制”出来,而不是把构造过程重跑一遍。复制完成之后,再按需修改几个差异字段,比如把requestId、tenantId换成当前请求的值。

所以我在实际项目里使用原型模式的判断标准就两条:

  1. 对象的创建过程涉及 IO、远程调用或复杂计算,重复创建代价高;
  2. 使用方需要独立修改复制出来的对象,不能和原对象共享可变状态。

这两条同时满足,就该考虑原型模式。如果只是创建一个简单对象,字段不超过三五个,那老老实实new或者用 Builder 反而更清晰。设计模式是为解决具体问题服务的,不是往代码里堆概念。

从实现角度看,Java 给了我们一条原生路径:Cloneable接口加Object.clone()方法。但真正上手之后你会发现,这套机制用起来并没有那么顺滑,里面藏着不少细节。

2. Cloneable 与 Object.clone():Java 原生克隆机制的底层逻辑

2.1 为什么 Object.clone() 是 protected native

Object.clone()是一个protected native方法。native意味着它不是用 Java 实现的,而是由 JVM 底层按对象的内存布局做复制。这带来一个非常重要的特性:clone()不会调用构造函数。

这句话值得划重点。原型模式的核心诉求之一,就是“不要重新执行构造逻辑”,而Object.clone()天然满足这个要求。你看new一定会走构造函数,无论构造函数内部是打印日志还是查数据库,都会执行一遍。但super.clone()是从 JVM 层面直接分配一块内存,把原对象的实例字段值复制过去。这样即使构造函数里面有再重的逻辑,也不会被重新触发。

那为什么是protected?因为 JDK 设计者认为,“怎么复制一个对象”只有这个类自己最清楚。如果直接让所有外部代码随便调用clone(),那复制出来的字段可能不完整,也没人能约束。protected保证只能在本类、同包或子类里访问。所以实际开发中,几乎所有实现类都会把clone()重写并声明为public,让外部调用成为可能。

2.2 被覆盖的 clone 为什么要先调用 super.clone()

既然Object.clone()是 JVM 底层的字段复制,那我们在子类里重写clone()时,第一步通常就是调用super.clone()。这个调用会完成“最基础的那层复制”,之后你才有机会对引用类型的字段做额外处理。

public class Order implements Cloneable { private Long id; private List<OrderItem> items; @Override public Order clone() { try { Order copy = (Order) super.clone(); copy.items = new ArrayList<>(this.items); return copy; } catch (CloneNotSupportedException e) { throw new IllegalStateException(e); } } }

super.clone()返回的对象,本质上已经是一个新的Order实例,所有字段的值都和原对象一致。问题在于 List 这种引用类型,super.clone()只是把items这个引用复制过去了,复制出来的两个对象还是指向同一个 List 实例。所以我紧接着用new ArrayList<>(this.items)换了一个新的 List 容器。这一步就是“浅拷贝”和“深拷贝”的分界线。

还有一点要注意:clone()声明会抛出CloneNotSupportedException,这是一个受检异常。很多人不理解为什么复制个对象还要 try/catch,其实就是因为Object.clone()运行期会检查类是否实现了Cloneable。没实现就抛异常,编译器只能强制要求你处理这个可能性。

2.3 Cloneable 是个空接口,却能在运行期决定对象生死

Cloneable接口里面没有任何方法,属于典型的标记接口。但它不是完全没意义,它更像一张“通行证”。JDK 底层的clone()实现执行时会检查:当前对象是否实现了Cloneable,如果没实现,直接抛CloneNotSupportedException。

如果你在一个类上写了implements Cloneable,但完全没重写clone(),外部代码也没法直接调用,因为继承自Object的那个clone()还是protected。很多 Java 新手在这里迷路:接口标记了,方法却不可见。所以正确做法是:

public class RuleTemplate implements Cloneable { @Override public RuleTemplate clone() { try { return (RuleTemplate) super.clone(); } catch (CloneNotSupportedException e) { throw new IllegalStateException(e); } } }

这也是为什么很多人说 Java 原生克隆机制“难用”:空接口没有契约,受检异常很烦,还要自己重写可见性。但它确实是最接近“原型模式”原生语义的实现方式。理解了这几条,后面再看浅拷贝和深拷贝就顺了。

3. 浅拷贝与深拷贝:对象复制绕不开的分水岭

3.1 浅拷贝到底复制了什么:先看一段最直观的代码

浅拷贝和深拷贝,是原型模式里最常被问的问题,也是实际项目中出 bug 最多的地方。我给你写一个最小例子:

public class Address implements Cloneable { private String city; public Address(String city) { this.city = city; } public String getCity() { return city; } public void setCity(String city) { this.city = city; } @Override public Address clone() { try { return (Address) super.clone(); } catch (CloneNotSupportedException e) { throw new IllegalStateException(e); } } } public class Person implements Cloneable { private String name; private Address address; public Person(String name, Address address) { this.name = name; this.address = address; } @Override public Person clone() { try { return (Person) super.clone(); } catch (CloneNotSupportedException e) { throw new IllegalStateException(e); } } }

这个Person.clone()是典型的浅拷贝。如果用下面的方式测试:

Address addr = new Address("北京"); Person p1 = new Person("张三", addr); Person p2 = p1.clone(); System.out.println(p1.address == p2.address); // 输出 true

p1.address == p2.address结果是true,说明两份 Person 的 address 字段指向了同一个对象。此时执行p2.getAddress().setCity("上海"),p1的地址也会变成上海。共享可变状态导致的数据串扰,就是这么来的。

3.2 数组和 List 的浅拷贝尤其容易误判

ArrayList的构造器复制、Arrays.copyOf、System.arraycopy这些方法,很多人以为是深拷贝,其实只是做到了“新容器、旧元素”。比如说:

List<RuleItem> source = new ArrayList<>(); source.add(new RuleItem("A")); List<RuleItem> target = new ArrayList<>(source); target.get(0).setName("B");

source.get(0)的 name 也会变成 B,因为容器虽然换了一个,容器里的元素引用还是同一个RuleItem对象。数组也是一样,Arrays.copyOf对基本类型数组是值复制,对对象数组只复制引用。

所以在原型模式的深拷贝里,一个常见误区是只 new 了 List,但 List 里的对象节点还是共享的。如果元素是不可变对象,比如 String、Integer、枚举,那共享没问题。只要元素里有可变字段,就得逐个元素去克隆。

3.3 什么时候用浅拷贝就够了:不可变字段与只读场景

我知道很多人一听深拷贝就觉得必须深,但工程上不是所有场景都需要。下面这几种情况,浅拷贝完全可以接受:

  • 字段类型是 String、基本类型包装类、枚举、BigDecimal这类不可变对象;
  • 被复制的对象整体是只读的,不修改任何嵌套对象;
  • 嵌套对象是全局缓存,本来就不允许调用方修改,且没有 setter 暴露。

看清楚,浅拷贝节省了性能,但前提是不能有人偷偷改共享对象。如果代码里到处都是 getter 返回内部 List 的写法,那浅拷贝迟早出事。一般我的经验是:涉及业务字段、需要被后续填充和修改的 DTO,尽量深拷贝;纯配置、纯元数据的只读对象,浅拷贝够了。

4. 深拷贝的正确姿势:手写 clone、拷贝构造器、序列化、JSON

4.1 手动重写 clone:最可控但最繁琐

最正统的深拷贝方式,就是在上面的clone()里对每一个可变引用字段都做一层复制。以Person为例:

@Override public Person clone() { try { Person copy = (Person) super.clone(); if (this.address != null) { copy.address = this.address.clone(); } return copy; } catch (CloneNotSupportedException e) { throw new IllegalStateException(e); } }

如果 Person 里还有 List,需要把元素也拷贝:

copy.contacts = new ArrayList<>(this.contacts.size()); for (Contact contact : this.contacts) { copy.contacts.add(contact.clone()); }

这种写法的优点是完全由你控制复制逻辑,性能也最好,因为不涉及 IO,也没有反射。缺点同样明显:对象层级一深,代码量成倍增长,而且每新增一个引用字段,就要记得在 clone 里补一行,漏掉就是线上事故。我见过太多项目里 clone 方法只复制了表层,新增字段后没人维护,最后数组越界或者数据被串改,排查得非常痛苦。

4.2 拷贝构造器:不经 Cloneable 也能实现原型复制

如果你觉得Cloneable这套机制太重,拷贝构造器是一个更符合 Java 习惯的替代方案。思路很简单:提供一个以同类型对象为参数的构造器,在构造器里手动复制字段。

public class Order { private Long id; private List<OrderItem> items; public Order(Order source) { this.id = source.id; this.items = source.items.stream() .map(OrderItem::new) .collect(Collectors.toCollection(ArrayList::new)); } }

拷贝构造器和原型模式并不冲突。你同样可以准备一个模板对象,然后用new Order(template)来“复制”新订单。它解决了final字段的问题,因为构造器里可以重新给 final 字段赋值,这一点比 clone 灵活很多。但它要求每个被拷贝的类都额外提供一个复制构造器,本质上也是在写手工复制逻辑。

4.3 序列化深拷贝:一行方案背后的限制

利用 JDK 序列化做深拷贝,是很多面试题里的标准答案之一。核心逻辑是把对象写入字节流,再读回来,得到一个全新对象。

@SuppressWarnings("unchecked") public static <T> T deepCopy(T source) { try (ByteArrayOutputStream bos = new ByteArrayOutputStream(); ObjectOutputStream oos = new ObjectOutputStream(bos)) { oos.writeObject(source); try (ObjectInputStream ois = new ObjectInputStream( new ByteArrayInputStream(bos.toByteArray()))) { return (T) ois.readObject(); } } catch (IOException | ClassNotFoundException e) { throw new IllegalStateException(e); } }

这种方案对“对象图”的处理非常省心,循环引用也能被序列化机制记录下来,不会无限递归。但限制也足够多:

  • 对象及其所有字段必须实现Serializable;
  • static和transient字段不会被序列化;
  • 如果类里有readObject、writeReplace、readResolve这类钩子方法,复制结果可能不是你期望的“全新普通对象”;
  • 性能相对较差,因为要完成完整的序列化和反序列化过程。

所以我的结论是:JDK 序列化深拷贝更适合临时脚本、测试场景,或者对象层级特别深且已经全部实现了Serializable的内部系统。用它去框架层拷贝 Spring Bean 基本不可行。

4.4 JSON 序列化复制:DTO 层的最实用深拷贝

现在很多服务端项目会使用 Jackson 或 Gson 做对象转换。既然对象能序列化成 JSON,自然也就能通过“先序列化再反序列化”完成深拷贝。

ObjectMapper mapper = new ObjectMapper(); Order copy = mapper.readValue(mapper.writeValueAsString(source), Order.class);

这个方案的优点是不要求类实现Serializable,只要字段能被 Jackson/Gson 正常识别即可,和日常 JSON 序列化共用一套配置。在 DTO、VO 这类数据结构简单、嵌套层次可控的对象上,它比手写 clone 省事得多,也比 JDK 序列化更贴近现代技术栈。

但它也有明显的坑:

  • 循环引用默认处理不了,Jackson 会抛出无限递归异常;
  • 多态类型需要额外配置多态信息,否则反序列化后可能变成父类对象;
  • 依赖对象的 getter、setter 或字段可见性,如果有自定义序列化器,复制后的结果可能和原对象不一致。

我个人的习惯是:内部配置类、领域模型使用手动深拷贝或拷贝构造器;对外 API 的 DTO 用 Jackson 深拷贝;只有已经全部Serializable的旧系统才考虑 JDK 序列化。

4.5 四种深拷贝方案对比

方案实现成本是否调用构造函数final 字段处理循环引用性能
手动重写 clone高,字段多时容易漏不调用复制后难以修改需要自己处理最好
拷贝构造器中,每个类都要写调用可以重新赋值需要自己处理好
JDK 序列化低,一行通用方法不调用受限于序列化天然支持较差
JSON 序列化低,一行调用通常不调用大部分受限不支持中等

看到这个对比就明白了,没有一种方案是万能银弹。选型要看对象图的复杂度、类是否可控、对性能的要求,以及会不会有循环引用。前两项往往是决定因素。

5. 原型模式在真实项目中的落地形态

5.1 原型注册表:把配置模板变成“复制工厂”

原型模式在代码里最典型的落地形态,是配一个注册表。注册表里预先存放各种类型的模板对象,调用方传入模板标识,注册表返回一个克隆副本。

public class ReportTemplateRegistry { private final Map<String, ReportConfig> templates = new ConcurrentHashMap<>(); public ReportConfig getTemplate(String templateId) { ReportConfig source = templates.computeIfAbsent( templateId, this::loadFromDatabase); return source.clone(); } } private ReportConfig loadFromDatabase(String templateId) { // 这里可能要查库、查缓存、做一堆校验,总之很贵 }

这样做的价值在于,loadFromDatabase只会在模板第一次被请求时执行,后续所有请求拿到的都是副本。每个调用方拿到的都是独立对象,改自己的副本不会污染注册表里缓存的模板。这正好同时解决了“构建太贵”和“共享对象被修改”两个问题。

注意ConcurrentHashMap.computeIfAbsent在多线程下的进度保证比较好,但如果你在加载模板的 lambda 里有非常耗时的操作,仍要考虑是否会被多个线程并发触发重复加载。更严格的实现是先加锁,再检查、再加载。不过中小型系统里computeIfAbsent已经够用了。

5.2 缓存对象返回副本,避免共享状态被污染

我在实际项目里见得比较多的一种错误写法,是直接把缓存中的对象 return 给调用方:

public class ConfigService { private ReportConfig configCache; public ReportConfig getConfig() { return configCache; // 错误:调用方可以随便改这个缓存 } }

如果返回的是同一个对象,调用方只要拿到引用,就能通过 setter 改掉全局状态。表面上是“读配置”,实际上变成“写配置”。改成原型模式的做法之后,问题就消失了:

public ReportConfig getConfig() { return configCache.clone(); }

每次返回一个新副本,调用方怎么改都不会影响缓存。这个改造非常简单,但收益极大。尤其是在多线程环境里,共享可变状态导致的偶发问题非常难查,与其用锁保护每一次读取,不如直接返回副本,从根源上隔离。

5.3 原型模式和工厂模式组合使用的典型写法

有经验的面试官通常会追问一个点:原型模式和工厂模式有什么区别?其实二者不是对立关系。工厂模式侧重“如何创建一个对象”,原型模式侧重“如何基于已有对象复制新对象”。在实际代码里,二者经常组合出现。

public class RuleService { private final ReportTemplateRegistry registry; public ReportConfig createRuleForBiz(String templateId, String bizCode) { ReportConfig config = registry.getTemplate(templateId); config.setBizCode(bizCode); return config; } }

这里registry.getTemplate()内部用了原型克隆,而整个RuleService对外看起来像一个工厂。你不能说这是纯工厂模式还是纯原型模式,它是组合。面试时如果能说出这种组合用法,比单纯背定义要加分得多。

6. 原型模式必须避开的坑与面试高频问法

6.1 final 字段、单例对象与不可变类的克隆禁忌

clone()不调用构造函数,这既是优点也是麻烦。麻烦主要体现在 final 字段上。如果你有一个 final 字段,super.clone()会把这个字段的当前引用原样复制过去。你没法在 clone 方法里通过 setter 给它赋新值,因为 final 字段不允许再赋值。如果这个 final 字段恰好是可变对象,两个对象还是会共享它。

所以我的建议是:

  • 有多个可变 final 字段的类,尽量用拷贝构造器实现原型复制,而不是硬套 Cloneable;
  • 如果类是不可变类,字段全是 final 且不可变,那压根不需要做深拷贝,直接共享原对象就行;
  • 单例类一定不要提供可用的 clone 方法。单例的本质是全局唯一实例,clone 会打破这个约束。如果真被要求实现 Cloneable,覆盖clone()直接抛UnsupportedOperationException()反而更安全。

6.2 循环引用:手动递归深拷贝最大的敌人

有些业务对象会双向引用,比如父子节点互相持有对方。手动写递归深拷贝时,遇到这种结构会变成无限递归,最后栈溢出。你当然可以在每次进入时判断“这个对象是否已经复制过”,但自己维护一个IdentityHashMap作为复制记录,代码复杂度一下就上去了。

public Person cloneGraph(Person source, IdentityHashMap<Person, Person> done) { if (done.containsKey(source)) { return done.get(source); } Person copy = new Person(source); done.put(source, copy); copy.friend = cloneGraph(source.friend, done); return copy; }

这是可行的思路,但只在对象图特别复杂时才值得写。日常业务中如果碰上循环引用,我更建议先重构对象结构,让父子关系变成单方向的 id 引用,或者直接用 JDK 序列化方案,让序列化机制去处理这些重复引用。

6.3 继承链上的 clone:协变返回类型与子类漏实现

原型模式的坑在继承关系里会被放大。假设父类实现了clone(),子类没有重写,那么子类的clone()调用会走父类逻辑,返回父类类型,并且子类新增的引用字段不会被深拷贝。由于 Java 支持协变返回类型,你可以在子类里这样覆盖:

@Override public Child clone() { return (Child) super.clone(); }

但如果你只写了这一句,没有对 Child 自身的新字段做深拷贝,那其实和没实现差不多,甚至是更隐蔽的浅拷贝。所以我处理继承结构的经验是:要么在基类里把clone()设为final,禁止子类继续重写,避免意外;要么要求每个子类重写clone()并各自处理自己的字段。最怕的是“有的子类实现了,有的没实现”,这种最容易在运行时翻车。

6.4 面试高频追问:从原理到实战一次理清

结合相关的 Java 面试题和八股文关键词,我整理了一份原型模式高频问题清单,供你复习用。

  • 原型模式解决什么问题?
    答:通过复制已有原型实例来创建新对象,避免重复执行昂贵的构建过程,同时可以用副本隔离可变状态。

  • 什么场景优先考虑原型模式?
    答:对象构造涉及 DB、RPC、复杂计算;或者需要频繁创建初始状态相同的对象;或者缓存对象需要以副本形式返回给调用方。

  • Cloneable 为什么是空接口?
    答:它只是 JDK 底层 clone 机制的一个运行期标记,JVM 在执行Object.clone()时会检查对象是否实现了该接口,没实现就抛异常。

  • Object.clone() 为什么不直接 public?
    答:JDK 希望每个类自己决定如何暴露和控制复制逻辑,所以设置成 protected,由子类按需重写为 public。

  • 浅拷贝和深拷贝的区别?
    答:浅拷贝复制引用字段的引用,深拷贝连带引用对象本身一起复制。关键是看复制后的对象修改嵌套属性时会不会影响原对象。

  • 原型模式和工厂模式有什么区别?
    答:工厂模式重点是封装创建逻辑,原型模式重点是基于已有实例复制;两者可以组合,原型注册表就是一个带工厂语义的实现。

  • 生产环境你会怎么实现深拷贝?
    答:DTO 层用 JSON 序列化,领域模型用手动 clone 或拷贝构造器,数据已经 Serializable 的旧系统考虑 JDK 序列化,同时注意循环引用和 final 字段。

说实话,原型模式在 Java 里并不是每个项目都会用到,但只要遇到“构造太贵”或者“缓存模板必须隔离修改”这两类问题,它就是最朴素也最直接的解法。我现在写代码的默认做法是:能不用 Cloneable 就不用,简单对象用拷贝构造器,DTO 深拷贝直接交给 Jackson;只有在“已有模板对象需要反复复制”这种特定场景中,才会把 clone 方法和原型注册表一起端出来。模式本身不难,难的是搞清楚什么时候该用它,以及复制完之后的那些对象到底是不是真的彼此独立。这一点想透了,原型模式才算真正学会。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询