61 months ago

谨慎使用反射机制

简介

有时,作为开发人员会遇到这种情况:没有办法用 new 操作符初始化一个对象,因为对象类的名称保存在某个 XML 配置文件里;又或者需要调用一个方法,但是方法名是在注解中的某个属性指定的。这时,脑袋里的小天使就会告诉你:“用反射呀!”。

在新版的 CUBA 框架中,我们决定对框架的许多方面都做新的改进,其中最重要的一项改进就是弃用了 UI 控制器中“经典的”事件监听器。以前的框架版本中,控制器的 init() 方法中存在着大量的脚手架代码,用来注册各种监听器,这样使得代码的可读性非常差。所以用新的观念审视时,我们发现必须解决这个问题。

可以通过给方法添加注解并存储 java.lang.reflect.Method 实例实现方法监听器,然后像许多框架的实现一样使用反射调用这些方法,但我们看看有没有别的可能。反射调用有一定的开销,如果是开发一个用于生产级别的框架,哪怕是一点点小的改进也能很快看到效果。

本文中,我们探讨一下反射 API,看看其使用上的优缺点,再评估一下其它能替代反射 API 调用的方案 - AOT 和代码生成以及 LambdaMetafactory。

反射 - 经典好用可靠的 API

维基百科称,“反射是计算机程序在运行时进行检查、内省以及修改自身结构和行为的一项能力”。

对于很多Java开发者来说,反射并不陌生,在很多情况下都有用到。我敢说,如果没有反射机制,Java不会有现在的繁荣。比如处理注解、数据序列化、使用注解或者配置文件的方法绑定等等,都用到了反射。对于当下最流行的 IoC 框架来说,反射也是框架的基石,广泛用于类代理,方法引用等。还有,面向切面编程也可以添加到这个列表,因为有些 AOP 框架依赖反射对方法的执行进行拦截。

反射机制有没有问题呢?这里列举三点:

执行速度 - 反射调用比直接调用慢。我们能在每一版 JVM 的发布中看到关于反射 API 性能提升的改进,JIT 编译器的优化算法越来越好,但是反射方法的调用还是比直接调用要慢三倍以上。

类型安全 - 如果在代码中使用了方法引用,那么这只是对方法的引用而已。如果写了代码通过引用来调用方法并传错了参数,这个调用会在运行时失败,而并不是在编译或者加载时。

可追踪性 - 如果一个反射方法调用失败,找到导致问题的代码行可能会很棘手,因为 stack trace 通常很大。需要跟踪到很深地方查许多 invoke() 和 proxy() 调用。

尽管有这三个问题,但是如果查看一下 Spring 中事件监听器的实现或者 Hibernate 中 JPA 的回调,你能发现熟悉的 java.lang.reflect.Method 引用。而且我觉得这个短期内不会被修改,因为成熟的框架又大又复杂,在很多关键任务的系统中都有使用,所以框架的开发者做大的改动时都会非常小心。

我们看看其它的方案。

AOT 编译和代码生成 - 让应用程序再次快起来

替换反射机制的第一个备选就是代码生成。如今,我们能看到类似  Micronaut 和 Quarkus 的新框架崛起,主要有两个目标:快速启动和低内存占用。这两个指标在微服务和 serverless 应用程序时代至关重要。新的框架试图通过使用 AOT 和代码生成来完全摆脱反射机制。通过使用注解处理、type visitor 和其他技术,这些技术将直接方法调用、对象实例等添加到代码中,从而使应用程序更快。那些技术不会在启动时使用 Class.newInstance() 来创建和注入 Bean,不会在监听器中使用反射方法调用。这看起来很有前景,但是这些方法有没有弱点?答案是有。

首先,运行的代码和编写的代码不一致。代码生成会修改原始代码,所以如果有哪里出问题了,第一时间并不能确认是原始代码错误还是代码生成器算法的一个盲点。别忘了,此时即便是调试,也是调试生成的代码而不是原始代码。

