Не заставляйте слушателей рефлексировать

Введение

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

В новой версии CUBA Platform одной из задач по улучшению фреймворка было избавление от явного создания обработчиков событий в классах-контроллерах UI экранов. В предыдущих версиях объявления обработчиков в методе инициализации контроллера очень захламляли код, так что в седьмой версии мы решительно намерились все оттуда вычистить.

Обработчик событий (event listener) — это всего лишь ссылка на метод, который нужно вызвать в нужный момент (см. шаблон Observer). Такой шаблон достаточно просто реализовать с использованием класса java.lang.reflect.Method. При старте нужно всего лишь просканировать классы, вытащить из них аннотированные методы, сохранить ссылки на них, и использовать ссылки для вызова метода (или методов) при наступлении события, как это сделано в основной массе фреймворков. Единственное, что нас останавливало, так это то, что в UI традиционно генерируется очень много событий, а при использовании reflection API приходится платить некоторую цену в виде времени вызова методов. Поэтому мы решили посмотреть на то, как ещё можно сделать обработчики событий, не используя reflection.

Мы уже публиковали на хабре материалы про MethodHandles и LambdaMetafactory, и этот материал является своеобразным продолжением. Мы рассмотрим аргументы «за» и «против» использования reflection API, а также альтернативы — генерацию кода с AOT компиляцией и LambdaMetafactory, и как это было применено во фреймворке CUBA.


Reflection: Старый. Добрый. Надежный.

В информатике отражение или рефлексия (холоним интроспекции, англ. reflection) означает процесс, во время которого программа может отслеживать и модифицировать собственную структуру и поведение во время выполнения. © Wikipedia.

Для большинства Java разработчиков reflection — ни разу не новая вещь. Мне кажется, что без этого механизма Java не стала бы той Java, которая сейчас занимает большую долю рынка разработки прикладного программного обеспечения. Просто подумайте: проксирование, привязка методов к событиям через аннотации, внедрение зависимостей, аспекты, да даже инстанцирование JDBC драйвера в самых первых версиях JDK! Reflection везде, это краеугольный камень всех современных фреймворков.

Есть ли с Reflection какие-то проблемы, применительно к нашей задаче? Мы выделили три:

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

Типизация — если вы используете java.lang.reflect.Method в коде, то это просто ссылка на какой-то метод. И нигде не написано, сколько параметров передается и какого они типа. Вызов с неправильными параметрам сгенерирует ошибку в рантайме, а не на этапе компиляции или загрузки приложения.

Прозрачность — если метод, вызванный через reflection, свалится с ошибкой, то нам придется продираться через несколько вызовов invoke(), прежде, чем мы докопаемся до реальной причины ошибки.

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

А какие ещё варианты есть?


AOT компиляция и кодогенерация — вернем приложениям скорость!

Первый кандидат на замену reflection API — генерация кода. Сейчас начали появляться фреймворки, такие как Micronaut или Quarkus, которые стараются решать две проблемы: уменьшение скорости запуска приложения и уменьшение потребления памяти. Эти две метрики жизненно важны в наш век контейнеров, микросервисов и serverless архитектур, и новые фреймворки пытаются решить это путем AOT компиляции. Используя разные техники (можно почитать тут, например), код приложения модифицируется таким образом, что все рефлексивные вызовы методов, конструкторов и т.д. заменяются на прямые вызовы. Таким образом, не нужно сканировать классы и создавать бины во время запуска приложения, да и JIT более эффективно оптимизирует код во время выполнения, что дает значительное увеличение производительности приложений, построенных на таких фреймворках. Есть ли у этого подхода недостатки? Ответ: конечно, есть.

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

Второе — для запуска приложения, написанного на фреймворке с AOT компиляцией, нужен специальный инструмент. Нельзя просто так взять и запустить приложение, написанное на Quarkus, например. Нужен специальный плагин для maven/gradle, который предварительно обработает ваш код. И теперь, в случае обнаружения ошибок во фреймворке, нужно обновлять не только библиотеки, но и плагин.

По правде сказать, кодогенерация — это тоже не новость в Java мире, она не появилась с Micronaut или Quarkus. В том или ином виде некоторые фреймворки ее используют. Здесь можно вспомнить lombok, aspectj с его предварительной генерацией кода для аспектов или eclipselink, который в классы-сущности добавляет код для более эффективной десериализации. В CUBA мы используем генерацию кода для генерации событий об изменении состояния сущности и для включения сообщений валидаторов в код класса для упрощения работы с сущностями в UI.

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

В Java 7 появилась новая инструкция для JVM — invokedynamic. Про нее есть отличный доклад Владимира Иванова на jug.ru тут. Изначально задуманная для использования в динамических языках вроде Groovy, эта инструкция стала отличным кандидатом на то, чтобы вызывать методы в Java без использования reflection. Одновременно с новой инструкцией в JDK появился связанный с ним API:


  • Класс MethodHandle — появился в ещё в Java 7, но все еще не очень часто применяется
  • LambdaMetafactory — этот класс уже из Java 8, он стал дальнейшим развитием API для динамических вызовов, использует MethodHandle внутри.

