覆盖考点:013|深拷贝与浅拷贝:对象图复制、序列化方案及不可变对象
适合读者:高级 Java 工程师、技术专家、架构师候选人
预计阅读时长:28–30 分钟(文末问答不计入)
业务代码里经常会出现"复制一份对象,改一改,别影响原来那份"的需求:缓存吐出去的数据不能被调用方顺手改坏、批量处理要基于一个模板对象派生出多个变体、多线程场景想给每个线程一份互不干扰的副本。这些需求表面上都是"复制对象",但复制到什么程度,结果天差地别——如果复制的只是最外层的壳,内部真正装数据的那部分还是原来那一份,这份"副本"随时可能变成一颗定时炸弹。
面试官问这个考点,追问链条通常是:clone() 默认复制了什么 → 浅拷贝会踩什么坑 → 深拷贝有几种实现方式 → 深拷贝自己又会踩什么坑(循环引用、性能)→ 有没有办法从根源上不需要拷贝。本文按这条链展开。
图1:浅拷贝的真相——两个对象,共享着同一个内部对象

一、浅拷贝:默认的 clone() 复制了什么
1. 一个对象里,到底存了什么
Java 里一个对象变量存的从来不是"对象本身",而是一份指向堆内存里那个对象的引用(可以粗暴理解成一个地址)。当对象的字段是 int、boolean 这类基本类型时,字段里存的就是值本身;但字段是另一个对象(String、List、自定义类)时,字段里存的仍然是引用,指向堆上真正的数据。
这个前提决定了"拷贝一个对象"天然有歧义:拷贝,到底是把这份引用复制一遍(两个变量指向同一块内存),还是把引用指向的那块内存本身也复制一遍(两块完全独立的内存)?
2. Object.clone():逐字段复制,就是浅拷贝
Java 给每个对象都准备了一个 protected 的 clone() 方法(定义在 Object 上),配合 Cloneable 这个标记接口使用。它的默认语义很朴素:在堆上开一块新内存,把原对象所有字段的值按位逐个复制过去。
public class Order implements Cloneable {
private String orderId;
private List<String> items;
@Override
public Order clone() throws CloneNotSupportedException {
return (Order) super.clone();
}
}
这段代码里,orderId 是 String(不可变对象,先不展开),items 是一个 List 引用。super.clone() 复制完之后,新对象的 items 字段里存的引用值,和原对象 items 字段里存的引用值,是同一个数字——换句话说,两个 Order 对象的 items 字段指向堆上同一个 ArrayList 实例。这就是浅拷贝:外层的 Order 对象确实是两个独立的实例,但内部的可变字段,谁都没有真的被复制,只是多了一份指向同一处的"钥匙"。
图2:浅拷贝埋下的雷——一处修改,处处遭殃

3. 浅拷贝的代价:共享引用引发的连锁反应
假设一个本地缓存返回数据时图省事,直接对缓存里的对象做了一次 clone() 再返回给调用方,以为这样调用方就不会碰到缓存的"真身":
public Order getOrderFromCache(String id) {
Order cached = cache.get(id);
return cached.clone(); // 以为返回的是"安全"的副本
}
如果 Order 用的还是上面那个只调用了 super.clone() 的浅拷贝实现,调用方拿到副本后执行 order.getItems().add("赠品"),这行代码改的其实是缓存里那份 items 列表本身——因为副本的 items 字段和缓存对象的 items 字段指向同一个 ArrayList。接下来任何一个调用方从缓存里再取这条数据,都会看到这个"赠品",缓存被一次看似无害的本地修改污染了。这类问题往往不会立刻报错,只会在某次线上问题排查时,才发现缓存里的数据不知道被谁悄悄改了——根因永远是同一句话:浅拷贝只复制了最外层,内部可变对象的引用被原样带过去了。
二、深拷贝:复制整张对象图
1. 深拷贝的定义:递归复制,直到没有可变引用为止
深拷贝要解决的正是浅拷贝的问题:新对象和原对象在内存上要完全独立——不仅外层对象是新开的,外层对象持有的每一个可变引用字段,也要递归地复制一份,直到递归到基本类型或者不可变对象(这些不需要再往下拷,因为它们本身不会被修改)为止。这个过程本质上是在复制一整张"对象图":一个对象引用另一个对象,另一个对象又引用别的对象,深拷贝要把这张图从根节点开始整体克隆一遍,而不是只克隆根节点。
图3:深拷贝——连内部对象也另起一份

