Статьи

Подумайте дважды, прежде чем использовать отражение

Вступление

Иногда, как разработчик, вы можете столкнуться с ситуацией, когда невозможно создать экземпляр объекта с помощью оператора new потому что его имя класса хранится где-то в XML-файле конфигурации, или вам нужно вызвать метод, имя которого указано в качестве свойства аннотации. В таких случаях у вас всегда есть ответ: «Используйте рефлексию!».

В новой версии инфраструктуры CUBA мы решили улучшить многие аспекты архитектуры, и одним из наиболее значительных изменений стало устаревшее «классическое» прослушивание событий в пользовательском интерфейсе контроллеров. В предыдущей версии фреймворка большое количество шаблонного кода, регистрирующего слушателей в методе screen init() экрана, делало ваш код практически нечитаемым, поэтому новая концепция должна была это исправить.

Вы всегда можете реализовать прослушиватель методов, сохраняя экземпляры java.lang.reflect.Method для аннотированных методов и вызывая их, как это реализовано во многих средах, но мы решили взглянуть на другие варианты. Звонки Reflection имеют свою стоимость, и если вы разрабатываете среду производственного класса, даже незначительные улучшения могут окупиться за короткое время.

В этой статье мы рассмотрим API-интерфейсы отражения, за и против его использования и рассмотрим другие варианты замены вызовов API-отражений — AOT и генерация кода и LambdaMetafactory.

Отражение — старый добрый надежный API

«Отражение — это способность компьютерной программы исследовать, анализировать и изменять свою собственную структуру и поведение во время выполнения», согласно Википедии.

Для большинства разработчиков Java отражение не является чем-то новым и используется во многих случаях. Я бы осмелился сказать, что Java не станет тем, чем она является сейчас, без размышлений. Подумайте только об обработке аннотаций, сериализации данных, привязке методов с помощью аннотаций или файлов конфигурации … Для наиболее популярных IoC-структур API-интерфейс является краеугольным камнем из-за широкого использования прокси-классов, использования ссылок на методы и т. Д. Кроме того, вы можете добавить аспектно-ориентированные программирование в этом списке — некоторые платформы AOP полагаются на отражение для перехвата выполнения метода.

Есть ли проблемы с отражением? Мы можем думать о трех из них:

Скорость — отражение вызовов медленнее, чем прямые вызовы. Мы можем видеть значительное улучшение производительности API отражений с каждым выпуском JVM, алгоритмы оптимизации компилятора JIT становятся лучше, но вызовы отражающих методов все еще примерно в три раза медленнее, чем прямые.

Безопасность типов — если вы используете ссылку на метод в своем коде, это просто ссылка на метод. Если вы напишите код, который вызывает метод по его ссылке и передает неправильные параметры, вызов завершится неудачей во время выполнения, а не во время компиляции или загрузки.

Трассируемость — в случае сбоя рефлексивного вызова метода может быть сложно найти строку кода, вызвавшую это, поскольку трассировка стека обычно огромна. Вы должны глубоко погрузиться во все эти вызовы invoke() и proxy() .

Но если вы посмотрите на реализации прослушивателя событий в Spring или JPA в Hibernate, вы увидите знакомые ссылки на java.lang.reflect.Method . И я сомневаюсь, что это изменится в ближайшем будущем — зрелые фреймворки большие и сложные, используются во многих критически важных системах, поэтому разработчикам следует осторожно вносить большие изменения.

Давайте посмотрим на другие варианты.

Компиляция AOT и генерация кода — снова делайте приложения быстрыми

