ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Java原型模式从原理到实战:Cloneable、浅拷贝与深拷贝全解析

Java原型模式从原理到实战:Cloneable、浅拷贝与深拷贝全解析 先说一个我自己踩过的坑。前两年做一个内部报表系统有个规则配置对象每次构建都要查七张表再做一轮折扣和权限计算单次构建接近一秒钟。并发一上来每个请求都 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换成当前请求的值。所以我在实际项目里使用原型模式的判断标准就两条对象的创建过程涉及 IO、远程调用或复杂计算重复创建代价高使用方需要独立修改复制出来的对象不能和原对象共享可变状态。这两条同时满足就该考虑原型模式。如果只是创建一个简单对象字段不超过三五个那老老实实new或者用 Builder 反而更清晰。设计模式是为解决具体问题服务的不是往代码里堆概念。从实现角度看Java 给了我们一条原生路径Cloneable接口加Object.clone()方法。但真正上手之后你会发现这套机制用起来并没有那么顺滑里面藏着不少细节。2. Cloneable 与 Object.clone()Java 原生克隆机制的底层逻辑2.1 为什么 Object.clone() 是 protected nativeObject.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 ListOrderItem 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); // 输出 truep1.address p2.address结果是true说明两份 Person 的 address 字段指向了同一个对象。此时执行p2.getAddress().setCity(上海)p1的地址也会变成上海。共享可变状态导致的数据串扰就是这么来的。3.2 数组和 List 的浅拷贝尤其容易误判ArrayList的构造器复制、Arrays.copyOf、System.arraycopy这些方法很多人以为是深拷贝其实只是做到了“新容器、旧元素”。比如说ListRuleItem source new ArrayList(); source.add(new RuleItem(A)); ListRuleItem 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、拷贝构造器、序列化、JSON4.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 ListOrderItem 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); } }这种方案对“对象图”的处理非常省心循环引用也能被序列化机制记录下来不会无限递归。但限制也足够多对象及其所有字段必须实现Serializablestatic和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 MapString, 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, IdentityHashMapPerson, 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 方法和原型注册表一起端出来。模式本身不难难的是搞清楚什么时候该用它以及复制完之后的那些对象到底是不是真的彼此独立。这一点想透了原型模式才算真正学会。
返回列表