代码上线就出事,黑锅总往身上背?那是 Java 里那些“你不知道”的细节在作祟!你确定完全掌握 Java 了吗?那些看似基础的操作背后,可能隐藏着你从未触及的深度。《你不知道的 Java》 专栏,专挖这些隐藏知识点,每日一个知识点,助你写出真正健壮、少出问题的代码,从此告别救火队员,安心下班!
本文全面解析 Java 克隆机制,从原理到实践,助你彻底掌握这一核心技能,涵盖常见问题与最佳实践,建议收藏随时查阅!
在 Java 开发中,我们经常需要创建对象的副本。有时是为了保护原始对象不被修改,有时是为了在多线程环境中安全地传递数据,或者仅仅是需要一个具有相同状态的新起点。Java 提供了 clone()
方法来实现这一目的,但它的机制和使用方式却有不少“坑”和需要注意的细节。本文将带你深入理解浅克隆、深克隆,并探讨各种场景下的最佳实践。
1. 浅克隆 (Shallow Clone)
是什么?
浅克隆是最基本的克隆形式。当你调用一个对象的 clone()
方法(通常是继承自 Object
类并经过适当处理的)进行浅克隆时,会发生以下情况:
- 内存分配: JVM 会为新对象分配一块内存空间,大小与原始对象相同。
- 基本类型字段复制: 原始对象中的所有基本数据类型(
int
,float
,boolean
等)的字段,它们的值会被直接复制到新对象对应的字段中。 - 引用类型字段复制: 原始对象中的所有引用类型(对象、数组)的字段,它们存储的**内存地址(引用)**会被复制到新对象对应的字段中。注意: 这并不复制引用指向的对象本身。
结果就是: 克隆出来的对象和原始对象是两个独立的对象(内存地址不同),但它们内部的引用类型字段指向的是同一批堆内存中的对象。
如何实现?
Java 中实现克隆需要遵循一定的约定:
- 实现
Cloneable
接口: 这是一个标记接口(Marker Interface),本身没有任何方法。它的作用是告诉 JVM 这个类的对象是“可以被克隆”的。如果不实现此接口而直接调用Object
类的clone()
方法,会抛出CloneNotSupportedException
。 - 重写
clone()
方法:Object
类中的clone()
方法是protected
的,所以如果想让其他类能够调用克隆方法,通常需要在你的类中将其重写为public
。最简单的浅克隆实现就是直接调用super.clone()
。
示例代码:
import java.util.Arrays;
class Address { // 一个简单的引用类型
String street;
int number;
public Address(String street, int number) {
this.street = street;
this.number = number;
}
@Override
public String toString() {
return "Address{" + "street='" + street + '\'' + ", number=" + number + '}';
}
}
class Person implements Cloneable {
String name; // String 是引用类型,但通常表现得像值类型(因为不可变)
int age; // 基本类型
Address address; // 引用类型
public Person(String name, int age, Address address) {
this.name = name;
this.age = age;
this.address = address;
}
@Override
public String toString() {
return "Person{" + "name='" + name + '\'' + ", age=" + age + ", address=" + address + '}';
}
// 实现浅克隆
@Override
public Person clone() {
try {
// 调用 Object.clone() 进行浅拷贝
return (Person) super.clone();
} catch (CloneNotSupportedException e) {
// 这理论上不应该发生,因为我们实现了 Cloneable
throw new AssertionError("Cloning failed", e);
}
}
public static void main(String[] args) {
Address addr = new Address("科技路", 1号);
Person person1 = new Person("张三", 30, addr);
System.out.println("Original Person1: " + person1); // 输出原始对象
Person person2 = person1.clone();
System.out.println("Cloned Person2: " + person2); // 输出克隆对象
// 验证基本类型和不可变类型
person2.name = "李四"; // String 是不可变的,这里 person2.name 指向了新的 String 对象
person2.age = 35;
System.out.println("\nAfter modifying cloned person's name and age:");
System.out.println("Original Person1: " + person1); // name 和 age 不受影响
System.out.println("Cloned Person2: " + person2);
// 验证引用类型 (关键点)
person2.address.street = "软件大道"; // 修改克隆对象的 Address
System.out.println("\nAfter modifying cloned person's address street:");
System.out.println("Original Person1: " + person1); // !!! 原对象的 Address 也被修改了 !!!
System.out.println("Cloned Person2: " + person2);
System.out.println("\nAre person1 and person2 the same object? " + (person1 == person2)); // false
System.out.println("Do person1 and person2 share the same address object? " + (person1.address == person2.address)); // true
}
}
浅克隆的风险: 正如示例所示,修改克隆对象中的可变引用类型字段(如 Address
对象),会影响到原始对象,反之亦然。这就是所谓的“副作用”,也是使用浅克隆时最大的陷阱。
2. 深克隆 (Deep Clone)
是什么?
深克隆的目标是创建一个完全独立的副本。这意味着:
- 基本类型字段的值被复制。
- 所有引用类型字段,不仅复制引用本身,还要递归地复制引用所指向的对象,直到所有的引用都指向新创建的对象副本。
结果就是: 克隆对象和原始对象不仅自身是独立的,它们内部引用的所有(可变)对象也都是独立的副本。修改克隆对象的任何部分(包括其引用的对象),都不会影响原始对象。
如何实现?
Java 的 Object.clone()
默认不支持深克隆。实现深克隆通常有以下几种方式:
2.1 手动在 clone()
方法中实现
- 首先调用
super.clone()
进行浅克隆,得到一个基础副本。 - 然后,对副本中的每一个可变引用类型字段,手动调用该字段对象的
clone()
方法(如果它也支持克隆的话),并将返回的新引用赋给副本的相应字段。这需要确保所有需要深克隆的嵌套对象都正确实现了clone()
方法(可能是浅克隆或深克隆,取决于需求)。
// 在 Person 类中修改 clone 方法以实现深克隆
@Override
public Person clone() {
try {
Person clonedPerson = (Person) super.clone(); // 1. 先进行浅克隆
// 2. 对可变的引用类型字段进行单独克隆
if (this.address != null) {
// 假设 Address 也实现了 Cloneable 和 clone()
// 如果 Address 内部还有引用类型,Address 的 clone 方法也需要处理
// 注意:这里假设 Address 的 clone 是足够的(可能是浅克隆或深克隆)
// 如果 Address 需要深克隆,其 clone 方法必须实现深克隆逻辑
clonedPerson.address = (Address) this.address.clone(); // Address 也需要实现 clone()
}
// 如果还有其他引用类型,继续处理...
return clonedPerson;
} catch (CloneNotSupportedException e) {
throw new AssertionError("Cloning failed", e);
}
}
// 注意:Address 类也需要实现 Cloneable 并重写 clone()
class Address implements Cloneable { ...
@Override
public Address clone() {
try {
return (Address) super.clone(); // Address 内部没有引用类型,浅克隆即可
} catch (CloneNotSupportedException e) {
throw new AssertionError();
}
}
}
这种方式比较灵活,但如果对象结构复杂,嵌套层次深,手动实现会非常繁琐且容易出错。
2.2 使用序列化 (Serialization)
- 将原始对象序列化到一个字节流(如
ByteArrayOutputStream
)中。 - 再从这个字节流中反序列化出一个新的对象。
- 这个过程天然地会创建对象图中所有对象(需要实现
Serializable
接口且未标记为transient
)的全新副本。
import java.io.*;
public class SerializationCloner {
@SuppressWarnings("unchecked")
public static <T extends Serializable> T clone(T obj) {
T clonedObj = null;
try {
// 写入字节流
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(baos);
oos.writeObject(obj);
oos.close();
// 读取字节流
ByteArrayInputStream bais = new ByteArrayInputStream(baos.toByteArray());
ObjectInputStream ois = new ObjectInputStream(bais);
clonedObj = (T) ois.readObject();
ois.close();
} catch (IOException | ClassNotFoundException e) {
// 处理异常,例如打印日志或抛出自定义异常
e.printStackTrace(); // 实际项目中应使用日志框架
// 或者可以考虑抛出一个运行时异常
// throw new RuntimeException("Failed to clone object using serialization", e);
}
return clonedObj;
}
// 使用示例 (假设 Person 和 Address 都实现了 Serializable)
public static void main(String[] args) {
// ... 创建 person1 ...
// Person person2 = SerializationCloner.clone(person1);
// ... 验证深克隆效果 ...
}
}
注意:所有需要被深克隆的类(包括 Person 和 Address)都需要实现 java.io.Serializable 接口
优点是实现简单通用,能处理复杂的对象图(包括循环引用,虽然需要小心)。缺点是性能开销相对较大,且要求所有相关类都必须实现 Serializable
接口。transient
关键字标记的字段不会被序列化,因此也不会被克隆。
2.3 使用第三方库
很多库提供了深克隆的工具类,如 Apache Commons Lang 的 SerializationUtils.clone()
(原理类似上面的序列化方式),或者像 Gson、Jackson 这样的 JSON 库,可以通过 对象 -> JSON -> 对象 的方式实现深克隆(但要注意可能存在的类型信息丢失或特定类型处理问题)。后面我们会专门讲一下 Hutool 的实现。
2.4 拷贝构造函数 (Copy Constructor)
- 定义一个构造函数,它接受同一个类的对象作为参数,并在函数体内手动复制所有字段,对于引用类型字段则递归地调用其拷贝构造函数或创建新对象。
- 这通常被认为是比实现
Cloneable
更清晰、更健壮的方式。
class Address {
// ... fields ...
// 拷贝构造函数
public Address(Address other) {
this.street = other.street;
this.number = other.number;
}
// ... other methods ...
}
class Person {
// ... fields ...
// 拷贝构造函数
public Person(Person other) {
this.name = other.name;
this.age = other.age;
// 关键:为引用类型调用其拷贝构造函数
if (other.address != null) {
this.address = new Address(other.address);
} else {
this.address = null;
}
// ... 复制其他字段 ...
}
// ... other methods ...
// 使用示例
// Person person1 = ...;
// Person person2 = new Person(person1); // 使用拷贝构造函数创建副本
}
3. 最佳实践
理解了浅克隆和深克隆后,我们来看看在不同场景下如何选择和应用。
3.1 基本类型克隆
基本类型(int
, float
, char
, boolean
, byte
, short
, long
, double
)在任何克隆操作(无论是 Object.clone()
还是其他方式)中都是值复制。你不需要为它们做任何特殊处理。它们天然就是“深克隆”的。
3.2 数组克隆
数组本身是对象,克隆数组有其特殊性:
-
array.clone()
: 对数组调用clone()
方法会创建一个新数组,但它执行的是浅克隆。- 如果数组元素是基本类型(如
int[]
),那么clone()
会复制所有元素的值,效果等同于深克隆。 - 如果数组元素是引用类型(如
Person[]
),那么clone()
只会复制数组中存储的对象引用,新旧数组将共享同一批元素对象。
- 如果数组元素是基本类型(如
-
深克隆数组: 如果你需要深克隆一个引用类型的数组(即,不仅复制数组结构,还要复制数组中的每个对象元素),你需要:
- 先用
array.clone()
创建一个新的浅克隆数组。 - 然后遍历这个新数组,对每个元素调用其自身的
clone()
方法(假设元素支持克隆且需要深克隆),并将返回的新对象引用存回新数组的相应位置。
- 先用
// 示例:深克隆 Person 数组
Person[] originalArray = { /* ... 初始化 ... */ };
Person[] clonedArray = originalArray.clone(); // 1. 浅克隆数组结构和引用
for (int i = 0; i < clonedArray.length; i++) {
if (originalArray[i] != null) {
// 2. 对每个元素进行克隆(这里假设 Person 的 clone 是深克隆)
clonedArray[i] = originalArray[i].clone();
}
}
// 现在 clonedArray 是一个深克隆副本
- 替代方法:
System.arraycopy()
和Arrays.copyOf()
也是常用的数组复制方法,但它们同样执行的是浅复制元素(即复制引用或基本类型值)。
3.3 列表克隆 (Collections)
Java 集合框架中的类(如 ArrayList
, LinkedList
, HashMap
等)通常也支持克隆:
-
list.clone()
: 对于ArrayList
等实现了Cloneable
的集合,调用clone()
方法同样执行的是浅克隆。它会创建一个新的集合实例(例如,一个新的ArrayList
对象),但集合中的元素仍然是原始集合中元素的引用副本。 -
拷贝构造函数: 大多数集合类都提供了拷贝构造函数,如
new ArrayList<>(originalList)
。这同样创建了一个新的集合实例,并且也是浅复制元素引用。这通常是比调用clone()
更推荐的方式,因为它更符合面向对象的设计原则,且类型安全。 -
深克隆列表: 如果需要深克隆一个包含对象的列表(即,复制列表本身,并复制列表中的每一个对象元素),你需要:
- 创建一个新的空列表实例(例如
new ArrayList<>()
)。 - 遍历原始列表。
- 对原始列表中的每个元素调用其
clone()
方法(假设元素支持克隆且需要深克隆)。 - 将克隆得到的新元素添加到新列表中。
- 创建一个新的空列表实例(例如
// 示例:深克隆 Person 列表
List<Person> originalList = // ... 初始化 ...
List<Person> clonedList = new ArrayList<>(originalList.size()); // 1. 创建新列表
for (Person p : originalList) {
if (p != null) {
// 2. 克隆每个元素并添加到新列表
clonedList.add(p.clone()); // 假设 Person.clone() 是深克隆
} else {
clonedList.add(null);
}
}
// 现在 clonedList 是一个深克隆副本
3.4 引用类型克隆 (General Objects)
处理普通引用类型对象时,选择哪种克隆策略取决于你的具体需求:
-
如果对象是不可变的 (Immutable): 例如
String
,Integer
,BigDecimal
等,或者你自己设计的不可变类。那么浅克隆就足够了,因为共享不可变对象没有任何风险。 -
如果对象是可变的 (Mutable):
- 只需要浅克隆: 明确知道共享内部状态没问题,或者性能要求极高且能妥善管理共享状态。此时,实现
Cloneable
并重写clone()
调用super.clone()
是标准做法,但要警惕其副作用。 - 需要深克隆: 这是更常见也更安全的需求。
- 拷贝构造函数通常是首选,它更清晰、类型安全,不易出错。
- 序列化适用于复杂对象图,或作为一种快速实现方式,但要注意性能和
Serializable
依赖。 - 手动在
clone()
中实现深克隆,适用于对性能有极致要求且对象结构可控的情况,但维护成本高。 - 使用库(如 Hutool, Apache Commons Lang, Jackson/Gson)可以简化深克隆实现。
- 只需要浅克隆: 明确知道共享内部状态没问题,或者性能要求极高且能妥善管理共享状态。此时,实现
-
关于
Cloneable
和Object.clone()
的争议: 很多有经验的开发者(包括 Joshua Bloch 在《Effective Java》中)建议避免使用Cloneable
和Object.clone()
。主要原因包括:Cloneable
是个没有方法的标记接口,违反了接口应该定义行为的原则。Object.clone()
是protected
的,需要子类重写才能公开。clone()
的协变返回类型(Java 5+)虽然有所改善,但其契约(何时浅克隆,何时深克隆)并不明确,依赖于实现者的自觉。- 异常处理 (
CloneNotSupportedException
是受检异常) 比较别扭。 - 构造函数不会被调用,可能绕过一些初始化逻辑。
因此,拷贝构造函数或静态工厂方法 (e.g.,
public static Person newInstance(Person other)
) 通常被认为是更优的替代方案。
3.5 Hutool 工具类如何实现深克隆及其原理
Hutool 是一个优秀的国产 Java 工具库,它提供了便捷的深克隆方法。
-
如何使用: Hutool 的
ObjectUtil
工具类提供了cloneByStream
方法来实现深克隆。import cn.hutool.core.util.ObjectUtil; import java.io.Serializable; // 别忘了你的类需要实现 Serializable // 假设 Person 和 Address 都实现了 Serializable // Person person1 = ...; // Person person2 = ObjectUtil.cloneByStream(person1); // 验证 person2 是否是 person1 的深克隆副本 // System.out.println(person1 != person2); // true // System.out.println(person1.getAddress() != person2.getAddress()); // true (如果 Address 不为 null)
-
原理: Hutool 的
ObjectUtil.cloneByStream()
方法正如其名,其底层原理就是我们前面提到的基于 Java 序列化。它的内部实现大致是这样的:- 创建一个
ByteArrayOutputStream
用于在内存中存储序列化后的字节数据。 - 创建一个
ObjectOutputStream
,关联到上述的字节输出流。 - 调用
ObjectOutputStream.writeObject()
将传入的原始对象obj
写入字节流。这会递归地序列化对象及其引用的所有非transient
、非static
且实现了Serializable
接口的对象。 - 关闭
ObjectOutputStream
。 - 创建一个
ByteArrayInputStream
,使用刚才写入的字节数组作为输入源。 - 创建一个
ObjectInputStream
,关联到上述的字节输入流。 - 调用
ObjectInputStream.readObject()
从字节流中读取并反序列化对象。这会根据字节流中的信息重新构建对象及其整个引用图,创建全新的对象实例。 - 关闭
ObjectInputStream
。 - 返回反序列化得到的新对象。
简单来说,Hutool 就是帮你封装了“序列化到内存 -> 从内存反序列化”这一过程,从而实现了通用的深克隆。
- 创建一个
-
优点: 使用极其简单,一行代码搞定。能处理复杂的对象图。
-
缺点/注意事项:
- 性能: 相比手动克隆或拷贝构造函数,序列化/反序列化的开销通常更大。
Serializable
约束: 所有需要被克隆的类(包括嵌套引用的所有类)都必须实现java.io.Serializable
接口。transient
字段: 被transient
修饰的字段不会参与序列化,因此在克隆后的对象中这些字段会是默认值(对象为null
,基本类型为 0 或false
)。static
字段: 静态字段属于类,不属于对象,不会被序列化和克隆。- 异常处理: 虽然
cloneByStream
内部会处理IOException
和ClassNotFoundException
(通常包装成UtilException
抛出),但使用者需要知道可能发生这些问题。
3.6 选择性字段复制(忽略字段、忽略 Null)
在实际开发中,我们经常遇到的不是严格意义上的“克隆”(创建一个一模一样的副本),而是更灵活的“对象属性复制”或“对象映射”,尤其是在不同层之间传递数据时,比如把前端传来的 DTO (Data Transfer Object) 的数据更新到数据库对应的 PO (Persistent Object) 或 Entity 上。这时候,我们往往不希望复制所有字段。以下是几种常用且推荐的做法:
3.6.1 手动赋值 (Getter/Setter)
最直接但也最“笨”的方法就是手动编写代码,逐个检查并设置字段。
优点:
- 完全的控制权,逻辑最清晰。
- 无需引入任何第三方库。
缺点:
- 代码冗余,极其繁琐,特别是当字段很多时。
- 容易出错(漏掉字段、写错字段名)。
- 违反 DRY (Don’t Repeat Yourself) 原则,维护困难。
3.6.2 使用 Bean 映射工具库 (推荐)
为了解决手动赋值的痛点,社区提供了很多优秀的 Bean 映射(或属性复制)工具库。它们通常使用反射或编译时代码生成技术来简化这个过程。
常用的库:
-
Spring Framework
BeanUtils
:org.springframework.beans.BeanUtils.copyProperties(source, target)
: 这是最常用的方法,但它默认会复制null
值。org.springframework.beans.BeanUtils.copyProperties(source, target, ignoreProperties)
: 可以明确指定要忽略的字段名(字符串数组)。- 如何忽略
null
值? Spring 的BeanUtils
没有直接提供忽略null
的选项。常见的做法是自定义一个工具方法,先获取目标对象的所有属性名,然后遍历源对象的属性,只复制那些值不为null
的属性。或者先进行一次全量拷贝,然后再用一个只包含非null
值的源对象(或 Map)进行覆盖拷贝,但这比较绕。更常见的是结合其他库或自己封装一个copyNonNullProperties
方法。
import org.springframework.beans.BeanUtils; import org.springframework.beans.BeanWrapper; import org.springframework.beans.BeanWrapperImpl; import java.util.HashSet; import java.util.Set; public class BeanCopyUtils { // Spring BeanUtils 忽略指定属性 public static void copyIgnoringFields(Object source, Object target, String... ignoreProperties) { BeanUtils.copyProperties(source, target, ignoreProperties); } // 实现 copyProperties 忽略 null 值 (常见封装) public static void copyNonNullProperties(Object source, Object target) { BeanUtils.copyProperties(source, target, getNullPropertyNames(source)); } // 辅助方法:获取源对象中属性值为 null 的属性名数组 private static String[] getNullPropertyNames(Object source) { final BeanWrapper src = new BeanWrapperImpl(source); java.beans.PropertyDescriptor[] pds = src.getPropertyDescriptors(); Set<String> emptyNames = new HashSet<>(); for (java.beans.PropertyDescriptor pd : pds) { Object srcValue = src.getPropertyValue(pd.getName()); if (srcValue == null) emptyNames.add(pd.getName()); } String[] result = new String[emptyNames.size()]; return emptyNames.toArray(result); } public static void main(String[] args) { UserDto dto = new UserDto(); dto.setUsername("SpringNewName"); // dto.setEmail(null); dto.setAge(32); UserPo po = new UserPo(); po.setId(2L); po.setUsername("SpringOldName"); po.setEmail("spring.old@example.com"); po.setAge(30); System.out.println("Before copy non-null: " + po); copyNonNullProperties(dto, po); // 复制 dto 中非 null 的属性到 po System.out.println("After copy non-null: " + po); // 输出应类似:After copy non-null: UserPo{id=2, username='SpringNewName', email='spring.old@example.com', age=32, createTime=null} UserPo po2 = new UserPo(); // 复制时忽略 id 和 createTime (假设 DTO 里有这些字段也不拷) copyIgnoringFields(dto, po2, "id", "createTime"); System.out.println("After copy ignoring id, createTime: " + po2); // 输出应类似: After copy ignoring id, createTime: UserPo{id=null, username='SpringNewName', email=null, age=32, createTime=null} // 注意这里 null 也被复制了,因为源 DTO 的 email 是 null } }
-
Apache Commons
BeanUtils
/PropertyUtils
:org.apache.commons.beanutils.BeanUtils.copyProperties(dest, orig)
: 注意: Apache Commons BeanUtils 在处理类型转换(尤其是基本类型和包装类、java.util.Date
和java.sql.Date
等)时有一些“坑”,并且会抛出受检异常,现在不太推荐直接使用它进行复杂的拷贝。它默认也会复制null
。org.apache.commons.beanutils.PropertyUtils.copyProperties(dest, orig)
: 这个方法只复制名称和类型匹配的属性,并且不进行类型转换,相对更安全,但也更严格。它同样会复制null
。- 如何忽略
null
或特定字段? 同样需要自己封装逻辑或查找是否有扩展库支持。
-
MapStruct (强力推荐):
- 这是一个编译时代码生成器,通过定义接口和注解,自动生成高性能、类型安全的映射代码。
- 优点: 性能极高(接近手动 getter/setter),类型安全(编译期检查错误),配置灵活强大。
- 如何忽略
null
值? 可以在@BeanMapping
或@Mapping
注解中使用nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE
。 - 如何忽略特定字段? 在
@Mapping
注解中使用target = "propertyName", ignore = true
。
import org.mapstruct.BeanMapping; import org.mapstruct.Mapper; import org.mapstruct.Mapping; import org.mapstruct.MappingTarget; import org.mapstruct.NullValuePropertyMappingStrategy; import org.mapstruct.factory.Mappers; @Mapper public interface UserStructMapper { UserStructMapper INSTANCE = Mappers.getMapper(UserStructMapper.class); // 默认会复制 null @Mapping(target = "id", ignore = true) // 忽略 id 字段 @Mapping(target = "createTime", ignore = true) // 忽略 createTime 字段 void updatePoFromDto(UserDto dto, @MappingTarget UserPo po); // 配置忽略 null 值的更新 @BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE) @Mapping(target = "id", ignore = true) @Mapping(target = "createTime", ignore = true) void updatePoFromDtoIgnoringNulls(UserDto dto, @MappingTarget UserPo po); public static void main(String[] args) { UserDto dto = new UserDto(); dto.setUsername("MapStructNew"); // dto.setEmail(null); dto.setAge(33); UserPo po = new UserPo(); po.setId(3L); po.setUsername("MapStructOld"); po.setEmail("mapstruct.old@example.com"); po.setAge(30); System.out.println("Before MapStruct ignore null: " + po); UserStructMapper.INSTANCE.updatePoFromDtoIgnoringNulls(dto, po); System.out.println("After MapStruct ignore null: " + po); // 输出应类似: After MapStruct ignore null: UserPo{id=3, username='MapStructNew', email='mapstruct.old@example.com', age=33, createTime=null} } } // 注意:使用 MapStruct 需要在项目中添加相应依赖,并配置 Maven 或 Gradle 插件来处理注解生成代码。
-
ModelMapper:
- 另一个流行的库,通过约定和配置进行映射,运行时动态处理。
- 优点: 配置相对简单,智能匹配能力强。
- 缺点: 运行时反射,性能相比 MapStruct 较低。
- 如何忽略
null
? 可以通过配置modelMapper.getConfiguration().setSkipNullEnabled(true)
。 - 如何忽略特定字段? 可以通过
TypeMap
或PropertyMap
进行更精细的映射规则定义。
3.6.3 BeanUtils.copyProperties 底层原理
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-beans</artifactId>
<version>5.3.22</version> <!-- 根据你的项目选择合适的版本 -->
</dependency>
org.springframework.beans.BeanUtils.copyProperties(source, target, ignoreProperties)
是 Spring 框架提供的一个工具方法,用于将一个对象(source
)的属性值复制到另一个对象(target
)中,同时可以指定忽略某些属性。它的原理主要基于 Java 反射机制,核心原理是通过反射动态调用 getter
和 setter
方法,实现属性的 浅拷贝。以下是其工作原理的详细解释:
1、方法签名:
public static void copyProperties(Object source, Object target, String... ignoreProperties)
source
:源对象,属性值从该对象复制。target
:目标对象,属性值复制到该对象。ignoreProperties
:需要忽略的属性名称(可选)。
2、工作原理:
-
获取源对象和目标对象的属性:
- 使用 Java 反射机制,通过
Class.getDeclaredFields()
或Class.getMethods()
获取源对象和目标对象的所有属性。
- 使用 Java 反射机制,通过
-
匹配属性名称和类型:
- 遍历源对象的属性,检查目标对象中是否存在同名且类型兼容的属性。
- 如果属性名称和类型匹配,则进行复制。
-
忽略指定属性:
- 如果
ignoreProperties
参数不为空,则跳过这些属性的复制。
- 如果
-
复制属性值:
- 通过反射调用源对象的
getter
方法获取属性值。 - 通过反射调用目标对象的
setter
方法设置属性值。
- 通过反射调用源对象的
-
处理异常:
- 如果属性复制过程中发生异常(如属性不存在或类型不匹配),会抛出
BeanException
或IllegalArgumentException
。
- 如果属性复制过程中发生异常(如属性不存在或类型不匹配),会抛出
3、源码分析:
以下是 BeanUtils.copyProperties
的核心逻辑(简化版):
public static void copyProperties(Object source, Object target, String... ignoreProperties) throws BeansException {
// 获取源对象和目标对象的 Class 对象
Class<?> sourceClass = source.getClass();
Class<?> targetClass = target.getClass();
// 获取源对象的所有属性
PropertyDescriptor[] sourceDescriptors = getPropertyDescriptors(sourceClass);
PropertyDescriptor[] targetDescriptors = getPropertyDescriptors(targetClass);
// 遍历源对象的属性
for (PropertyDescriptor sourceDescriptor : sourceDescriptors) {
String propertyName = sourceDescriptor.getName();
// 检查是否需要忽略该属性
if (isIgnoredProperty(propertyName, ignoreProperties)) {
continue;
}
// 查找目标对象中对应的属性
PropertyDescriptor targetDescriptor = findPropertyDescriptor(targetDescriptors, propertyName);
if (targetDescriptor != null && isAssignable(sourceDescriptor, targetDescriptor)) {
try {
// 获取源对象的属性值
Object value = sourceDescriptor.getReadMethod().invoke(source);
// 设置目标对象的属性值
targetDescriptor.getWriteMethod().invoke(target, value);
} catch (Exception ex) {
throw new BeansException("Failed to copy property '" + propertyName + "'", ex);
}
}
}
}
4、关键点:
-
反射机制:
- 通过反射动态获取和调用对象的
getter
和setter
方法。
- 通过反射动态获取和调用对象的
-
属性匹配:
- 只复制名称和类型匹配的属性。
-
忽略属性:
- 通过
ignoreProperties
参数指定需要跳过的属性。
- 通过
-
浅拷贝:
BeanUtils.copyProperties
是浅拷贝,对于引用类型的属性,复制的是引用而不是对象本身。
3.6.4 总结与选择
- 对于简单的、一次性的拷贝,或者对外部库有限制时,手动赋值或在目标类中实现更新方法是可行的。
- 对于常规的应用开发,特别是涉及多层架构(Controller/Service/DAO)和频繁的对象映射,强烈推荐使用 Bean 映射工具库。
- MapStruct 因其编译时生成代码带来的高性能和类型安全,是现代 Java 项目中的首选。
- Spring BeanUtils 因其集成在 Spring 生态中,使用方便,对于不那么追求极致性能且能接受其
null
值处理方式(或愿意封装)的场景也很常用。 - ModelMapper 提供了另一种灵活的选择,尤其适合喜欢其约定优于配置风格的开发者。
- 尽量避免使用 Apache Commons BeanUtils,除非是维护旧项目。
选择哪种方式取决于项目的具体需求、团队的技术栈偏好以及对性能、类型安全和开发效率的权衡。但总的来说,利用好现成的工具库通常比自己“造轮子”更高效、更可靠。
4. 总结
Java 克隆是一个涉及对象内存结构和引用机制的核心概念。
- 浅克隆简单快速,但共享引用可能导致意外的副作用,适用于不可变对象或能妥善管理共享状态的场景。
- 深克隆创建完全独立的副本,更安全,但实现更复杂,性能开销更大。
- 实现深克隆有多种方式:手动递归克隆、序列化、拷贝构造函数、第三方库。
- 拷贝构造函数通常被认为是比实现
Cloneable
接口更推荐的实践。 - 数组和集合的
clone()
或拷贝构造函数默认都是浅克隆元素,需要深克隆元素时需手动处理。 - 像 Hutool 这样的工具库提供了基于序列化的便捷深克隆方法,简化了开发,但需注意其性能和
Serializable
依赖。 - POJO 类如何实现拷贝:Spring BeanUtils 的
copyProperties
因其集成在 Spring 生态中,使用方便,对于不那么追求极致性能且能接受其null
值处理方式、忽略某些属性的复制的场景也很常用。
理解这些机制和权衡,能帮助你在实际开发中根据具体需求,选择最合适的对象复制策略,编写出更健壮、更可维护的代码。希望这篇讲解对你有所帮助!