Первый кандидат на замену отражения — генерация кода. В настоящее время мы можем наблюдать рост новых фреймворков, таких как Micronaut и Quarkus , которые нацелены на две цели: быстрое время запуска и низкий объем памяти. Эти две метрики имеют жизненно важное значение в эпоху микросервисов и серверных приложений. А последние фреймворки пытаются полностью избавиться от рефлексии, используя заблаговременную компиляцию и генерацию кода. Используя обработку аннотаций, ввод посетителей и другие методы, они добавляют прямые вызовы методов, экземпляры объектов и т. Д. В ваш код, что делает приложения быстрее. Они не создают и не вводят bean-компоненты во время запуска с использованием Class.newInstance() , не используют отражающие вызовы методов в слушателях и т. Д. Выглядит очень многообещающе, но есть ли здесь какие-то компромиссы? И ответ — да.

Первый — вы запускаете код, который не совсем ваш. Генерация кода изменяет ваш исходный код, поэтому, если что-то пойдет не так, вы не сможете определить, является ли это вашей ошибкой или это сбой в алгоритмах обработки кода. И не забывайте, что теперь вы должны отлаживать сгенерированный код, но не ваш код.

Второй компромисс — вы должны использовать отдельный инструмент / плагин, предоставленный поставщиком, чтобы использовать платформу. Вы не можете «просто» запустить код, вы должны предварительно обработать его особым образом. И если вы используете инфраструктуру в работе, вы должны применить исправления поставщика как к базе кода платформы, так и к инструменту обработки кода.

Генерация кода была известна давно, она не появилась с Micronaut или Quarkus . Например, в CUBA мы используем улучшение классов во время компиляции, используя пользовательский плагин Grails и библиотеку Javassist . Мы добавляем дополнительный код для генерации событий обновления сущности и включаем сообщения проверки компонентов в код класса в виде полей String для удобного представления пользовательского интерфейса.

Но реализация генерации кода для слушателей событий выглядела несколько экстремально, потому что это потребовало бы полного изменения внутренней архитектуры. Есть ли такая вещь, как отражение, но быстрее?

LambdaMetafactory — более быстрый вызов метода

В Java 7 была введена новая инструкция JVM — invokedynamic . Изначально нацеленный на реализации динамических языков на основе JVM, он стал хорошей заменой вызовам API. Этот API может дать нам улучшение производительности по сравнению с традиционным отражением. И есть специальные классы для создания вызовов invokedynamic в вашем коде Java:

  • MethodHandle — этот класс был представлен в Java 7, но до сих пор не известен.
  • LambdaMetafactory — был представлен в Java 8. Это дальнейшее развитие идеи динамического вызова. Этот API основан на MethodHandle.

API дескрипторов метода — хорошая замена стандартному отражению, потому что JVM выполнит все проверки перед вызовом только один раз — во MethodHandle создания MethodHandle . Короче говоря, дескриптор метода — это типизированная, непосредственно исполняемая ссылка на базовый метод, конструктор, поле или аналогичную низкоуровневую операцию с необязательными преобразованиями аргументов или возвращаемых значений.

Удивительно, но чистый вызов ссылки на MethodHandle не обеспечивает лучшей производительности по сравнению с API отражения, если вы не сделаете ссылки на MethodHandle статическими, как обсуждалось в этом списке адресов электронной почты .

Но LambdaMetafactory — это другая история — он позволяет нам генерировать экземпляр функционального интерфейса во время выполнения, который содержит ссылку на метод, разрешенный MethodHandle . Используя этот лямбда-объект, мы можем напрямую вызывать ссылочный метод. Вот пример:

01
02
03
04
05
06
07
08
09
10
11
12
13
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:

1
2
3
4
5
6
7
8
9
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);
    }
}

И вот как это можно изменить с помощью ссылки на метод на основе лямбды:

1
2
3
4
5
6
7
8
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);
    }
}

В коде есть небольшие изменения и функциональность одинакова. Но у этого есть некоторые преимущества перед традиционным размышлением:

Безопасность типов — вы указываете сигнатуру метода в вызове LambdaMetafactory.metafactory , поэтому вы не сможете привязать «просто» методы как слушатели событий.

Traceability — лямбда-оболочка добавляет только один дополнительный вызов к трассировке стека вызовов методов. Это делает отладку намного проще.

