Skip to content

对象映射:MapStruct 与反射方案的 100 倍差距从哪来 ​

八个字段的 DTO 转换,用 JMH 测:MapStruct 2.4 纳秒,手写 setter 7.5 纳秒,BeanUtils.copyProperties 289 纳秒。差距不在「写法优雅程度」,而在属性解析是在编译期完成还是每次调用都做一遍。

本文用 JMH 实测三种映射方式的性能,并讨论各自适合的场景(JDK 21、MapStruct 1.6.3、Spring 7.0.9),结果见文末配套实验。

一、先说结论 ​

  • 运行时反射的代价在于每次都要解析属性:查找 getter/setter、类型匹配、Method.invoke 的装箱与访问检查。
  • 编译期生成的方案没有这些开销:MapStruct 在编译时生成一个普通的 Java 类,里面就是一行行 dto.setX(entity.getX())。
  • 实测差距约 100 倍(2.4ns vs 289ns),但换算到单次请求通常可以忽略——除非映射发生在每个请求几千次的循环里。
  • 选型的主要依据不是性能,而是「错误在什么时候暴露」:字段改名后,MapStruct 编译报错,反射方案在运行时静默丢字段。
  • 手写 setter 并不比 MapStruct 快:两者生成的代码几乎一样,都在个位数纳秒;手写最容易在新增字段时漏掉。

二、实测 ​

映射对象有 8 个字段(Long、String × 4、Integer、LocalDate):

MapStruct注解处理器生成 Impl 类2.4 ns手写 setter与生成代码几乎相同7.5 nsBeanUtils 反射运行时解析属性描述符289 ns每次映射都分配 48 字节;反射方案慢在属性匹配与 Method.invoke,而不是对象分配
图 1 · 8 个字段的对象映射(JMH):MapStruct 在编译期生成 setter 代码,比反射快两个数量级;手写 setter 与 MapStruct 都在个位数纳秒,谁快谁慢取决于 JIT 的编译细节
方式吞吐单次耗时
手写 setter约 1.33 亿次/秒7.54 ± 0.08ns
MapStruct约 4.09 亿次/秒2.44 ± 0.14ns
BeanUtils.copyProperties(反射)约 346 万次/秒289.04 ± 2.30ns

测量用 JMH(1 个 fork,预热 3 秒,测量 5 秒),耗时后面是 99.9% 置信区间。手写反而比 MapStruct 慢,这不符合直觉:两者每次都分配 48 字节,MapStruct 生成的代码和手写几乎一模一样(见下一节)。把手写版本改成先把源对象读进局部变量,仍然是 7.5ns。差异来自 JIT 的编译细节,这里没有深究。能确定的是,两者都在个位数纳秒,谁快谁慢在真实业务里没有意义。

反射方案慢在哪:每次调用都要拿到源对象和目标对象的属性描述符(Spring 有缓存)、逐个匹配名称与类型、再通过 Method.invoke 调用。虽然有缓存,仍然逃不掉反射调用本身的开销和类型检查。

三、MapStruct 做了什么 ​

MapStruct 是一个注解处理器,在编译期生成实现类:

java
@Mapper
public interface UserMapper {
    UserMapper INSTANCE = Mappers.getMapper(UserMapper.class);
    UserDto toDto(UserEntity entity);
}

编译后可以在 target/generated-sources/annotations 里看到生成的类 UserMapperImpl:

java
public class UserMapperImpl implements UserMapper {
    @Override
    public UserDto toDto(UserEntity entity) {
        if (entity == null) return null;
        UserDto userDto = new UserDto();
        userDto.setId(entity.getId());
        userDto.setName(entity.getName());
        // ... 逐个字段赋值
        return userDto;
    }
}

这也解释了它的两个特点:运行时零反射,以及映射错误在编译期就报出来——源对象缺少目标字段时,编译会给出警告或错误(取决于 unmappedTargetPolicy 配置)。实测目标对象多一个 nickname 字段、策略为 ERROR 时,编译报 Unmapped target property: "nickname". 并失败。

配置要点:

xml
<plugin>
  <artifactId>maven-compiler-plugin</artifactId>
  <configuration>
    <annotationProcessorPaths>
      <path><groupId>org.mapstruct</groupId><artifactId>mapstruct-processor</artifactId><version>1.6.3</version></path>
      <!-- 与 Lombok 一起用时,顺序很重要:lombok → lombok-mapstruct-binding → mapstruct-processor -->
    </annotationProcessorPaths>
    <compilerArgs>
      <arg>-Amapstruct.unmappedTargetPolicy=ERROR</arg>   <!-- 字段没映射上直接编译失败 -->
    </compilerArgs>
  </configuration>
</plugin>

把 unmappedTargetPolicy 设成 ERROR 是这套方案最大的价值:新增字段却忘了配置映射时,构建直接失败,而不是上线后发现某个字段永远是 null。

四、各方案的适用场景 ​

方案适合主要风险
手写 setter字段少、映射逻辑特殊、性能极敏感新增字段容易漏;代码冗长
MapStruct大多数 DTO / Entity / VO 转换需要注解处理器;与 Lombok 的顺序问题
BeanUtils、ModelMapper 等反射方案临时脚本、字段完全同名的简单场景字段改名静默失效;性能最差
Java record + 构造器不可变数据传递字段多时构造调用冗长

需要注意,Spring 的 BeanUtils.copyProperties 还有两个容易踩的点:只按名称匹配(类型不兼容时直接跳过,不报错),以及浅拷贝(引用类型共享同一个对象)。

五、映射层的设计建议 ​

  • 不要把映射写进业务方法里:单独的 Mapper 接口或类,业务代码只调用一次转换。
  • 别让映射承担业务逻辑:需要计算、判断的字段,用 @AfterMapping 或在领域层处理,而不是塞进表达式。
  • 区分层与层之间的模型:Entity(持久化)、Domain(业务)、DTO(接口契约)各自独立,映射就是它们之间的边界。省掉中间层短期省事,长期会让持久化结构泄漏到接口上。
  • 给映射写测试:哪怕只有一个「所有字段都不为空」的断言,也能挡住漏字段。
  • 集合映射注意大小:List<Entity> 转 List<Dto> 时,几万条数据下即使是几纳秒的单次开销也会累积,更要关注的是这次查询本身是否合理。

六、常见误区 ​

  • 「反射慢 100 倍,所以必须换掉」:先算绝对值。单次请求映射 10 个对象,反射方案多花 3 微秒。
  • 「MapStruct 是运行时框架」:它是注解处理器,运行时只有普通的 Java 类。
  • 「copyProperties 复制失败会报错」:类型不匹配时静默跳过。
  • 「用了 Lombok 就不用管处理器顺序」:顺序错了会导致 MapStruct 找不到 getter/setter,报「找不到属性」的编译错误。

小结 ​

对象映射的选型,性能只是次要因素,「错误什么时候暴露」才是关键:编译期生成的方案把字段遗漏变成构建失败,运行时反射方案把它变成线上数据缺失。如果项目已经在用 Lombok 和注解处理器,MapStruct 几乎没有额外成本;如果只是脚本或原型,反射方案够用。


配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权