其次,必须使用供应商提供的单独工具或插件才能使用该框架,你不能“只是”运行代码。首先应该以特殊的方式先对代码进行预处理。如果在生产环境中使用该框架,那么需要将供应商的 bug fix 应用到框架代码库和代码处理工具上。

尽管代码生成早已为人所知,但还没有出现在 Micronaut 或 Quarkus 中。在 CUBA 中,我们使用自定义的 Grails 插件和 Javassist 库在编译时进行类增强。我们还添加了额外的代码来生成实体更新事件,并且为了 UI 展示的美观,我们将 bean 验证消息作为 String 包含在类代码中。

但是,为事件监听器实现代码生成看起来有些极端了,因为需要完全改变框架的内部体系结构。那么有没有类似反射这样的东西,但是速度比反射更快呢?

LambdaMetafactory - 更快的方法调用

在Java 7中,引入了一个新的JVM指令 - invokedynamic。最初针对基于JVM的动态语言实现,已成为API调用的很好的替代品了。与传统反射相比,此 API 能提供更多的性能改进。在 Java 代码中有一些特殊的类来构建 invokedynamic 调用:

l MethodHandle(方法句柄) - 这个类是在 Java 7 中引入的,但是知道的人还是很少。

l LambdaMetafactory - 在 Java 8 中引入。是动态调用思想的进一步发展。此 API 基于MethodHandle。

方法句柄的 API 是标准反射机制的一个很好替代品,因为 JVM 只会在 MethodHandle 创建期间执行一次所有的调用前检查。简而言之,方法句柄是一个有类型的、可直接执行的引用,其引用对象为方法、构造函数、字段或者类似的低级别操作,并且具有可选的参数转换或返回值。

奇怪的是,与反射 API 相比,纯 MethodHandle 引用调用并没有提供更好的性能,除非按照此电子邮件列表中的说明将MethodHandle 引用设置为 static。

但是 LambdaMetafactory 不同 - 允许我们在运行时生成一个功能接口的实例,其中包含一个方法的引用,该方法通过 MethodHandle 解析。使用这个 lambda 对象,我们可以直接调用引用的方法。示例:

private BiConsumer createVoidHandlerLambda(Object bean, Method method) throws Throwable {
        MethodHandles.Lookup caller = MethodHandles.lookup();
        CallSite site = LambdaMetafactory.metafactory(caller,
                "accept",
                MethodType.methodType(BiConsumer.class),
                MethodType.methodType(void.class, Object.class, Object.class),
                caller.findVirtual(bean.getClass(), method.getName(),
                        MethodType.methodType(void.class, method.getParameterTypes()[0])),
                MethodType.methodType(void.class, bean.getClass(), method.getParameterTypes()[0]));
        MethodHandle factory = site.getTarget();
        BiConsumer listenerMethod = (BiConsumer) factory.invoke();
        return listenerMethod;
    }

需要注意的是,使用这种方法我们可以只需要使用 java.util.function.BiConsumer 替换 java.lang.reflect.Method,因此使用这个方案不需要对现有代码做太多的重构。下面是事件监听器处理程序代码,这是从 Spring Framework 改编的简化版:

public class ApplicationListenerMethodAdapter
        implements GenericApplicationListener {
    private final Method method;
    public void onApplicationEvent(ApplicationEvent event) {
        Object bean = getTargetBean();
        Object result = this.method.invoke(bean, event);
        handleResult(result);
    }
}

看看如何用基于 Lambda 的方法引用来改造:

public class ApplicationListenerLambdaAdapter extends ApplicationListenerMethodAdapter {
    private final BiFunction funHandler;
    public void onApplicationEvent(ApplicationEvent event) {
        Object bean = getTargetBean();
        Object result = handler.apply(bean, event);
        handleResult(result);
    }
}

代码只有细微的改动,功能也是一样的,但是跟传统的反射相比,有一些优势:

l 类型安全 - 需要在 LambdaMetafactory.metafactory 调用中指定方法签名,因此不能简单地将方法绑定为事件监听器。

