深入解析 Java 克隆机制:原理、实现与最佳实践——你不知道的 Java(7)

代码上线就出事,黑锅总往身上背?那是 Java 里那些“你不知道”的细节在作祟!你确定完全掌握 Java 了吗?那些看似基础的操作背后,可能隐藏着你从未触及的深度。《你不知道的 Java》 专栏,专挖这些隐藏知识点,每日一个知识点,助你写出真正健壮、少出问题的代码,从此告别救火队员,安心下班!

本文全面解析 Java 克隆机制,从原理到实践,助你彻底掌握这一核心技能,涵盖常见问题与最佳实践,建议收藏随时查阅!

在 Java 开发中,我们经常需要创建对象的副本。有时是为了保护原始对象不被修改,有时是为了在多线程环境中安全地传递数据,或者仅仅是需要一个具有相同状态的新起点。Java 提供了 clone() 方法来实现这一目的,但它的机制和使用方式却有不少“坑”和需要注意的细节。本文将带你深入理解浅克隆、深克隆,并探讨各种场景下的最佳实践。

1. 浅克隆 (Shallow Clone)

是什么?

浅克隆是最基本的克隆形式。当你调用一个对象的 clone() 方法(通常是继承自 Object 类并经过适当处理的)进行浅克隆时,会发生以下情况:

  1. 内存分配: JVM 会为新对象分配一块内存空间,大小与原始对象相同。
  2. 基本类型字段复制: 原始对象中的所有基本数据类型(int, float, boolean 等)的字段,它们的值会被直接复制到新对象对应的字段中。
  3. 引用类型字段复制: 原始对象中的所有引用类型(对象、数组)的字段,它们存储的**内存地址(引用)**会被复制到新对象对应的字段中。注意: 这并不复制引用指向的对象本身。

结果就是: 克隆出来的对象和原始对象是两个独立的对象(内存地址不同),但它们内部的引用类型字段指向的是同一批堆内存中的对象。

如何实现?

Java 中实现克隆需要遵循一定的约定:

  1. 实现 Cloneable 接口: 这是一个标记接口(Marker Interface),本身没有任何方法。它的作用是告诉 JVM 这个类的对象是“可以被克隆”的。如果不实现此接口而直接调用 Object 类的 clone() 方法,会抛出 CloneNotSupportedException
  2. 重写 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)

是什么?

深克隆的目标是创建一个完全独立的副本。这意味着:

  1. 基本类型字段的值被复制。
  2. 所有引用类型字段,不仅复制引用本身,还要递归地复制引用所指向的对象,直到所有的引用都指向新创建的对象副本。

结果就是: 克隆对象和原始对象不仅自身是独立的,它们内部引用的所有(可变)对象也都是独立的副本。修改克隆对象的任何部分(包括其引用的对象),都不会影响原始对象。

如何实现?

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() 只会复制数组中存储的对象引用,新旧数组将共享同一批元素对象。
  • 深克隆数组: 如果你需要深克隆一个引用类型的数组(即,不仅复制数组结构,还要复制数组中的每个对象元素),你需要:

    1. 先用 array.clone() 创建一个新的浅克隆数组。
    2. 然后遍历这个新数组,对每个元素调用其自身的 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() 更推荐的方式,因为它更符合面向对象的设计原则,且类型安全。

  • 深克隆列表: 如果需要深克隆一个包含对象的列表(即,复制列表本身,并复制列表中的每一个对象元素),你需要:

    1. 创建一个新的空列表实例(例如 new ArrayList<>())。
    2. 遍历原始列表。
    3. 对原始列表中的每个元素调用其 clone() 方法(假设元素支持克隆且需要深克隆)。
    4. 将克隆得到的新元素添加到新列表中。
