ARTICLE DETAIL

资讯详情

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

Java常见API与对象克隆:从浅拷贝到深拷贝的底层原理与实战

Java常见API与对象克隆:从浅拷贝到深拷贝的底层原理与实战 1. 从一道让我翻车的面试题聊起API和对象克隆到底在考什么先讲个真实经历。几年前我面一个自称Java基础扎实的候选人聊到集合和对象拷贝时我问ArrayList里存的都是对象引用那list.get(0)拿到的对象和添加到list前的那个对象是同一个对象吗他愣了两秒说应该是同一个吧但如果是的话为什么阿里开发手册里说不要用clone呢这个回答其实已经踩到了今天这个主题的核心——很多人在学API的时候只记住了怎么调用没搞懂底层是什么、边界在哪、什么时候不能用。day18这个阶段的API学习恰恰就是把这些边界问题掰开揉碎的过程。你可以把API理解成JDK给我们准备好的现成零件库——String是处理文本的零件包装类是把基本类型装箱成对象的零件Arrays是操作数组的零件。而对象克隆则是利用Object这个根类提供的clone机制在内存里复制出一个长得一样但独立存在的新对象。这两块内容放在同一天讲是因为它们有一个共同的底层逻辑搞清楚对象在内存里是怎么分布的引用和实例到底是什么关系。搞清楚了这个API用得明白克隆也不会踩浅拷贝的坑。这篇博文适合三类人看正在按部就班学Java基础、正好学到常见API和对象克隆这一节的初学者已经工作一两年、写了不少业务代码但没深究过clone机制的开发准备面试、想系统梳理这块知识点的求职者。我会把API里最高频的几个类讲透再把克隆从原理到代码、从浅拷贝到深拷贝完整走一遍最后聊聊真实项目里到底怎么选方案。2. 常见API里那些看着会用、实则没懂的高频点2.1 String的不可变性为什么说拼接字符串要小心初学者刚接触String时最容易产生一个误解String就是可以随便改的字符串。实际上String在Java里是不可变对象一旦创建它内部的字符数组就被final修饰不能再被修改。你执行str str abc表面上str变了其实是JVM在堆里新建了一个String对象把拼接结果放进去然后让str这个引用指向了新对象原来的对象就变成了垃圾等待回收。这个机制带来的性能问题很直观。如果在循环里做字符串拼接比如String result ; for (int i 0; i 10000; i) { result result i; }每次循环都会创建一个新的String对象再加上中间产生的临时对象10000次循环就会有上万次对象创建。我在本地跑过这个例子10万次拼接耗时超过1500毫秒而用StringBuilder基本在10毫秒以内差距是数量级的。所以在频繁拼接的场景里正确做法是用StringBuilder的append方法它内部维护一个可变字符数组扩容时自动拷贝不需要反复创建新对象。但这里也要说句公道话Java编译器其实会对拼接做一些优化比如String a hello world在编译期就能确定结果JVM直接把它折叠成helloworld。可一旦拼接操作发生在循环或方法参数这种运行时环境编译器就无法优化了就会退化成创建StringBuilder再toString。我见过不少新人拿编译器会优化来反驳但在循环里用拼字符串该慢还是慢别抱侥幸心理。2.2 包装类的缓存池自动装箱的隐藏陷阱另一个高频API是包装类比如Integer、Long、Boolean。Java 5之后有了自动装箱Integer a 127其实等价于Integer a Integer.valueOf(127)。valueOf这个方法里有个缓存机制默认情况下-128到127之间的Integer对象会被缓存在一个数组里每次valueOf返回的都是同一个对象。来看这个经典案例Integer a 127; Integer b 127; System.out.println(a b); // true Integer c 128; Integer d 128; System.out.println(c d); // false同样的赋值方式一个true一个false原因就在于128超出了缓存范围每次valueOf都会新建对象所以a和b指向同一对象而c和d指向两个不同的对象。这在真实开发中踩坑太常见了。我记得有个同事调外部接口把返回的Integer字段直接用比较平时数据小看不出问题某天线上订单号超过127后一堆对不上排查了半天才发现是包装类比较的问题。正确的比较方式永远是equals只能用来判断基本类型。另外如果要用到包装类的场景比较多可以考虑用int或long这些基本类型能省去装箱拆箱的开销。我这里说的开销不只是性能更重要的是它模糊了值和对象的概念边界而这个概念边界恰好是理解对象克隆的基础。2.3 Arrays和System.arraycopy数组操作的标准动作Arrays工具类算是API里的瑞士军刀——排序用Arrays.sort二分查找用Arrays.binarySearch填充用Arrays.fill把数组转成字符串调试用Arrays.toString。不过有一个点经常被忽略Arrays.asList()返回的List底层还是数组长度固定不能执行add和remove否则会抛UnsupportedOperationException。我见过有人在拿到asList返回值后直接add结果线上报错排查半天才发现是这个问题。数组拷贝这块System.arraycopy是native方法效率很高。比如int[] src {1, 2, 3, 4, 5}; int[] dest new int[5]; System.arraycopy(src, 0, dest, 0, src.length);而Arrays.copyOf内部其实就是调用了System.arraycopy只是它可以根据需求返回一个新数组。注意一点无论是System.arraycopy还是Arrays.copyOf对于引用类型数组它拷贝的是引用不是对象本身。也就是说两个数组里的对象还是同一个。这个特性到后面讲对象克隆时会非常关键——数组的clone同样是浅拷贝很多人在这里栽过跟头。String、包装类、Arrays这三个是day18里最典型的常见API代表。它们的共同点是表面用法都不难但真正决定代码质量的是你对底层内存模型和边界条件的理解深度。3. 对象克隆的本质浅拷贝和深拷贝到底差在哪3.1 先搞清楚为什么需要克隆写业务代码时我们经常需要复制一个对象。最早学编程的时候我们可能会直接写出User u2 u1;然后发现改u2的属性u1也变了。原因很简单这个赋值只是把引用复制了一份两个变量指向堆里的同一个对象根本不叫克隆。真正的克隆是创建一块新内存把原对象的所有属性值都复制过去让两个引用指向两个独立的对象。那什么时候真的需要克隆我举三个实际场景一是对象图快照你要把当前状态存下来做后续对比如果不克隆后面的修改会污染历史数据二是方法传参你不想让调用方影响内部状态就传一个副本进去三是缓存重建从缓存里取对象后要改一部分字段再返回给不同用户如果直接改缓存里的对象第二个用户就会拿到被改过的数据。3.2 浅拷贝的看起来像与深拷贝的真正独立Java里实现克隆的标准路径是让类实现Cloneable接口然后重写clone方法调用super.clone()。但这里有个非常关键的坑Object的clone方法默认是浅拷贝——它对基本类型字段会复制值对引用类型字段只复制引用不会复制引用指向的对象本身。看这段代码public class Teacher implements Cloneable { private String name; private Student student; // 引用类型字段 Override protected Object clone() throws CloneNotSupportedException { return super.clone(); } }执行Teacher t2 (Teacher) t1.clone()之后t2的name字段是独立拷贝没问题但t2.student和t1.student指向的是同一个Student对象。你改t2.student.namet1里的student.name也会变。这就是浅拷贝。那深拷贝是什么就是要把Student对象也复制一份让t2.student指向一个新的、与t1.student内容相同的对象。如果Student里面还有别的引用类型深拷贝就要递归地把整个对象图都复制一遍直到所有字段都被复制成独立副本。判断一个拷贝是深是浅最简单的方法就是拷贝完之后修改新对象的某个引用字段看原对象是否受影响。如果变了就是浅拷贝没变就是深拷贝。3.3 从内存模型角度理解引用很多人到这一步还是犯迷糊我想用一个生活类比讲透。引用变量就像你手上的门牌号或者快递单号对象本身才是那个包裹库房里的实体。User u2 u1相当于又抄了一张一模一样的门牌号但门后面还是同一个房间当然会互相影响。浅拷贝相当于找工匠给你建了个新房间但房间里的家具没搬只是写了一张家具在隔壁原来那个房间的纸条放进去——新房间能看能住但是动家具原房间也会跟着乱。只有深拷贝才会把家具一件件搬运到新房间两个房间从此互不干扰。这个类比放在代码里的意义是你在设计克隆方案时先画一遍对象图看看里面的引用类型字段有多少层。一层都没有全是基本类型或String浅拷贝就够了有一层或多层你得立刻决定要不要做深拷贝。4. Cloneable接口的正确姿势从protected到public的重写细节4.1 为什么Object.clone偏偏是protected每次讲到这里总有学生问为什么要实现Cloneable接口clone方法才能用这里包含两个机制。第一Cloneable是一个标记接口里面没有任何抽象方法它的作用就是告诉JVM这个类的对象允许调用clone方法。如果你没实现Cloneable就调用cloneJVM会抛CloneNotSupportedException。第二Object.clone是protected方法意味着在Object的子类里可以访问到它但外部代码在不认识这个类的时候不能随便调用。所以要让外部也能克隆就必须在类里重写clone方法并且把权限从protected改成public。这两个设计其实都在传递同一个信号克隆不是默认给你的能力而是类作者显式声明允许克隆之后才开放的能力。这和现实的道理一样不是所有东西都该被随意复制对象也是。4.2 一个标准的clone重写模板当类只有基本类型字段和不可变对象比如String时正确写法是这样的public class Person implements Cloneable { private String name; private int age; Override public Person clone() { try { return (Person) super.clone(); } catch (CloneNotSupportedException e) { // 因为已经实现了Cloneable理论上不会走到这里 throw new AssertionError(e); } } }注意几个细节返回值类型可以写成协变返回类型Person这样调用处不用强转权限改成publiccatch住CloneNotSupportedException后我习惯转成RuntimeException抛出而不是把它继续向上抛因为既然已经implements Cloneable这个检查型异常就永远不可能被触发继续抛只会污染调用方的代码。4.3 五个我亲眼见过无数次的坑第一个坑实现了Cloneable但重写clone时不调用super.clone而是自己去new一个对象再手动赋值。这样做不是不行但违背了clone方法的语义而且容易漏字段。正确做法一定是调用super.clone让JVM帮你完成最基础的内存拷贝。第二个坑数组的clone。很多人以为array.clone()会深拷贝实际上它同样是浅拷贝。对于int[]这种基本类型数组clone得到的是独立数组没问题但对于对象数组比如Person[]clone之后数组本身是新的但数组里的Person对象还是同一个。要深拷贝对象数组必须遍历数组逐个克隆元素。第三个坑final字段。super.clone是内存层面的拷贝它能处理final字段吗答案是分情况。final基本类型字段没问题final引用类型字段复制引用也没问题但如果你在clone里要给一个final引用字段赋一个新的对象编译都过不了。所以如果一个类里有需要深拷贝的final引用字段要么把它改成非final要么别指望用clone做深拷贝。第四个坑不允许子类克隆。如果一个父类实现了Cloneable并重写了clone但某个子类没有重写clone那么子类依然可以调用clone方法因为clone是继承的。如果这个子类里新增了一个需要特殊处理的字段而它又没重写clone就会悄悄产生浅拷贝。这道隐患很难发现我的建议是凡是涉及克隆的类继承链每一层子类都要检查自己的字段是否适合浅拷贝。第五个坑对象克隆和单例模式的冲突。如果一个单例类不小心实现了Cloneable别人克隆一下就出现两个单例整个系统的单例约束瞬间失效。真实项目里单例类要么别实现Cloneable要么重写clone抛出异常。5. 深拷贝的两种可靠实现手工逐个拷贝与序列化方案5.1 方案一手工重写clone逐个字段重建对象当对象结构清楚、嵌套层数不多时我倾向于手工实现深拷贝。这是最可控的方式每一层拷贝逻辑都写在你眼前出了bug一看代码就知道。看一个包含引用类型的完整例子public class Student implements Cloneable { private String name; private int score; Override public Student clone() throws CloneNotSupportedException { Student stu (Student) super.clone(); // 如果Student有引用类型字段在这里继续拷贝 return stu; } } public class Teacher implements Cloneable { private String name; private Student student; Override public Teacher clone() throws CloneNotSupportedException { Teacher teacher (Teacher) super.clone(); teacher.student student.clone(); // 关键把student也克隆一份 return teacher; } }这段代码的核心就在那行teacher.student student.clone()。没有这一行Teacher的克隆就是浅拷贝有了这一行Student也被复制了一份两个Teacher各自的student字段指向不同对象。如果Student里面还有引用类型字段就需要继续在Student.clone里递归处理。手工方案的好处是逻辑透明、性能好因为你精确知道哪些字段要复制。代价是要写很多样板代码而且类结构一旦变化比如新增一个引用类型字段clone方法就得同步修改很容易漏。所以我的使用场景是对象结构稳定、字段少、性能敏感或拷贝逻辑特殊的时候。5.2 方案二利用序列化实现通用深拷贝工具如果对象嵌套很深或者你不想为每个类都写clone方法可以用序列化来处理深拷贝。原理不复杂先把对象序列化成字节流再从字节流反序列化出一个新对象。因为中间经过了字节流新对象的整个对象图都是重新创建的天然就是深拷贝。Java自带的Serializable接口写法import java.io.*; public class DeepCloneUtil { SuppressWarnings(unchecked) public static T extends Serializable T deepClone(T source) { try (ByteArrayOutputStream bos new ByteArrayOutputStream(); ObjectOutputStream oos new ObjectOutputStream(bos)) { oos.writeObject(source); try (ByteArrayInputStream bis new ByteArrayInputStream(bos.toByteArray()); ObjectInputStream ois new ObjectInputStream(bis)) { return (T) ois.readObject(); } } catch (IOException | ClassNotFoundException e) { throw new RuntimeException(Deep clone failed, e); } } }用的时候只要类实现了Serializable接口一行代码Teacher copy DeepCloneUtil.deepClone(teacher);我之前在一个多线程并发处理订单对象的场景里用过这个工具。订单对象里嵌套了用户信息、商品列表、优惠明细层数深手工写clone实在不现实序列化方案几分钟就搞定了。但序列化方案有它的代价和限制。第一性能比手工clone差不少因为涉及IO和对象图的完整序列化反序列化性能敏感的高频操作慎用。第二所有参与克隆的对象图里的类都必须实现Serializable接口这等于给整个对象图加了一个约束——新增一个没实现Serializable的字段序列化直接崩。第三序列化不是通过构造函数创建的反序列化时不会调用构造方法如果有对象初始化逻辑依赖构造方法复制出来的对象会缺失这些初始化步骤。5.3 两种方案怎么选其实要看对象图的复杂度我在实际项目里总结出一个经验法则对象图深度不超过两层或者你明确知道这个类不会经常变动用手工clone对象图深、结构复杂、还在频繁迭代用序列化方案如果两者都不想碰那就别用clone改用别的思路下面第6节会讲。值得提醒的是无论哪种方案写完clone之后一定要写单元测试验证深浅。测试方法很直接克隆完之后修改原对象里的引用类型字段断言克隆对象不受影响。我见过太多以为深拷贝了实际还是浅拷贝的case其中最坑的是嵌套集合比如List里面装了一堆对象你以为克隆了List就完事其实里面的元素全是同一个对象。测试用例里多写一个修改元素的断言能帮你避免一堆线上问题。6. 从会写clone到会选方案项目里的克隆场景与替代思路6.1 真实开发中到底哪些场景需要克隆与其说哪些场景需要克隆不如说哪些场景需要对象的副本。我刚才提到的对象快照、方法传参保护、缓存对象隔离都是很典型的副本需求。这里我再补充一个我在电商项目里遇到的具体案例用户下单后系统需要生成订单快照存入数据库但订单对象后面还要经历一系列状态流转比如改收货地址、参与活动改价格。如果不做快照副本历史订单数据就会被后续操作改得面目全非。这个场景下要么在入库前把对象克隆一份存进去要么直接构建一个只读的订单快照DTO。后者其实更常见——这也引出我下一小节要说的核心观点。6.2 为什么在成熟项目里你很少见到clone方法如果你看过一些大型项目的源码会发现clone方法出现频率很低。原因不是不用复制对象了而是现代开发更倾向于用不可变对象和专门的对象转换工具来替代克隆。不可变对象思路类字段全部final构造时一次性赋值不提供任何修改方法。这样就不存在改了一个对象影响到另一个的问题因为没人能改对象本身。Java里String、LocalDate都是这么设计的。如果你有大量需要复制的场景把对象设计成不可变的连复制都不需要了——直接复用同一个引用安全得很。对象转换工具思路用BeanUtils.copyProperties或者MapStruct这类工具把一个对象的值拷贝到另一个对象里。Spring的BeanUtils基于反射用法简单OrderVO vo new OrderVO(); BeanUtils.copyProperties(orderEntity, vo);这本质上也是一种拷贝只是它创建的是新对象目标对象字段值逐个复制而不是内存层面的clone。我在团队里给新人的建议是能建新对象不要clone能传基本类型和不可变对象不要传可变引用必须拷贝可变对象时优先考虑BeanUtils或序列化方案最后才考虑手工clone。这样做的好处是代码意图更清晰。clone这个机制虽然功能强大但它在很多情况下比较隐晦——你看到一个build方法知道它在构建新对象你看到一个clone方法却不一定知道它是浅拷贝还是深拷贝必须去看实现才敢信。真实团队协作里这种不确定性本身就是一种风险。6.3 如果非要用clone我的几条底线建议第一类设计时明确标注克隆策略用注释说明这个类是浅拷贝还是深拷贝以及为什么。别小看一句注释它能拦住很多后来修改代码的人。第二clone方法如果涉及深拷贝调用链上所有类的clone方法必须保持一致语义要么都是浅拷贝要么都是深拷贝最忌讳集中在某个层面做深拷贝上一层还是浅拷贝这种不一致最容易出bug。第三克隆后的对象和原对象是独立的但独立的只是当前这一层如果后续有人给类新增了引用类型字段他有义务回来检查clone方法要不要跟着改。这靠自觉也靠code review。第四单例类、线程池类、数据库连接类这种资源型对象绝对不要实现Cloneable。它们的副本没有意义还会白白消耗资源。如果框架要求你必须实现重写clone时直接抛异常。在Java基础里API和对象克隆从来不是孤立的两个知识点——它们共同指向了对象在内存中如何存在、如何被操作、如何被安全地复制这个底层问题。你把这一课学扎实了后面接触集合的深拷贝、序列化、反射、框架里的Bean管理时都会顺利很多。至少再有人问你浅拷贝和深拷贝的区别你能从内存模型一路讲到代码实现再从代码实现讲到项目选型这就已经超过很多工作了几年的人了。
返回列表