l 可追踪性 - Lambda 包装器只在 stack trace 中添加了一层额外调用。调试起来更容易。

l 快速执行 - 这个是关键的优势。

基准测试

对于新版本的 CUBA 框架,我们创建了一个基于 JMH 的微基准(microbenchmark)来比较“传统”反射方法调用和基于lambda的方法调用的执行时间和吞吐量的差别,另外,我们添加了直接方法调用,以供比较这三种方式的差异。在测试执行之前,都预先创建了方法引用和lambda 方法,并进行了缓存。

我们使用了如下基准测试的参数:

@BenchmarkMode({Mode.Throughput, Mode.AverageTime})
@Warmup(iterations = 5, time = 1000, timeUnit = TimeUnit.MILLISECONDS)
@Measurement(iterations = 10, time = 1000, timeUnit = TimeUnit.MILLISECONDS)

可以从 GitHub 下载基准测试程序自己试试。

对于 JVM 11.0.2 和 JMH 1.21 我们得出下列结果(每次运行得到的数字可能有细微差别),分别测试读写值的吞吐量(次/微秒)和执行时间(微秒/次):

Test - Get Value Throughput (ops/us) Execution Time (us/op)
LambdaGetTest 72 0.0118
ReflectionGetTest 65 0.0177
DirectMethodGetTest 260 0.0048
Test - Set Value Throughput (ops/us) Execution Time (us/op)
LambdaSetTest 96 0.0092
ReflectionSetTest 58 0.0173
DirectMethodSetTest 415 0.0031

可以看到,基于 lambda 的方法处理器平均快 30%,这里有对基于 lambda 方法调用性能的讨论。LambdaMetafactory 生成的类可以是 inline 的,这样能获得部分性能提升。另一个比反射机制快的原因是因为反射调用每次都要做安全检查。

这个基准测试有一定的局限性,并没有考虑类层级结构、final 方法等。只是测量了方法调用,但是能满足我们论证的目的了。

实施

在 CUBA 中,可以使用 @Subscribe 注解让一个方法“监听”各种 CUBA 特有的应用程序事件。我们内部使用这个新的基于 MethodHandles/LambdaMetafactory 的 API 进行更快速的监听器调用。在第一次调用之后,所有的方法处理都会被缓存。

新的架构使得代码更加干净、更易于管理,特别是复杂的 UI 带有很多事件处理器的情况下。看一个简单的例子,假设需要根据在订单中添加的产品重新计算订单金额。已经有了 calculateAmount() 方法,需要在每次订单中产品集合发生变化时调用。旧版本的 UI 控制器如下:

public class OrderEdit extends AbstractEditor<Order> {
    @Inject
    private CollectionDatasource<OrderLine, UUID> linesDs;
    @Override
    public void init(
            Map<String, Object> params) {
        linesDs.addCollectionChangeListener(e -> calculateAmount());
    }
...
}

新版本的:

public class OrderEdit extends StandardEditor<Order> {
    @Subscribe(id = "linesDc", target = Target.DATA_CONTAINER)
    protected void onOrderLinesDcCollectionChange (CollectionChangeEvent<OrderLine> event) {
            calculateAmount();
    }
...
}

代码更加干净了,而且我们可以去掉神奇的 init() 方法,因为这里总是充满了各个组件的各种事件处理器的创建语句。还有,我们甚至不需要在控制器注入数据容器了,框架会根据组件的 ID 找到它。

结论

尽管最近推出的新一代框架(MicronautQuarkus),比“传统”框架有一些优势,但是由于使用了Spring,仍然有大量基于反射的代码。我们可以看看市场在不久的将来会如何变化,但是现在,Spring 明显还是 Java 应用程序框架中的领导者,因此在相当长的时间内我们还是需要继续处理反射机制 API。

如果考虑在代码中使用反射 API,无论是实现自己的框架还是仅实现应用程序,请考虑另外两个选项 - 代码生成、尤其是 LambdaMetafactory。后者能提高代码执行速度,而与“传统”反射 API 使用相比,不会花费更多开发时间。