// 示例:深克隆 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)可以简化深克隆实现。
  • 关于 CloneableObject.clone() 的争议: 很多有经验的开发者(包括 Joshua Bloch 在《Effective Java》中)建议避免使用 CloneableObject.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 序列化。它的内部实现大致是这样的:

    1. 创建一个 ByteArrayOutputStream 用于在内存中存储序列化后的字节数据。
    2. 创建一个 ObjectOutputStream,关联到上述的字节输出流。
    3. 调用 ObjectOutputStream.writeObject() 将传入的原始对象 obj 写入字节流。这会递归地序列化对象及其引用的所有非 transient、非 static 且实现了 Serializable 接口的对象。
    4. 关闭 ObjectOutputStream
    5. 创建一个 ByteArrayInputStream,使用刚才写入的字节数组作为输入源。
    6. 创建一个 ObjectInputStream,关联到上述的字节输入流。
    7. 调用 ObjectInputStream.readObject() 从字节流中读取并反序列化对象。这会根据字节流中的信息重新构建对象及其整个引用图,创建全新的对象实例。
    8. 关闭 ObjectInputStream
    9. 返回反序列化得到的新对象。

    简单来说,Hutool 就是帮你封装了“序列化到内存 -> 从内存反序列化”这一过程,从而实现了通用的深克隆。

  • 优点: 使用极其简单,一行代码搞定。能处理复杂的对象图。

  • 缺点/注意事项:

    • 性能: 相比手动克隆或拷贝构造函数,序列化/反序列化的开销通常更大。
    • Serializable 约束: 所有需要被克隆的类(包括嵌套引用的所有类)都必须实现 java.io.Serializable 接口。
    • transient 字段:transient 修饰的字段不会参与序列化,因此在克隆后的对象中这些字段会是默认值(对象为 null,基本类型为 0 或 false)。
    • static 字段: 静态字段属于类,不属于对象,不会被序列化和克隆。
    • 异常处理: 虽然 cloneByStream 内部会处理 IOExceptionClassNotFoundException(通常包装成 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.Datejava.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)
    • 如何忽略特定字段? 可以通过 TypeMapPropertyMap 进行更精细的映射规则定义。
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 反射机制,核心原理是通过反射动态调用 gettersetter 方法,实现属性的 浅拷贝。以下是其工作原理的详细解释:


1、方法签名:

public static void copyProperties(Object source, Object target, String... ignoreProperties)
  • source:源对象,属性值从该对象复制。
  • target:目标对象,属性值复制到该对象。
  • ignoreProperties:需要忽略的属性名称(可选)。

2、工作原理:

  1. 获取源对象和目标对象的属性

    • 使用 Java 反射机制,通过 Class.getDeclaredFields()Class.getMethods() 获取源对象和目标对象的所有属性。
  2. 匹配属性名称和类型

    • 遍历源对象的属性,检查目标对象中是否存在同名且类型兼容的属性。
    • 如果属性名称和类型匹配,则进行复制。
  3. 忽略指定属性

    • 如果 ignoreProperties 参数不为空,则跳过这些属性的复制。
  4. 复制属性值

    • 通过反射调用源对象的 getter 方法获取属性值。
    • 通过反射调用目标对象的 setter 方法设置属性值。
  5. 处理异常

    • 如果属性复制过程中发生异常(如属性不存在或类型不匹配),会抛出 BeanExceptionIllegalArgumentException

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、关键点:

  1. 反射机制

    • 通过反射动态获取和调用对象的 gettersetter 方法。
  2. 属性匹配

    • 只复制名称和类型匹配的属性。
  3. 忽略属性

    • 通过 ignoreProperties 参数指定需要跳过的属性。
  4. 浅拷贝

    • 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 BeanUtilscopyProperties 因其集成在 Spring 生态中,使用方便,对于不那么追求极致性能且能接受其 null 值处理方式、忽略某些属性的复制的场景也很常用。

理解这些机制和权衡,能帮助你在实际开发中根据具体需求,选择最合适的对象复制策略,编写出更健壮、更可维护的代码。希望这篇讲解对你有所帮助!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值