1. 前言

一直以来听说反射的性能比较拉胯,至于有多拉胯,自己也没有一个概念,于是抱着学习的心态,使用JMH这个工具对看到的一段使用多次反射代码与普通的new对象并set处理同一业务逻辑进行性能对比,结果还是有一点出乎意料的。

1.1 反射的代码

//代码主要目的是通过反射将一个对象A上某个成员变量的值b取到,然后从给定集合里拿到属性为b的对象并取出第一个,再通过反射将获取到的对象赋值到对象C的同名属性上
public static <T, A, S> void setListMapperOneByColumnLambdaValues(@NonNull List<T> targetList, @NonNull LambdaColumn<A, ?> anyColumn,
                                                                      @NonNull Object result, @NonNull Object source, @NonNull LambdaColumn<S, ?>... sourceColumn) {
    	//通过lambda方式获取属性名,需要用一次反射
        String anyColumnName = getClassFieldNameLambda(anyColumn);
    	//通过lambda方式获取属性名,需要用一次反射
        List<String> sourceColumnNames = getClassFieldNameLambdas(sourceColumn);
        if (sourceColumnNames != null) {
            for (String sourceColumnName : sourceColumnNames) {
                //获取属性值,一次反射(注:Hutool的ReflectUtil没有任何缓存)
                Object sourceValue = ReflectUtil.getFieldValue(source, sourceColumnName);
                //赋值,一次反射
                ReflectUtil.setFieldValue(result, sourceColumnName, targetList.stream().filter(item -> sourceValue.equals(ReflectUtil.getFieldValue(item, anyColumnName)) ||
                        String.valueOf(sourceValue).equals(String.valueOf(ReflectUtil.getFieldValue(item, anyColumnName)))
                ).findFirst().orElse(null));
            }
        }
    }
    public static <T> String getClassFieldNameLambda(LambdaColumn<T, ?> lambdaColumn) {
        return Optional.ofNullable(getClassFieldMethodNameLambda(lambdaColumn))
                .map(fieldMethodName -> StrUtil.lowerFirst(fieldMethodName.replace("get", "")))
                .orElse(null);
    }
public static <T> String getClassFieldMethodNameLambda(LambdaColumn<T, ?> lambdaColumn) {
        Method method = lambdaColumn.getClass().getDeclaredMethod("writeReplace");
        if (method != null) {
            method.setAccessible(Boolean.TRUE);
            SerializedLambda serializedLambda = (SerializedLambda) method.invoke(lambdaColumn);
            return serializedLambda.getImplMethodName();
        } else {
            return null;
        }
    }

2. 准备工作

2.1 要在项目里引包

	<!-- JMH依赖-->
  <dependency>
      <groupId>org.openjdk.jmh</groupId>
      <artifactId>jmh-core</artifactId>
      <version>1.21</version>
  </dependency>
  <dependency>
      <groupId>org.openjdk.jmh</groupId>
       <artifactId>jmh-generator-annprocess</artifactId>
       <version>1.21</version>
       <scope>compile</scope>
  </dependency>


<!-- JMH插件-->
<plugin>
    <artifactId>maven-compiler-plugin</artifactId>
    <version>3.8.1</version>
    <configuration>
        <source>1.8</source>
        <target>1.8</target>
        <annotationProcessorPaths>
            <!-- JMH需要引入注解处理器,不然会报错,JMH会根据注解自动生成测试,在target/generated-test-sources文件夹下-->
            <path>
                <groupId>org.openjdk.jmh</groupId>
                <artifactId>jmh-generator-annprocess</artifactId>
                <version>1.21</version>
            </path>
            <path>
                <groupId>org.mapstruct</groupId>
                <artifactId>mapstruct-processor</artifactId>
                <version>${org.mapstruct.version}</version>
            </path>
            <path>
                <groupId>org.projectlombok</groupId>
                <artifactId>lombok</artifactId>
                <version>${org.projectlombok.version}</version>
            </path>
        </annotationProcessorPaths>
    </configuration>