2. 实现方式一:手写 clone(),逐层递归
最直接的做法是在 clone() 里手动把每一个可变引用字段也 clone 一遍,而不是依赖 super.clone() 的浅拷贝默认行为:
public class Order implements Cloneable {
private String orderId;
private List<String> items;
@Override
public Order clone() throws CloneNotSupportedException {
Order copy = (Order) super.clone();
copy.items = new ArrayList<>(this.items); // 手动重建一份新的 List
return copy;
}
}
如果 items 里装的不是 String(不可变),而是自定义的可变对象,还要对列表里的每个元素也调用一次 clone(),逐层往下递归。这条路的问题也很直接:类里有多少个可变引用字段,就要在 clone() 里手写多少行拷贝逻辑;字段一多、嵌套一深,代码写起来极其容易漏改一个字段,编译器和测试都未必能发现。《Effective Java》里明确建议尽量少用 Cloneable/clone() 这套机制,一部分原因就在这——它把"是不是深拷贝"这件事完全压在开发者的自觉性上。
3. 实现方式二:拷贝构造器 / 静态工厂方法
更被推荐的写法是不依赖 Cloneable,改用一个专门的构造器或静态工厂方法,在里面显式地把每个可变字段重新构造一份:
public class Order {
private final String orderId;
private final List<String> items;
public Order(Order source) {
this.orderId = source.orderId;
this.items = new ArrayList<>(source.items); // 防御性拷贝
}
}
这种写法和手写 clone() 的工作量其实差不多——一样要逐个字段处理,一样容易漏——但胜在语义清晰:不依赖 Object.clone() 那套容易踩坑的协议(比如子类继承后 clone() 的返回类型、final 字段和 clone() 天然冲突等问题),构造器该怎么写、该传什么参数一目了然,也更容易配合不可变对象的设计(字段声明成 final,见本文第七节)。
三、序列化方案:借道字节流做深拷贝
手写递归拷贝的核心痛点是"字段越多越容易漏",序列化方案换了一种思路:不再手写每个字段怎么拷,而是借助 Java 自带的序列化机制,把整个对象图先变成一串字节,再从这串字节里重新还原出一个全新的对象图。
图4:序列化深拷贝时序——写成字节流,再从字节流里长出一个全新对象图

1. Java 原生序列化:ObjectOutputStream + ObjectInputStream
具体做法是把对象写进一个内存字节数组,再从这个字节数组读出来,读出来的就是一份完全独立的新对象:
public static <T extends Serializable> T deepCopy(T original) {
try (ByteArrayOutputStream bos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(bos)) {
oos.writeObject(original);
try (ObjectInputStream ois = new ObjectInputStream(
new ByteArrayInputStream(bos.toByteArray()))) {
@SuppressWarnings("unchecked")
T copy = (T) ois.readObject();
return copy;
}
} catch (IOException | ClassNotFoundException e) {
throw new RuntimeException("深拷贝失败", e);
}
}
这条路要求参与拷贝的类(包括所有引用字段的类)都实现 Serializable 接口,否则序列化时会直接抛 NotSerializableException。writeObject 会递归处理对象图上的每一个引用字段——这一点和手写递归 clone 的思路是一致的,区别是这个"递归遍历所有字段"的动作由 JDK 反射机制自动完成,不需要开发者手写。
2. JSON 序列化方案:Jackson / Gson
另一条不依赖 Serializable 接口的路,是借助 JSON 库先把对象序列化成 JSON 字符串,再反序列化回一个新对象:
public static <T> T deepCopyByJson(T original, Class<T> clazz) {
ObjectMapper mapper = new ObjectMapper();
try {
String json = mapper.writeValueAsString(original);
return mapper.readValue(json, clazz);
} catch (JsonProcessingException e) {
throw new RuntimeException("深拷贝失败", e);
}
}
这种写法不要求类实现任何特定接口,对没有源码、无法加 Serializable 的第三方类也能用;缺点是要求字段有可访问的 getter/setter(或者按 Jackson 的配置支持字段直接访问),且转成 JSON 文本再解析回来,对 transient 字段、泛型类型擦除后的复杂类型、日期等特殊类型经常需要额外配置才能还原准确,边界情况比原生序列化更多。
四、深拷贝躲不开的坑:循环引用
图5:循环引用——手写递归拷贝最容易掉进的坑

