2562 字
约 8 分钟
18
S04-X06 · 深拷贝与浅拷贝:四种实现与各自的坑

拷贝的难点不在"怎么复制",而在"复制到第几层"。BeanUtils.copyProperties 在扁平 DTO 上是效率工具,在嵌套对象上是事故源头;Object.clone() 在扁平对象上够用,在持有 List 的实体上会留下共享引用。下面按四种实现拆开,各给失效边界。


1. 先定语义:拷贝的边界在哪一层

层级 浅拷贝后 深拷贝后
基本类型字段 值独立 值独立
String 字段 共享同一个不可变对象(安全) 同样共享(不可变,无差别)
可变对象字段(Date、List、自定义类) 共享同一个实例 各自独立实例
数组字段 共享同一个数组 独立数组与独立元素
嵌套集合里的元素 共享元素引用 递归独立
copy.id = src.id;                // 值复制
copy.items = src.items;          // 引用赋值:浅拷贝
copy.createdAt = src.createdAt;  // 引用赋值:浅拷贝
copy.items.add(new Item());      // src.items 也变了

判断标准只有一句:改副本会不会影响原件。会,就是浅;不会,才是深。


2. Object.clone():语义、标记接口与逐层重写

Object.clone() 是 protected native 方法,行为是"按字段逐位复制"——对引用字段只复制引用值,所以它天然是浅拷贝。它还有两个前置条件:类必须实现 Cloneable,否则抛 CloneNotSupportedException;调用方必须能访问到 clone,通常要重写成 public。

规则 说明
Cloneable 是标记接口 里面没有任何方法,只影响 Object.clone() 的检查分支
不实现就抛异常 CloneNotSupportedException 是受检异常
super.clone() 才能拿到正确类型 逐层调用,父类先复制自己那部分字段
引用字段仍共享 想深必须手动再克隆每一层
class Order implements Cloneable {
    String id;
    List<Item> items;

    @Override
    public Order clone() {
        try {
            Order c = (Order) super.clone();      // 第一层:字段逐位复制
            c.items = new ArrayList<>(this.items); // 第二层:容器新建
            for (int i = 0; i < c.items.size(); i++) {
                c.items.set(i, c.items.get(i).clone());  // 第三层:元素也克隆
            }
            return c;
        } catch (CloneNotSupportedException e) {
            throw new AssertionError(e);   // 已实现 Cloneable,不会走到这里
        }
    }
}

关键在顺序:先复制容器,再复制元素。只做 c.items = new ArrayList<>(this.items) 是"半个深拷贝"——容器独立、元素共享,改元素属性依然互相影响。这是 clone 最常见的半成品。


3. 数组的 clone() 是浅拷贝

数组的 clone() 与集合不同:它复制的是数组结构,元素按引用复制。对基本类型数组,等于深拷贝;对对象数组,是浅拷贝。

int[] b = a.clone();     b[0] = 99;         // a[0] 仍是 1:基本类型独立
Item[] y = x.clone();    y[0].name = "b";   // x[0].name 也变 "b":元素共享
Item[][] n = m.clone();  n[0][0].name = "b";// 只复制外层,内层仍共享

二维数组的 clone() 只克隆第一维,这是"看起来像深拷贝"的经典陷阱。要真深,得逐维克隆或序列化。


4. 序列化拷贝:最省心,代价最明确

思路是把对象写进字节流再读回来。JDK 自带 ObjectOutputStream/ObjectInputStream,也可以借 Spring 的 SerializationUtils(内部就是这两个)。

@SuppressWarnings("unchecked")
static <T extends Serializable> T deepCopy(T src) {
    try (ByteArrayOutputStream bos = new ByteArrayOutputStream();
         ObjectOutputStream oos = new ObjectOutputStream(bos)) {
        oos.writeObject(src);
        try (ObjectInputStream ois =
                 new ObjectInputStream(new ByteArrayInputStream(bos.toByteArray()))) {
            return (T) ois.readObject();
        }
    } catch (IOException | ClassNotFoundException e) {
        throw new IllegalStateException("deep copy failed", e);
    }
}
维度 表现
前置条件 整条引用链上的每个类都必须 Serializable
深度 真深拷贝,引用图里所有可达对象都复制
transient 字段 复制后是默认值(null/0),数据会丢
性能 差:反射式读写 + 字节流,比手写慢一到两个数量级
特殊结构 循环引用能正确处理(靠 handle 表)

链路里任一类没实现 Serializable 就抛 NotSerializableException 并指名那个类——这反而是优点:失败得明确,不会悄悄留下共享引用。


5. JSON 序列化拷贝:方便,但会失真

用 Jackson 做"对象 → JSON 字符串 → 对象",本质是文本往返。它不需要 Serializable,但要求字段可被序列化器看见,于是引入四种失真:

失真类型 触发条件 后果
字段丢失 无 getter/setter、字段 transient、被 @JsonIgnore 副本里是默认值
类型丢失 泛型擦除,目标类型写成 Object/裸 Map 拿到 LinkedHashMap 而非实体
时间格式变化 未注册 JavaTimeModule,或自定义了 @JsonFormat 抛 InvalidDefinitionException,或时区偏移
数值精度 long 超过 2^53 后经前端 JS 解析,或转成 double 末位被截断
ObjectMapper om = new ObjectMapper();
Order copy = om.readValue(om.writeValueAsString(src), Order.class);

