浅拷贝和深拷贝有什么区别?
简化版
浅拷贝只复制最外层对象,里面的引用字段仍指向同一批子对象;深拷贝会把可变子对象也一并复制,得到两个互不影响的对象图。判断标准就一句:改了拷贝的嵌套字段,原对象会不会跟着变——会变就是浅拷贝。
详细版
先厘清三件容易混的事:
- 赋值
User b = a;:根本没拷贝,两个变量指向同一个对象。 - 浅拷贝:new 了一个新的外层对象,但它的引用字段(
List、Address等)还是指向原来的子对象。 - 深拷贝:连同引用字段指向的可变对象一起复制,两份数据彻底独立。
Object.clone() 默认做的就是浅拷贝——按字段逐个复制,基本类型复制值,引用类型复制的是「引用」,所以子对象仍是共享的。而且它还牵扯 Cloneable 标记接口、受保护的 clone 方法、绕过构造器等历史包袱,业务代码里通常不如显式复制清晰。
// 手写深拷贝:把可变的 Address 也 new 一份
User copy = new User(
original.getName(), // String 不可变,直接共享即可
new Address(original.getAddress().getCity()) // 可变对象,必须复制
);
注意 name 是 String,不可变,直接共享没有任何风险;只有可变的子对象才需要复制。所以更好的设计往往是让子对象不可变,从根上省掉深拷贝的麻烦。
完整版教学
一、为什么会有「浅」这个坑
Java 里变量存的是引用(对象的地址),不是对象本身。所以「复制一个对象」天然有歧义:复制那个地址,还是复制地址指向的东西?浅拷贝复制了地址,深拷贝复制了东西。
一个典型 bug:
List<Integer> a = new ArrayList<>(List.of(1, 2, 3));
List<Integer> b = new ArrayList<>(a); // 拷贝构造,但对元素是浅拷贝
这里 b 是新的 List,往 b 加元素不影响 a——外层独立了。但如果元素是可变对象(比如 List<User>),b.get(0) 和 a.get(0) 还是同一个 User,改其中一个另一个也变。这就是为什么「拷贝了 List 还出 bug」。
二、几种实现深拷贝的方式
- 手写构造 / 拷贝方法:最清晰可控,逐字段决定复制还是共享。推荐首选。
- 拷贝构造函数:
new ArrayList<>(old)、new HashMap<>(old)——注意它们只深拷贝容器结构,元素仍是浅拷贝。 clone():默认浅拷贝;要深拷贝得重写clone()、手动复制每个可变字段,很容易漏,不推荐。- 序列化再反序列化:把对象写成字节流再读回来,天然是整棵对象图的深拷贝。但要求所有字段可序列化、性能差、还有安全问题,只在没有更好办法时用。
三、关键判断:这个子对象「可变」吗
深拷贝的目的是隔离修改。如果一个子对象根本不可变(String、Integer、LocalDate、你自己设计的不可变值对象),它被共享也永远不会被改,那共享它就是安全的,不需要深拷贝。
记忆点:深拷贝只针对「可变且会被独立修改」的子对象。能把子对象设计成不可变的,就根本不需要深拷贝——这是最优解,而不是无脑给所有东西都做深拷贝。
四、用对象图理解复制边界
深拷贝复制的不是“字段列表”,而是从根对象可达的一张对象图。假设一个订单有 2 个明细,而两个明细共享同一个商品对象;正确的深拷贝既要让新订单拥有独立明细,又要决定新图内部是否继续共享同一个商品,不能把同一商品错误复制成两个不同对象。
原订单 ─┬→ 明细A ─┐
└→ 明细B ─┴→ 商品P
新订单 ─┬→ 新明细A ─┐
└→ 新明细B ─┴→ 新商品P(若商品可变且要求隔离)
对象图还可能有环,例如 parent.child.parent == parent。朴素递归会无限复制,因此通用深拷贝算法需要一张“原对象 → 新对象”的身份映射表:每创建一个副本先登记,再递归复制邻接对象。序列化框架也需要用类似的引用追踪机制来保持环和共享关系。
五、方案选择与成本
假设一个对象图有 1 万个可变节点,完整深拷贝至少要访问并分配这 1 万个节点,时间与额外空间通常都是 O(V + E);浅拷贝外层对象则接近 O(字段数)。因此深拷贝绝不是免费的“更安全版本”,复制大图会显著增加延迟、内存峰值和 GC 压力。
| 方案 | 优点 | 代价与适用边界 |
|---|---|---|
| 手写拷贝构造器 | 类型安全、复制边界清楚 | 字段变化时要同步维护 |
clone() | 语言内置入口 | 默认浅拷贝、API 历史包袱重 |
| 序列化往返 | 可复制整张对象图 | 慢、受格式和安全约束 |
| 不可变对象共享 | 无需复制且线程安全 | 更新时要创建新值 |
| 写时复制 | 读多写少时节省成本 | 首次写入成本高,实现更复杂 |
工程上应先定义隔离边界,再选择方案。DTO 快照可能只需复制可变集合,领域聚合则可能要求整棵聚合内部独立;没有边界定义,“深拷贝”这个需求本身就是含糊的。
六、常见误区与追问
- 误区:以为
new ArrayList<>(list)就是深拷贝。 它只复制容器,元素引用仍然共享。 - 误区:以为调用
super.clone()会递归复制字段。 它只做逐字段复制,可变引用字段必须显式处理。 - 误区:给
String、LocalDate等不可变值也创建副本。 共享不可变对象没有修改泄漏,重复复制只增加成本。 - 追问:深拷贝一定要复制所有对象吗? 不一定,关键是复制隔离边界内的可变对象,不可变对象和明确共享的服务对象可以保留引用。
- 追问:有循环引用时如何避免无限递归? 使用
IdentityHashMap一类按对象身份记录“已复制对象”,遇到同一源对象直接复用对应副本。 - 追问:为什么 JSON 往返不总是好方案? 它可能丢失具体子类型、对象身份、瞬态字段或环,并且有序列化性能与安全成本。
处理环和共享节点的核心算法可以写成下面的伪代码。关键步骤是“先登记副本,再递归字段”,否则 A 指向 B、B 又指向 A 时,递归永远回不到已知结果:
Object copy(Object source, IdentityHashMap<Object, Object> seen) {
if (source == null || isImmutable(source)) return source;
if (seen.containsKey(source)) return seen.get(source);
Object target = allocateSameType(source);
seen.put(source, target); // 必须先登记,才能截断环
for (Field field : fieldsOf(source)) {
field.set(target, copy(field.get(source), seen));
}
return target;
}
真实实现还要处理数组、集合、代理、不可访问字段和构造不变量,所以业务代码通常不应自己造一个“万能深拷贝器”,而应为明确的领域边界编写显式复制逻辑。
七、加强记忆
先画对象图,再决定复制边界:赋值只复制根引用,浅拷贝创建新外壳但共享内部节点,深拷贝则为需要隔离的可变节点建立新图,同时保留必要的共享和环。实践中优先把值对象设计为不可变,让它们安全共享;确需独立修改时,用显式拷贝构造器清楚表达哪些字段复制、哪些字段共享。这样既不会漏掉嵌套可变对象,也不会为“看起来更彻底”付出无谓的时间和内存。