如果对象图里 A 引用 B、B 又引用回 A(比如双向关联的父子节点、双向链表),手写递归 clone 或拷贝构造器如果没有特殊处理,会陷入"拷贝 A 时发现要拷贝 B,拷贝 B 时发现要拷贝 A,拷贝 A 时又发现要拷贝 B……"的无限递归,最终栈溢出(StackOverflowError)。要正确处理循环引用,手写方案必须维护一张"已经拷贝过谁"的哈希表(key 是原对象的身份,value 是对应的拷贝结果),递归前先查表,查到了直接返回已有的拷贝结果,而不是继续往下递归——这正是很多手写深拷贝代码会漏掉的一步,也是这类代码在生产环境偶尔炸出 StackOverflowError 的根因。
Java 原生序列化天然不怕这个问题:ObjectOutputStream 内部维护了一张"引用表",同一个对象在一次 writeObject 调用里只会被真正序列化一次,第二次遇到同一个引用时写入的是一个指向已序列化条目的"引用标记",反序列化时能还原出同样的共享关系而不会死循环。这也是原生序列化方案相比手写递归拷贝的一个天然优势。JSON 序列化方案则相反——绝大多数 JSON 库默认不处理循环引用,遇到 A 引用 B、B 引用 A 会直接在序列化阶段陷入无限递归并抛出 StackOverflowError,需要显式配置(比如 Jackson 的 @JsonIdentityInfo 注解标记对象身份)才能支持。
五、四种方案怎么选:性能与适用场景对比
图6:Java 深拷贝四种方式速度对比

把前面几种方案放到同一个维度上比:
| 方案 | 是否自动处理循环引用 | 性能量级 | 侵入性 | 典型场景 |
|---|---|---|---|---|
| 手写 clone / 拷贝构造器 | 不自动,需手动维护已拷贝表 | 最快 | 每个类都要手写维护 | 字段少、结构稳定的核心领域对象 |
| Kryo 等第三方二进制序列化 | 自动 | 较快 | 需要引入第三方依赖 | 对性能敏感、字段结构复杂的场景 |
| Jackson/Gson JSON 序列化 | 默认不支持,需额外配置 | 中等 | 需要可访问的 getter/setter | 需要跨语言/跨进程时正好顺带完成深拷贝 |
| Java 原生序列化 | 自动 | 最慢 | 类必须实现 Serializable | 字段结构复杂、对性能不敏感、追求写法简单统一 |
手写方案快,是因为它跳过了反射、跳过了字节的编码解码,直接用 new 和字段赋值完成复制;序列化方案慢,是因为要走反射读取每个字段、要做类型信息的编码解码、要在内存里来回倒腾字节数组,这些开销比直接的字段赋值高出一个量级。Kryo 之所以能比 Java 原生序列化快得多,核心原因是它省掉了 Java 序列化协议里大量用于兼容性(类描述符、字段名字符串)的冗余信息,用更紧凑的二进制编码,同时大量使用字节码生成而非纯反射来读写字段。工程实践里,Apache Commons Lang 的 SerializationUtils.clone() 就是对"Java 原生序列化深拷贝"这套写法的现成封装,不用每个项目重复写一遍上面那段 try-with-resources 代码。
需要额外提一句:Java 原生序列化除了慢,还有安全层面的顾虑——如果 readObject 处理的是不可信来源的字节流,攻击者可以构造特殊的序列化数据触发目标类里的意外逻辑(反序列化漏洞,历史上多个知名库都出过相关 CVE)。但这里讨论的深拷贝场景是"自己写、自己读、字节流从不落地也不跨进程传输",数据源完全可信,不涉及这类风险;如果要把这套机制挪用到"接收外部输入并反序列化"的场景,就是另一个需要单独评估的安全问题了。
六、更彻底的解法:设计成不可变对象
前面讨论的所有拷贝方式,出发点都是"这个对象可能被意外修改,所以要复制一份隔离风险"。如果一个对象从设计上就不可能被修改,那这份顾虑就不再存在——不需要防御性拷贝,因为没有什么"防御"的对象;可以被多个线程、多个调用方安全共享同一个实例,不用担心谁改了谁的数据。这就是不可变对象(Immutable Object)要解决的问题:不是把拷贝做得更快更全,而是从根源上让拷贝变得不必要。
图7:设计一个不可变类的四个动作