Скорость — это вещь, которую нужно измерять.

Бенчмаркинг

Для новой версии инфраструктуры CUBA мы создали микробенч на основе JMH, чтобы сравнить время выполнения и пропускную способность для «традиционного» вызова метода отражения, лямбда-метода, и добавили прямые вызовы методов только для сравнения. Обе ссылки на методы и лямбда-выражения были созданы и кэшированы перед выполнением теста.

Мы использовали следующие параметры тестирования:

1
2
3
@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 — получить ценность Пропускная способность (ops / us) Время выполнения (us / op)
LambdaGetTest 72 0,0118
ReflectionGetTest 65 0,0177
DirectMethodGetTest 260 0,0048
Тест — Установить значение Пропускная способность (ops / us) Время выполнения (us / op)
LambdaSetTest 96 0,0092
ReflectionSetTest 58 0,0173
DirectMethodSetTest 415 0,0031

Как видите, обработчики методов на основе лямбды в среднем работают примерно на 30% быстрее. Здесь хорошо обсуждается производительность вызовов методов на основе лямбды. Результат — классы, сгенерированные LambdaMetafactory, могут быть встроены, улучшая производительность. И это быстрее, чем отражение, потому что отражающие вызовы должны были проходить проверки безопасности при каждом вызове.

Этот тест довольно анемичен и не учитывает иерархию классов, конечные методы и т. Д. Он измеряет «просто» вызовы методов, но этого было достаточно для нашей цели.

Реализация

В CUBA вы можете использовать аннотацию @Subscribe чтобы метод «прослушивал» различные специфичные для CUBA события приложения. Внутренне мы используем этот новый API на основе MethodHandles / LambdaMetafactory для более быстрого вызова слушателя. Все дескрипторы метода кэшируются после первого вызова.

Новая архитектура сделала код чище и более управляемым, особенно в случае сложного пользовательского интерфейса с большим количеством обработчиков событий. Просто взгляните на простой пример. Предположим, что вам нужно пересчитать сумму заказа на основе продуктов, добавленных к этому заказу. У вас есть метод calculateAmount() и вам нужно вызвать его, как только будет изменен набор товаров в заказе. Вот старая версия контроллера UI:

01
02
03
04
05
06
07
08
09
10
public class OrderEdit extends AbstractEditor<Order> {
    @Inject
    private CollectionDatasource<OrderLine, UUID> linesDs;
    @Override
    public void init(
            Map<String, Object> params) {
        linesDs.addCollectionChangeListener(e -> calculateAmount());
    }
...
}

А вот как это выглядит в новой версии:

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

Код стал чище, и мы смогли избавиться от «волшебного» метода init() который обычно наполняется операторами создания обработчиков событий. И нам даже не нужно вводить компонент данных в контроллер — фреймворк найдет его по ID компонента.

Вывод

Несмотря на недавнее внедрение фреймворков нового поколения ( Micronaut , Quarkus ), которые имеют некоторые преимущества по сравнению с «традиционными» фреймворками, благодаря Spring существует огромное количество основанного на отражении кода. Мы увидим, как рынок изменится в ближайшем будущем, но в настоящее время Spring является очевидным лидером среди фреймворков Java-приложений, поэтому мы будем работать с API отражений довольно долгое время.

И если вы задумываетесь об использовании в своем коде API отражения, независимо от того, реализуете ли вы собственную платформу или просто приложение, рассмотрите два других варианта — генерацию кода и, особенно, LambdaMetafactory. Последнее увеличит скорость выполнения кода, в то время как разработка не займет больше времени по сравнению с «традиционным» использованием API отражения.

Опубликовано на Java Code Geeks с разрешения Андрея Беляева, партнера нашей программы JCG . Смотрите оригинальную статью здесь: Дважды подумайте, прежде чем использовать отражение

Мнения, высказанные участниками Java Code Geeks, являются их собственными.