</plugin>

2.2 安装idea插件

image-20211202172407984

2.3 创建测试类

/**
 * 可以直接通过main方法跑代码块测试,这种情况下放在项目哪里都可以,如果要集成maven,springboot还待进一步探究
 */
@BenchmarkMode(Mode.AverageTime) // 测试方法平均执行时间
@OutputTimeUnit(TimeUnit.MILLISECONDS) // 输出结果的时间粒度为微秒
@State(Scope.Thread)
public class JMHSpringBootTest {

    private static Set<LocOut> locSet = new HashSet<>(2000);

    private static List<LocOut> locAll = new ArrayList<>(2000);

    private static List<RoomDO> roomDOS = new ArrayList<>(2000);

    private static List<RoomOut> roomOuts = new ArrayList<>(2000);

    public static void main(String[] args) throws RunnerException {
    	//warmupIterations 热身轮次,一般用于预热数据
    	//measurementIterations 预热完数据后进行测试循环的轮次
    	//如果 fork 数是2的话,则 JMH 会 fork 出两个进程来进行测试。
        Options options = new OptionsBuilder().include(JMHSpringBootTest.class.getSimpleName())
                .warmupIterations(1).measurementIterations(5).forks(1).build();
        new Runner(options).run();
    }

    @Setup(Level.Trial)//测试级别 执行一次 用于初始化数据
    public void init(){
        //业务逻辑主要目的是将RoomDO成员变量的long值RoomId等取到,然后从给定集合里拿到id为RoomDO中的RoomId的locOut对象并取出第一个,再将获取到的对象赋值到RoomOut的RoomId等属性上
        AtomicLong atomicLong = new AtomicLong(1);
        //准备1000个LocOut和50个RoomDO
        for(int i =0;i<=1000;i++){
            LocOut locOut = new LocOut();
            locOut.setId(atomicLong.getAndIncrement());
            locOut.setName("123");
            locOut.setLevel(1);
            locSet.add(locOut);
        }
        for(int i =0;i<=50;i++){
            RoomDO roomDO = new RoomDO();
            roomDO.setRoomId(new Random().nextInt(100));
            roomDO.setBuildingId(new Random().nextInt(100));
            roomDO.setParkId(new Random().nextInt(100));
            roomDO.setCityId(new Random().nextInt(100));
            roomDOS.add(roomDO);
        }
        locAll = new ArrayList<>(locSet);
    }

    @Benchmark //表示该方法是需要进行 benchmark 的对象,用法和 JUnit 的 @Test 类似。如果装了插件,idea在方法左边会出现绿色箭头
    @Threads(1) //每个进程中的测试线程,这个非常好理解,根据具体情况选择,一般为cpu乘以2。
    public static void testSet(){
        //此处为new对象set的代码,没有用到反射
        Map<Long, LocOut> locMap = locAll.stream().collect(Collectors.toMap(LocOut::getId, Function.identity()));
        roomOuts = roomDOS.stream().map(roomDO -> {
            RoomOut out = new RoomOut();
            out.setRoomId(locMap.get(roomDO.getRoomId().longValue()));
            out.setBuildingId(locMap.get(roomDO.getBuildingId().longValue()));
            out.setParkId(locMap.get(roomDO.getParkId().longValue()));
            out.setCityId(locMap.get(roomDO.getCityId().longValue()));
            return out;
        }).collect(Collectors.toList());
    }

    @Benchmark
    @Threads(1)
    public static void testReflect(){
        //此处使用了1.1中的反射代码,两个测试获得的结果完全相同
        RoomOut out;
        for (RoomDO roomDO : roomDOS) {
            out = RoomConvert.INSTANCE.convert(roomDO);
            setListMapperOneByColumnLambdaValues(locAll, LocOut::getId, out, roomDO,
                    RoomDO::getRoomId,
                    RoomDO::getBuildingId,
                    RoomDO::getParkId,
                    RoomDO::getCityId
            );
            roomOuts.add(out);
        }
    }
}