// 泛型必须显式给类型,否则 List<Item> 变成 List<LinkedHashMap>
List<Item> list = om.readValue(json, new TypeReference<List<Item>>() {});

最后一行不是讲究,是必需。目标类型写成 List.class 时,Jackson 只知道要造 List,元素类型被擦除成 Object,反序列化器只能选 LinkedHashMap——拿到的对象在用 getItems().get(0).getName() 时抛 ClassCastException。这跟泛型擦除是同一件事的另一面。

另外,JSON 往返会跳过构造器(Jackson 直接写字段),字段若依赖构造器里的计算,副本就缺那部分状态。


6. 手动构造拷贝:最可控,也最啰嗦

写拷贝构造器或静态工厂,逐个字段决定"复制值、复制引用、还是递归复制"。它是唯一能精确表达意图的方式:哪些要深、哪些共享、哪些重新生成。

public Order(Order src) {
    this.id = src.id;
    this.createdAt = new Date(src.createdAt.getTime());   // Date 要新建
    this.items = src.items.stream().map(Item::new).toList();  // 元素也新建
}
维度 手动构造 序列化 JSON clone()
可控性 最高 低 中 中
样板代码 多 无 无 中
性能 最好 最差 差 好
跳构造器 不会 会 会 不会
适合场景 核心领域对象的复制 一次性、离线 跨服务传输 扁平结构

代价是维护成本:新增字段漏写进拷贝构造器就是静默 bug,所以这条路上通常要配一个断言"所有字段都被复制"的测试。


7. 不可变对象为什么不需要拷贝

如果对象的所有字段都是 final 且指向不可变类型(String、基本类型、java.time 的值类型、List.of(...) 这类不可变容器),那么它没有可被修改的状态,共享就是最安全的拷贝。

public record Money(long cents, String currency) {}   // record 天然不可变
public Holder(List<String> list) { this.list = List.copyOf(list); }  // 入口拷一次

record 只保证字段引用不可变,不保证被引用对象不可变:Holder(List<String> list) 里的 list 仍可 add,要真不可变得在构造器里做防御性拷贝。

不可变对象把"拷贝成本"从"每次读取"移到"每次构造",这是函数式风格在并发场景占优的根源。


8. 集合与 BeanUtils:两个高频浅拷贝事故

new ArrayList<>(list) 与 list.addAll(other) 都只复制元素引用,不是深拷贝:

List<Item> src = List.of(new Item("a"));
List<Item> copy = new ArrayList<>(src);
copy.get(0).name = "b";        // src.get(0).name 也变成 "b"
copy.add(new Item("c"));       // 这个才是独立的:容器本身已分离

结论:容器分离 ≠ 元素分离,要元素独立只能 map(Item::new) 逐个重建。

BeanUtils.copyProperties 的问题更隐蔽,且有两个版本陷阱:

框架 签名 参数顺序
Spring BeanUtils copyProperties(Object source, Object target) 源在前
Apache Commons BeanUtils copyProperties(Object dest, Object orig) 目标在前

两个同名方法、相反顺序,都是浅拷贝。写反了不报错,只是把目标对象的空字段覆盖到源对象上——现象是"接口返回全 null",排查时往往要花很久。另外,Spring 的实现对同名但类型不兼容的属性会直接跳过(旧版本还会抛 FatalBeanException),嵌套对象只复制引用。

OrderDTO dto = new OrderDTO();
BeanUtils.copyProperties(order, dto);        // Spring:源在前
// dto.getItems() 与 order.getItems() 是同一个 List

9. 排错

现象 原因 处理
改了副本,原件跟着变 引用字段共享 定位是哪一层没复制,按层补
clone 后元素仍联动 只复制了容器,未复制元素 循环里逐个克隆元素
序列化拷贝后字段为 null 字段被 transient 修饰 改用 clone/手动拷贝
JSON 往返得到 LinkedHashMap 目标类型被擦除 用 TypeReference 显式给泛型
BeanUtils 后全部字段为 null 参数顺序写反(Spring vs Apache) 核对框架与参数位置

10. 边界

场景 首选 理由
扁平 DTO(全是基本类型与 String) clone() 或 BeanUtils 没有引用层,浅即深
嵌套实体、需要完全隔离 拷贝构造器 意图可读、性能最好
一次性离线任务 序列化拷贝 写起来最省,性能不重要
跨进程/跨语言 JSON 本来就要序列化
大量小对象高频复制 不可变 + 共享 从根上消灭拷贝

11. 自测

  1. Object.clone() 为什么一定是浅拷贝?它复制引用字段时做了什么?
  2. Cloneable 里没有方法,为什么还必须实现它?
  3. 二维数组 clone() 之后,改 n[0][0] 会影响原数组吗?为什么?
  4. 序列化拷贝为什么会把 transient 字段变成默认值?
  5. JSON 往返得到 LinkedHashMap 而不是实体类,根因在哪一层?
  6. Spring 与 Apache 的 copyProperties 参数顺序分别是什么?写反的现象是什么?
S04-X06 · 深拷贝与浅拷贝:四种实现与各自的坑
https://www.clxhxhhr.top/posts/5664/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。