Skip to content

Java性能优化战术-对象复制

About 1339 wordsAbout 4 min

性能新书

2026-11-5

对象复制

当我们需要克隆一个POJO对象,比如从Caffeine缓存里取出一个对象,为了避免业务操作污染这个缓存对象,需要克隆一份。这种场景有

  • 电商从缓存里取出一个商品对象,或者库存数据等,进行计算。
  • 物联网取出设备的物模型,计算出设备的数字模型。

Java提供了大量的工具来帮助自动完成克隆而不需要手工编写代码。同时手工编写代码主要问题在于当POJO改变的时候,负责克隆此对象的代码未同步修改。如下一个Department对象,需要完成一个复制功能

public class Department {
  Integer id;
  String name;
  List<User> users;
  //忽略getter和 setter方法
}

通过编码实现最高效,如下代码是一个Department的克隆方法:

// CloneUtil
public  Department cloneDepartment(Department source){
    Department target = new Department();
    target.setId(source.getId());
    target.setName(source.getName());
    //复制用户
    List<User> users = target.getUsers();
    if(users!=null){
      List userList = new ArrayList(users.size());
      for(User user:users){
        User targetUser= cloneUser(user);
        userList.add(targetUser);
      }
      target.setUsers(userList);
    }
    return target;
  }

  protected User cloneUser(User source){
    User target = new User();
    target.setId(source.getId());
    target.setName(source.getName());
    target.setPassword(source.getPassword());
    return target;
  }

如上代码问题在于当对象改变的时候,比如,新增一个type属性,这段cloneDepartment必须调整,添加type属性的赋值,很不幸,忘记复制新增属性是手写克隆代码常见现象。

可以通过JDK对象的序列化,反序列化来实现对象的复制,或者使用JSON序列化&反序列化实现复制。但这些复制方法性能非常糟糕,

 public Department cloneByObjectWriter(Department source){
    try {
      //序列化
      ByteArrayOutputStream  bos = new ByteArrayOutputStream();
      ObjectOutputStream os = new ObjectOutputStream(bos);
      os.writeObject(source);
      os.close();
      //反序列化
      ByteArrayInputStream bis = new ByteArrayInputStream(bos.toByteArray());
      ObjectInputStream is = new ObjectInputStream(bis);
      Department department = (Department)is.readObject();
      return department;
    } catch (IOException | ClassNotFoundException e) {
      throw new RuntimeException(e);
    }
  }

可以运行CloneUtilTest方法,会发现采用JDK序列化机制性能太糟糕了

Benchmark                                        Mode  S     Score    Units      
c.i.c.c.CloneUtilTest.testCloneByHardCode        avgt       59.844    ns/op      
c.i.c.c.CloneUtilTest.testCloneByJson            avgt     1476.493    ns/op      
c.i.c.c.CloneUtilTest.testCloneByObjectWriter    avgt    22846.874    ns/op

性能如此糟糕主要还是因为对于对象的任何属性,都要序列化成byte,然后通过byte 反序列化成对象属性,这是一个非常耗时的操作,使用Jackson序列化反序列化也是复制对象的一个办法,然而性能也很糟糕

因此在关键业务中,不要使用Java的序列化机制来实现对象的的拷贝。

Selma 是一款高效的对象复制和属性映射工具,他实现了JSR269,JSR269允许编译时期执行用户的操作,比如生成一个对象复制工具类。要使用selma,引入下面的依赖。

<dependency>
  <groupId>fr.xebia.extras</groupId>
  <artifactId>selma-processor</artifactId>
  <version>1.0</version>
  <scope>provided</scope>
</dependency>

<dependency>
  <groupId>fr.xebia.extras</groupId>
  <artifactId>selma</artifactId>
  <version>1.0</version>
</dependency>

另外一个工具MapStruts也能实现同样的功能

编写一个简单的接口

@Mapper
public interface DepartmentMapper {
  Department clone(Department source);
}

接口添加一个输入参数是Department ,输出也是Department对象的方法,接口类必须用@Mapper注解,这样,在这个代码被编译的时候,会让Selma生成一个DepartmentMapperSelmaGeneratedClass的类,如下

public final class DepartmentMapperSelmaGeneratedClass
    implements DepartmentMapper {

  @Override
  public final Department clone( Department inDepartment) {
           //忽略代码,内容类似CloneUtil.cloneDepartment手写的复制代码
  }

  public final User asUser(User inUser) {
    //忽略代码,内容类似CloneUtil.cloneUser手写的复制代码
  }
}

Selma会生成一个该接口的实现,放到target/generated-sources/annotations 目录下,这是JSR269规范的默认生成目录,IDE能识别此目录为源代码目录。

使用selma,无需知道这个类存在,调用Selma.builder方法,传入我们定义好的接口即可,返回生成的接口实现类

DepartmentMapper mapper = Selma.builder(DepartmentMapper.class).build();

Selma之所以会生成asUser方法,是因为它发现User是一个非Java内置的对象,因此单独生成一个asUser方法用于复制User,这有利于复用这个方法,如果Deparment 除了List属性外,如果还有个属性

private User managaer;

那么,Selma可以复用asUser方法,实现manager属性的复制。

可以在Mapper接口定义自己的复制方法,比如,User对象的复制你需要一定个性化扩展,那么,你需要在定义一个Mapper类,我们在DepartmentMapper接口里定义一个

@Mapper(withCustom = UserCustomMapper.class)
public interface DepartmentMapper {

  Department clone(Department source);

}
class UserCustomMapper{
  public UserCustomMapper(){}
  public User toUser(User source){
    User target = new User();
    target.setId(source.getId());
    target.setAge(source.getAge());
    return target;
  }
}

在Selma生成复制代码的时候,如果碰到User对象需要复制,则先检测有没有自定义的类,如果有,再检测自定义的类的方法里有没有入参是User的,如果有,则会调用此方法作为对象克隆方法。

如果对象出现了循环引用,可以使用withCyclicMappings = true

@Mapper(withCyclicMappings = true)
public interface DepartmentMapper {
  Department clone(Department source);
}

如果不想有些属性被复制,可以使用withIgnoreFields

@Mapper(withIgnoreFields = {"age", "User.password"})
public interface DepartmentMapper {
  Department clone(Department source);
}

也可以使用完全限定名 com.ibeetl.xxxx.Userer.password。

Selma 不仅仅可以作为对象复制工具,也可以作为一个对象到另外一个对象的映射工具,限于篇幅,不再详细介绍。

性能测试表明,Selma具备与手写代码一样的性能

  c.i.c.c.CloneUtilTest.testCloneBySelma           avgt       45.338    ns/op

知行合一