3.运行测试

结果如下:

# Warmup: 1 iterations, 10 s each
# Measurement: 5 iterations, 10 s each
# Timeout: 10 min per iteration
# Threads: 1 thread, will synchronize iterations
# Benchmark mode: Average time, time/op
# Benchmark: testReflect

# Run progress: 0.00% complete, ETA 00:02:00
# Fork: 1 of 1
# Warmup Iteration   1: 15.583 ms/op
Iteration   1: 15.272 ms/op
Iteration   2: 15.299 ms/op
Iteration   3: 15.282 ms/op
Iteration   4: 15.286 ms/op
Iteration   5: 15.879 ms/op

Result "testReflect":
  15.404 ±(99.9%) 1.025 ms/op [Average]
  (min, avg, max) = (15.272, 15.404, 15.879), stdev = 0.266
  CI (99.9%): [14.379, 16.428] (assumes normal distribution)

# Warmup: 1 iterations, 10 s each
# Measurement: 5 iterations, 10 s each
# Timeout: 10 min per iteration
# Threads: 1 thread, will synchronize iterations
# Benchmark mode: Average time, time/op
# Benchmark: testSet

# Run progress: 50.00% complete, ETA 00:01:12
# Fork: 1 of 1
# Warmup Iteration   1: 0.019 ms/op
Iteration   1: 0.019 ms/op
Iteration   2: 0.019 ms/op
Iteration   3: 0.020 ms/op
Iteration   4: 0.021 ms/op
Iteration   5: 0.019 ms/op

Result "testSet":
  0.019 ±(99.9%) 0.004 ms/op [Average]
  (min, avg, max) = (0.019, 0.019, 0.021), stdev = 0.001
  CI (99.9%): [0.015, 0.023] (assumes normal distribution)


# Run complete. Total time: 00:02:24

Benchmark                      Mode  Cnt   Score   Error  Units
JMHSpringBootTest.testReflect  avgt    5  15.404 ± 1.025  ms/op
JMHSpringBootTest.testSet      avgt    5   0.019 ± 0.004  ms/op

简单讲一下指标含义:

Mode:基准测试类型。这里选择的是Throughput也就是吞吐量。根据源码点进去,每种类型后面都有对应的解释,比较好理解,吞吐量会得到单位时间内可以进行的操作数。

  • Throughput: 整体吞吐量,例如”1秒内可以执行多少次调用”。
  • AverageTime: 调用的平均时间,例如”每次调用平均耗时xxx毫秒”。
  • SampleTime: 随机取样,最后输出取样结果的分布,例如”99%的调用在xxx毫秒以内,99.99%的调用在xxx毫秒以内”
  • SingleShotTime: 以上模式都是默认一次 iteration 是 1s,唯有 SingleShotTime 是只运行一次。往往同时把 warmup 次数设为0,用于测试冷启动时的性能。
  • All(“all”, “All benchmark modes”);

此处采用的是AverageTime。

Cnt:测试循环的轮次,本次测试为5次。

Score:测试结果,以后面的Units为单位,可以看到反射的处理成绩为每次操作15.404毫秒,而new对象set的处理结果为0.019毫秒,性能差距约为810倍。

Error:误差,可见即使以反射最快和set最慢的结果进行对比,性能差距仍然十分巨大。

因此,反射性能差的事实可以在测试中得以体现,这也是我们选择用mapstruct进行对象拷贝操作的一个原因。

4.总结

JMH可以比较好地帮我们对比一些代码中的不同写法带来的性能差距,如果对于性能的差距只是听说而没有自己进行实测的话可以尝试使用JMH进行对比,例如String拼接和Stringbuilder,for循环和stream的foreach等等都可以尝试使用JMH对比性能差距。未来会尝试去探究一下JMH与Springboot的结合,可以让我们更方便地发现代码中的坏味道。