拷贝的难点不在"怎么复制",而在"复制到第几层"。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. 自测
Object.clone()为什么一定是浅拷贝?它复制引用字段时做了什么?Cloneable里没有方法,为什么还必须实现它?- 二维数组
clone()之后,改n[0][0]会影响原数组吗?为什么? - 序列化拷贝为什么会把
transient字段变成默认值? - JSON 往返得到
LinkedHashMap而不是实体类,根因在哪一层? - Spring 与 Apache 的
copyProperties参数顺序分别是什么?写反的现象是什么?