Казалось, что MethodHandle, по сути являясь типизированным указателем на метод, (конструктор, и т.д.), сможет выполнять роль java.lang.reflect.Method. И вызовы будут быстрее, потому что все проверки на соответствие типов, которые выполняются в Reflection API при каждом вызове, в данном случае выполняются только один раз, при создании MethodHandle.

Но увы, чистый MethodHandle оказался даже медленее, чем вызовы через reflection API. Можно добиться повышения производительности, если сделать MethodHandle статическими, но не во всех случаях это возможно. Есть отличная дискуссия по поводу скорости вызовов MethodHandle на рассылке OpenJDK.

Но, когда появился класс LambdaMetafactory, то появился реальный шанс ускорить вызовы методов. LambdaMetafactory позволяет создать объект-лямбду и завернуть в него прямой вызов метода, который можно получить через MethodHandle. А потом, используя сгенерированный объект, можно вызывать нужный метод. Вот пример генерации, которая «оборачивает» метод-getter, переданный в качестве параметра, в BiFunction:

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

В итоге, мы получаем экземпляр BiFunction вместо Method. И теперь, если даже мы и использовали Method в своем коде, то заменить его на BiFunction не составит труда. Возьмем реальный (немного упрощенный, правда) код вызова обработчика метода, помеченного @EventListener из 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);
    }
}

А вот тот же код, но который использует вызов метода через лямбду:

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);
    }
}

Минимальные изменения, функциональность такая же, но есть преимущества:

У лямбды есть тип — он указывается при создании, так что вызвать «просто метод» не получится.

Стек трейс короче — при вызове метода через лямбду добавляется всего один дополнительный вызов — apply(). И все. Дальше вызывается сам метод.

А вот скорость надо померить.


Замеряем скорость

Для проверки гипотезы мы сделали микробенчмарк с использованием JMH для сравнения времени выполнения и пропускной способности при вызове одного и того же метода разными способами: через reflection API, через LambdaMetafactory, а также добавили прямой вызов метода для сравнения. Ссылки на Method и на лямбды создавались и кэшировались перед запуском теста.

Параметры тестирования:

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

Сам тест можно скачать с GitHub и запустить самому, если интересно.

Результаты теста для Oracle JDK 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

В среднем, получилось, что вызов метода через лямбду примерно на 30% быстрее, чем через reflection API. Есть ещё одна отличная дискуссия о производительности вызова методов тут, если кому-то интересны детали. Коротко — выигрыш в скорости получается в том числе и за счет того, что сгенерированные лямбды могут быть заинлайнены в код программы, а ещё не выполняются проверки типов, в отличие от reflection.

Конечно, этот бенчмарк довольно простой, тут не включен вызов методов по иерархии классов или замер скорости вызова final методов. Но мы делали и более сложные измерения, и результаты всегда были в пользу использования LambdaMetafactory.


Использование

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

Это нововведение позволило сильно очистить код, особенно в случае форм с большим количеством элементов, сложным взаимодействием и, соответственно, с большим количеством обработчиков событий. Простой пример из CUBA QuickStart: представьте, что вам нужно пересчитывать сумму заказа при добавлении или удалении позиций товаров. Нужно написать код, который запускает метод calculateAmout() при изменении коллекции в сущности. Как это выглядело раньше:

public class OrderEdit extends AbstractEditor {
    @Inject
    private CollectionDatasource linesDs;
    @Override
    public void init(
            Map params) {
        linesDs.addCollectionChangeListener(e -> calculateAmount());
    }
...
}

А в CUBA 7 код выглядит так:

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

Итог: код чище и нет волшебного метода init(), который имеет свойство разрастаться и наполняться обработчиками событий с ростом сложности формы. А ещё — нам даже не надо делать поле с компонентом, на который мы подписываемся, CUBA найдет этот компонент по ID.


Выводы

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

А если вы думаете об использовании Reflection API в своем коде — приложение ли это или фреймворк — подумайте дважды. Сначала про генерацию кода, а потом — про MethodHandles/LambdaMetafactory. Второй способ может оказаться быстрее, а усилий на разработку будет затрачено не больше, чем в случае использования Reflection API.

Ещё немного полезных ссылок:
A faster alternative to Java Reflection
Hacking Lambda Expressions in Java
Method Handles in Java
Java Reflection, but much faster
Why is LambdaMetafactory 10% slower than a static MethodHandle but 80% faster than a non-static MethodHandle?
Too Fast, Too Megamorphic: what influences method call performance in Java?

© Habrahabr.ru