1. 不可变类的四个动作
把一个类设计成真正不可变,通常要做到:类本身声明为 final(禁止被继承,防止子类打破不可变的约定);所有字段声明为 final,并且都在构造器里一次性赋值完成;不提供任何 setter,或者任何会修改内部状态的方法;如果构造器接收的参数是可变类型(比如 List、Date),构造器内部要重新复制一份再赋给字段,getter 返回字段时如果字段本身是可变类型,也要返回一份拷贝或者不可修改的视图,而不是把内部字段的引用原样交出去——这一步业界统称为防御性拷贝,它的本质正是本文前面讲的浅拷贝/深拷贝问题在不可变对象设计里的具体应用:如果构造器只是把传入的 List 引用直接赋值给字段,外部调用方后续修改传入的那个 List,实际上就绕过了不可变类的保护,间接改掉了"不可变对象"内部的状态。
public final class ImmutablePoint {
private final int x;
private final int y;
private final List<String> tags;
public ImmutablePoint(int x, int y, List<String> tags) {
this.x = x;
this.y = y;
this.tags = new ArrayList<>(tags); // 防御性拷贝:切断和外部传入引用的联系
}
public List<String> getTags() {
return Collections.unmodifiableList(tags); // 返回不可修改视图,不暴露内部真实引用
}
}
2. Java 生态里的不可变实践
String、Integer 等包装类是最常见的不可变对象——它们的"修改"操作(比如 String 的 concat、substring)从不改动原实例,都是返回一个新实例,这也是为什么可以放心地把 String 当 HashMap 的 key、在多线程间随意传递而不用加锁。Java 16 引入的 record 默认把所有字段声明为 final 并自动生成对应的访问器,天然具备不可变的"骨架",但要注意:record 只保证自己声明的字段引用不会被重新赋值,如果某个字段本身是 List 这种可变类型,record 的默认构造器并不会自动帮你做防御性拷贝——这仍然需要在紧凑构造器(compact constructor)里手动补上,否则 record 依然可能通过内部可变字段被间接篡改,"用了 record 就等于线程安全的不可变对象"是一个常见的误解。另外要分清 Collections.unmodifiableList 和 List.copyOf 的区别:前者只是在原列表外面包了一层"只读视图",原列表本身如果被修改,这个视图看到的内容也会跟着变,它防的是"通过这个视图去修改",防不了"原始列表被别的地方直接改了";后者才是真正复制出一份独立的、不可变的新列表,和原列表彻底脱钩。
七、总结:一张决策树覆盖所有场景
把整篇内容串起来,选择哪种复制策略可以归纳成一套判断顺序:先问这个对象有没有可能从设计上就变成不可变——能的话最省事,不拷贝、直接共享引用即可;不能的话再看对象图结构:如果字段全是基本类型或 String 这类不可变类型,浅拷贝(clone 或拷贝构造器)就够用,因为浅拷贝对不可变字段而言效果等同于深拷贝;如果内部有可变的复杂结构且存在循环引用,纯手写递归拷贝要格外小心,通常直接换成序列化方案更省心;在序列化方案内部还要再权衡性能——性能敏感就用 Kryo 或 SerializationUtils 这类二进制序列化,性能不敏感或者只是图写法省事,用 Jackson 之类的 JSON 序列化也可以接受。
图8:拷贝策略决策树——先问要不要可变,再选怎么拷

