ARTICLE DETAIL

资讯详情

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

Java深拷贝与浅拷贝:原理、陷阱与面试实战指南

Java深拷贝与浅拷贝:原理、陷阱与面试实战指南 写Java这么多年深拷贝和浅拷贝这个知识点几乎是面试必问平时写代码也是躲不开。网上相关文章一搜一大把但大多是给你甩两张图、贴几段代码就跑真到了线上出问题、面试官深挖的时候你还是很懵。这篇东西我不打算讲教科书那套就按我自己踩过的坑、做过的高频场景把Java里的深拷贝浅拷贝从头到尾剥一遍。适合正准备Java面试的人也适合那些写完实体类复制后线上数据莫名其妙被改掉的同学。看完你至少能搞清楚三件事这俩到底在分什么边界clone()为什么总让人觉得别扭以及真正做深拷贝时该选哪条路。1. 先分清三个概念引用拷贝、浅拷贝、深拷贝1.1 最容易被忽略的引用拷贝很多人一开始接触Java就被Java只有值传递绕晕了后来又听人说对象其实是引用传递于是脑子里打架。实际上基础概念里还有一个更底层的东西叫引用拷贝。简单说就是赋值操作——User user2 user1;那一行没有创建任何新对象只是在栈上多了一个引用变量它和原来的引用指向堆内存里同一个User对象。这个特性和浅拷贝、深拷贝不一样的是它完全不存在复制这个动作所以严格意义上连拷贝都算不上。但对理解深浅拷贝特别重要因为我见过太多初学者分不清赋值传引用和拷贝一个新对象的区别。赋值之后你去改user2里的字段user1也会跟着变因为本来就是同一个对象。面试时把这事放在开头讲考官马上就知道你基础是成体系的而不是背了两个名词就来了。1.2 浅拷贝复制了引用共享了内存浅拷贝这个动作一定是产生了一个新对象这一点必须记住。新对象的每个字段都复制了一遍但如果字段是引用类型那复制过来的只是引用地址并没有把引用的目标对象再复制一份。所以拷贝出来的新对象和原对象在引用类型字段指向的内容上是共享的。我拿生活中常见的情况打个比方。你有一份房屋租赁合同原件浅拷贝就是复印了一份合同。合同上的出租方、承租方这些基本信息是字面复制没问题但合同附件里的房屋清单复印的只是清单的编号、页数你顺着这个页数找到的还是原始那一份清单。所以你改复印件的附件清单上某个内容原件的附件也变了。这个例子我在地铁上给一个刚入行的同事讲过他听完立刻就说原来这就叫对象图没有深挖到底。1.3 深拷贝整个对象图全部重建深拷贝就不是复印合同这么简单了。它是把整份合同连附件、连附件里引用的补充协议、连手写备注的每一页全部重新排版成一份全新的。在原对象和拷贝对象之间找不到任何一个共享的引用类型节点。所有嵌套对象、集合里的元素、集合里的集合都被递归复制了一遍。写代码的时候深拷贝往往不是一个方法调用能解决的它更像是针对你的对象结构做一次图遍历。网上的教程喜欢给你画一张对象引用关系图然后把共享节点标红说这就是浅拷贝。图是没问题的但你要清楚真正写代码的时候你需要的不是一张图而是一个递归策略走到哪个字段、该不该停、要不要深挖下去。这也是为什么后面要花大篇幅讲工具和手写方法因为深拷贝的尽头往往是对象图边界的设计问题。2. Object.clone()机制值得扒层皮为什么写clone()前要想清楚2.1 Cloneable标记接口与clone()的native实现Java里设计了一套和克隆相关的机制核心就两部分Cloneable标记接口以及Object类里的protected native Object clone()方法。这个设计有它特殊的地方。Cloneable接口里头是空的不声明任何方法它纯粹是给JVM当通行证用的。你调用任何一个对象的clone()时JVM会检查这个类有没有实现Cloneable没有就抛CloneNotSupportedException。还有一个细节很多人忽略Object.clone()是protected方法而且返回值是Object。这意味着你不重写、不向外暴露外面根本没法用。通常我们重写时要把它改成public并且返回具体的类型这个在Java里叫协变返回类型5.0之后就支持了。我见过一些旧代码还专门去强转那其实是没必要了。2.2 为什么说Object.clone()默认做的是浅拷贝不明白的人第一次看到Object.clone()的native实现时总以为它能自动深拷贝。其实native的默认行为就是逐字段复制。基本类型字段直接拷值引用类型字段拷引用地址。这个逐字段听起来挺像浅拷贝的定义对吧对它的默认行为就是浅拷贝。给你看个典型例子实体类有基本类型、String、List三种字段时clone()完再改List两边都会变。String特殊一点它虽然也是引用类型但因为不可变改String实际上会生成新对象所以看起来像深拷贝一样安全。这个特性面试里经常被拿来讨论你要能说出来不是clone()深拷贝了String而是String的不可变性掩盖了浅拷贝的问题。public class User implements Cloneable { private String name; private int age; private ListString tags; private Address address; Override public User clone() { try { User u (User) super.clone(); // 到这里u.tags仍然和this.tags指向同一个List return u; } catch (CloneNotSupportedException e) { throw new RuntimeException(e); } } }2.3 手写clone()重写的正确姿势与边界问题如果要利用clone()来做深拷贝那就得每一层引用都自己处理。比如上面的User你要额外做两件事新建一个ArrayList把tags拷贝进去新建一个Address对象把address里的字段拷一遍。如果tags里的元素也是对象还要决定元素要不要再复制。这套东西写起来不难难的是想清楚边界。我在实际项目里遇到过这个坑一个订单实体里面嵌套了商品列表、买家信息、卖家信息还有一张物流轨迹表。一开始图省事只对商品列表做了浅层拷贝线上用户修改订单备注时不小心把另一个订单的商品名称也给改了。排查了半天最后发现是拷贝的边界太浅。所以你在重写clone()之前先画一张对象引用结构图标清楚哪些节点需要深拷哪些节点允许共享。比如某个字段是常量配置、是枚举、是不可变对象那浅拷贝也没问题而那些后续会被修改的List、Map、自定义对象必须深拷贝。注意super.clone()返回的对象类型是Object即使你在clone()里用了(User)强转底层也是先在堆上分配内存再把字段逐一拷贝。它的效率比new一个对象再逐个set字段往往更高但这个优势很有限别过度迷信。3. 序列化、工具库和手写三种深拷贝方案实测对比3.1 方案一手写复制方法与对象图边界最直白的深拷贝方式就是写一个copy方法或者构造函数把所有字段一个个赋值嵌套对象自己new。这种方式最大的优点在于你能精确控制边界哪些字段共享、哪些深拷代码里一目了然。public class OrderCopyUtil { public static Order deepCopy(Order source) { Order target new Order(); target.setOrderId(source.getOrderId()); target.setRemark(source.getRemark()); if (source.getItems() ! null) { ListItem itemList new ArrayList(); for (Item item : source.getItems()) { Item newItem new Item(); newItem.setSkuId(item.getSkuId()); newItem.setPrice(item.getPrice()); // 如果Item里还有嵌套继续在这里处理 itemList.add(newItem); } target.setItems(itemList); } return target; } }缺点其实更明显代码量会跟着对象嵌套层数爆炸。业务对象三层、四层嵌套是常态每个类都得写这么一串维护起来非常痛苦。最恶心的是你加了一个字段copy方法容易漏改。这不是个理论问题是我在代码评审时发现很多次的实际问题。所以手写深拷贝只适合结构简单、字段少、很少变化的场景比如DTO、VO这种轻量对象。3.2 方案二序列化流式复制第二种做法是利用序列化。把对象写到输出流里再读回来整个过程会重建一个完整的对象图。常用的写法是把对象写到字节数组再从字节数组构造一个ObjectInputStream读出来。这个方案几乎不用写具体字段复制的代码几个方法就能完成任意深度的深拷贝前提是你所有涉及的类都实现了Serializable。public static T T deepCopyBySerializable(T source) { ByteArrayOutputStream byteOut new ByteArrayOutputStream(); try (ObjectOutputStream out new ObjectOutputStream(byteOut)) { out.writeObject(source); ByteArrayInputStream byteIn new ByteArrayInputStream(byteOut.toByteArray()); try (ObjectInputStream in new ObjectInputStream(byteIn)) { return (T) in.readObject(); } } catch (IOException | ClassNotFoundException e) { throw new RuntimeException(深拷贝失败, e); } }这里有个很大的坑static字段不会被序列化transient字段也不会被序列化。比如订单对象里有个transient Logger或者transient Map缓存序列化回来之后这些字段会是默认值。如果业务上恰好依赖了这些字段线上就是诡异Bug的爆发点。另外老版本JDK的序列化会有安全警告Java 17以后模块化、反序列化过滤也是常见考题你最好知道这些细节而不是只背模板。3.3 方案三Gson/Jackson等序列化框架与BeanTools的取舍如果项目里已经用了Gson或者Jackson做JSON处理很多人会顺手用它拷贝把对象转成JSON字符串再从JSON字符串解析回来。API层面非常优雅一行代码。比如Gson的new Gson().fromJson(json, type)。这个方法不需用对象的类实现Serializable因为它走的是反射和JSON序列化机制这是它对比Java原生序列化的显著优势。但它也有自己的规则。Gson对某些类型有特殊处理比如Integer缓存、枚举、泛型擦除后的List类型解析回来时序号、内部缓存都可能变化或者丢失。Jackson还需要类有无参构造函数如果没有大概率直接抛异常。还有一点JSON方式在拷贝大对象时性能损耗非常明显因为它要经历对象转字符串、字符串转对象两次完整转换。我在压测一个库存快照拷贝时试过100万次JSON深拷贝比手写深拷贝慢了接近20倍。这个量级其实已经会影响到线上接口了。工具库方面Apache Commons的BeanUtils.copyProperties、Spring的BeanUtils.copyProperties也常被拿出来讲。Spring的那个做的是浅拷贝不是深拷贝把嵌套对象整个引用复制走了。但有很多面试题会故意拿它混淆你得知道它不是万能的深拷贝工具只能处理简单字段复制。提示选方案时先回答三个问题——类的字段层次有多深拷贝频率高不高类能不能改动深且低频用序列化省事浅且高频手写更稳不能新增依赖但又想简洁Gson/Jackson是折中项。4. 实战案例订单对象深拷贝的完整处理4.1 需求场景与代码实现我最早遇到深拷贝是在做一个订单快照功能。需求是这样的用户下单后订单数据可能之后会被修改比如商家改商品价格、物流更新状态。但财务对账却要求保留下单那一刻的订单原始数据。如果直接把order对象存到快照表里后来订单一改快照查出来多少会受影响因为对象引用都指向同一个地方。所以必须生成一个完全独立的快照对象。这个场景下订单快照对象OrderSnapshot包含String商品名称、BigDecimal商品金额、ListItemSnapshot商品明细、AddressSnapshot收货地址还有收货人的transient日志配置。我给这个场景做了两种实现手写复制方式和序列化方式。手写方式按照第3节的结构一层层new对象、set字段。序列化方式则要求所有快照类实现Serializable然后调用deepCopyBySerializable(order)。这个功能我实测过单条订单深拷贝耗时基本在1毫秒以下单线程批量复制1000条订单也只有不到50毫秒。对于低频快照任务完全够用。但如果是在高并发交易链路里做快照我建议别这么做序列化在大量对象时CPU开销会明显能用写SQL直接重建对象视图就尽量不要在Java内存里做对象图复制。// 实体类定义简化展示 public class OrderSnapshot implements Serializable { private static final long serialVersionUID 1L; private String orderId; private BigDecimal totalAmount; private ListItemSnapshot items; private AddressSnapshot address; // 省略getter/setter } public class ItemSnapshot implements Serializable { private static final long serialVersionUID 1L; private String skuId; private String productName; private BigDecimal price; } public class AddressSnapshot implements Serializable { private static final long serialVersionUID 1L; private String province; private String city; private String detail; }4.2 性能与正确性验证思路写完拷贝逻辑怎么验证真的深拷贝了网上最常见的方案就是打印两个对象的引用地址然后想着反正不同就行了。这不够。最直接的方法是分别修改原对象和拷贝对象里的嵌套字段再判断彼此是否受影响。比如拷贝完成之后修改原订单里的items.get(0).setPrice(new BigDecimal(0.01))然后去读拷贝对象里的价格如果还是原价说明这一层深拷贝生效了。这套验证最好写成单元测试我遇到过几次拷贝代码重构时把深拷贝改回浅拷贝的情况。没有测试兜底这类问题当场发现不了等到线上对账数据出错才查那感觉非常酸爽。写JUnit时直接用反射把对象字段全部取出来比较也行但遍历对象图会很繁琐实际上很少有人这么干更常见的是挑几个关键嵌套节点做显式断言。性能上做快照类的拷贝前先想清楚有多少数据量。几百条、延迟不敏感用序列化没问题。如果是千万级数据迁移就别用任何Java对象拷贝方案了那是SQL和流式处理的活。5. 深拷贝陷阱与面试高频题速查5.1 常见陷阱从线上事故里总结的几个坑第一个坑是循环引用。A里面有个字段BB里面又有字段A如果深拷贝的时候没做访问标记递归下去就是无限循环堆栈直接溢出。手写递归深拷贝时几乎必然会遇到这个问题。我的处理方式是在项目里维护一份身份集合每拷贝一个对象就把原对象的引用丢进去碰到已经有了的就直接返回或者抛异常。别等到线上爆了才想起来。第二个坑是单例模式被深拷贝破坏。有些全局配置类用了单例但如果它实现了Serializable又被人拿去深拷贝序列化机制会绕过私有构造函数创建新对象单例就被破坏了全局状态可能出现两份。Java里对这种情况的防御是readResolve方法如果你在写一个需要防拷贝的类记住这个细节。第三个坑是Integer的缓存问题。集合里的Integer小值对象在序列化和反序列化时会有缓存共享某些框架下可能影响你判断这两个对象是否完全独立。这不算大坑但面试官如果深挖很容易考倒人。第四个坑被踩得最多就是浅拷贝遇到不可变类。String和Integer这些不可变类型掩盖了浅拷贝的问题让开发者误以为自己的拷贝逻辑没问题结果某个嵌套的可变对象一改就爆炸。排查方式我刚才说了拿一个可变嵌套字段做双向修改测试。5.2 面试怎么答才能加分面试官问深拷贝和浅拷贝区别你要是只会说浅拷贝复制引用深拷贝复制对象那是及格线。想拿高分要按这个顺序组织答案第一层定义区别讲清楚引用地址和堆对象是否共享。第二层结合Java具体机制讲Object.clone()默认浅拷贝Cloneable接口是标识super.clone()之后要进一步处理引用字段才能变深拷贝。第三层讲实现方案对比手写、序列化、JSON框架各自适用什么场景序列化会漏掉static和transient字段。第四层主动讲坑循环引用怎么处理单例被序列化破坏Spring BeanUtils.copyProperties实际是浅拷贝。这个回答结构的好处是它会引导面试官顺着你喜欢的方向问。比如你主动讲到序列化漏掉transient字段面试官可能会问你transient到底是什么哪怕你没准备那么深你也有机会把话题拉回你熟悉的安全区。还有个小技巧你可以主动提一嘴现在线上我们其实很少靠clone()拷贝业务对象更喜欢用DTO转换因为可以限定边界。这是很多公司实际的做法说出来既真实又能体现你见过生产代码。5.3 有一类特殊问题为什么有时候你不想深拷贝很多人把深拷贝当成更高级的东西碰到任何拷贝场景都一律深拷贝。其实不然。共享对象在某些场合是特性不是Bug。比如只读配置对象项目启动时加载一次多个线程共享同一个实例内存占用小性能也好。这种场景如果你去深拷贝一份反而浪费内存和时间。再比如缓存池里复用的枚举、常量深拷贝完全是多余动作。真正需要深拷贝的场景其实很聚焦对象后续会被修改并且这份修改不应该影响原对象或者对象要跨线程传递而你不希望另一线程在不知情时改动共享状态再比如做快照、做审计日志、做请求参数隔离。把场景想清楚你就不容易被面试官一句这个场景该不该用深拷贝问倒。我个人在实际项目里的体会是深浅拷贝问题到最后往往是对象设计问题。如果你的对象嵌套太深、边界模糊怎么拷都容易出错。先把类结构理清把可变与不可变字段分开把共享和复制边界定义好再谈拷贝实现大多问题都能在动手前化解掉。踩过几次坑之后你就明白了Java基础考的不是背概念而是你能否在真实场景里做出正确判断。
返回列表