问答(不计入阅读时长)
Q1:为什么《Effective Java》建议尽量不要实现 Cloneable?
A:Cloneable 本身是个空接口,没有声明任何方法,它唯一的作用是给 Object.clone() 做一个"运行时开关"——没实现它就调用 clone() 会抛 CloneNotSupportedException。真正的克隆逻辑要靠重写 clone() 手写,而这个方法的契约相当模糊:它默认是浅拷贝,子类重写时既要记得调用 super.clone(),又要手动处理每一个可变字段,还要处理 final 字段无法在 clone() 里重新赋值的天然冲突(final 字段只能在构造器里赋值一次,super.clone() 走的不是构造器路径)。构造器或静态工厂方法把这些坑都绕开了:不依赖父类实现是否正确,参数和返回值类型完全自主可控,也不需要处理受检异常。
Q2:数组调用 clone() 是深拷贝还是浅拷贝? A:这类基本类型数组,clone() 出来的新数组和原数组用的是两块完全独立的内存,效果上"看起来"就是深拷贝,因为基本类型本身不存在共享引用的问题;但如果是 Object 数组、String 二维数组这类引用类型数组或者多维数组,clone() 只复制了数组这一层容器,数组里每个元素的引用仍然和原数组指向同一批对象——多维数组本质上是"数组的数组",外层数组被复制了,但内层的子数组作为对象,仍然是共享的,clone() 之后修改内层数组的内容,两个外层数组看到的都是同一份变化。
Q3:BeanUtils.copyProperties 和 MapStruct 这类对象转换工具,是深拷贝还是浅拷贝?
A:默认都是浅拷贝。这类工具的原理是通过反射(BeanUtils)或编译期生成代码(MapStruct)读取源对象每个属性的值,再调用目标对象对应属性的 setter 写进去——如果属性是基本类型或 String,这个"读取再写入"的过程没有问题;但如果属性是 List、Map 或自定义对象引用,写进目标对象的仍然是同一个引用,源对象和目标对象的这部分数据仍然共享。MapStruct 支持通过显式配置对某些字段生成深拷贝逻辑,但这不是默认行为,用之前要确认清楚。
Q4:字符串是不可变的,那 String s = s + "abc" 算不算一种"拷贝"?
A:不算拷贝,这是引用重新赋值。s + "abc" 会在堆上创建一个全新的 String 对象(内容是拼接后的结果),然后把这个新对象的引用赋给变量 s;原来 s 指向的那个 String 对象没有被改动,也没有被复制,只是变量 s 不再指向它了,如果没有别的变量还持有那个引用,它会在下一次 GC 时被回收。这和"拷贝一个对象"是两件事——拷贝强调的是"内容相同的两份独立数据同时存在",而这里原对象和新对象内容并不相同,只是发生了变量指向的切换。
Q5:用 Java 原生序列化做深拷贝时,transient 字段会怎么样?
A:transient 修饰的字段在序列化时会被跳过,writeObject 不会把它的值写进字节流;对应地,反序列化出来的新对象里,这个字段会是该类型的默认值(对象引用是 null,基本类型是 0/false)。如果一个类里有 transient 字段还想用序列化方案做深拷贝,要清楚这个字段在拷贝结果里会"丢失",需要额外补一行代码手动把这个字段的值单独赋给拷贝出来的新对象,或者用其他拷贝方式处理这个字段。
Q6:Kryo 和 Java 原生序列化都是二进制序列化,为什么性能差这么多?
A:Java 原生序列化协议里,每写一个对象都要连带写入完整的类描述信息(类名、字段名、serialVersionUID 等),这些元信息占了序列化结果的很大一部分体积,而且读写过程大量依赖反射;Kryo 默认关闭了这类冗余的类型信息(改用注册表 + 数字 ID 代表类型),并且大量使用运行时生成的字节码去直接读写字段,避免了反射调用的开销。两者拿到的最终效果类似(都是把对象图变成字节流再还原),但底层"写了多少字节、怎么读写字段"的实现细节差异很大,这直接反映在速度差距上。
Q7:如果一个 record 的字段是 List,这个 record 还算不可变吗?
A:严格说不算完全不可变,是一个常见的设计陷阱。record 保证的只是"这个字段引用本身不会被重新赋值"——没有 setter,字段是 final 的;但如果这个字段是 List 类型,且构造器直接把外部传入的 List 引用赋给字段,调用方拿到 record 实例后,只要还持有那个原始 List 的引用,就可以直接修改它,record 内部看到的数据也会跟着变,getter 拿到的还是那个可变的原始引用。要让这个 record 真正不可变,需要在紧凑构造器里对 List 参数做防御性拷贝(比如 this.tags = List.copyOf(tags);),并确保生成的访问器不会把内部可变引用直接交出去。
Q8:深拷贝是不是任何时候都比浅拷贝更安全,应该默认全部用深拷贝?
A:不是,深拷贝有实际的性能和内存代价——复制一整张对象图,尤其是嵌套很深或者数据量很大的结构,会带来明显的 CPU 和内存开销,如果这份拷贝根本不会被修改,这份开销就是纯粹的浪费。判断要不要深拷贝的关键不是"深拷贝更安全就无脑用",而是回到本文的决策树:这个对象内部到底有没有可变字段、这份拷贝出去之后会不会被修改、修改了会不会影响到不该受影响的另一份数据。如果字段全是不可变类型,浅拷贝就已经足够安全;如果一个对象本来就不会被修改(比如只读的配置对象),甚至完全不需要拷贝,直接共享引用即可。