Более быстрая альтернатива Java Reflection

Автор оригинала: Carlos Raphael
  • Перевод
Всем привет. Сегодня хотим поделиться с вами переводом статьи, подготовленным специально для студентов курса «Разработчик Java».

В моей статье Specification Pattern (паттерн Спецификация) я специально не упомянул о лежащем в основе компоненте, который сильно помог в реализации. Здесь я подробнее расскажу о классе JavaBeanUtil, который я использовал, чтобы получить значение поля объекта. В том примере это был FxTransaction.



Конечно, вы скажете, что для получения того же результата можно использовать Apache Commons BeanUtils или одну из его альтернатив. Но мне было интересно покопаться в этом и то, что я изучил, работает намного быстрее любой библиотеки, построенной на основе широко известного Java Reflection.

Технологией, позволяющей избежать очень медленного reflection, является инструкция байткода invokedynamic. Вкратце, проявление invokedynamic (или «indy») было самым серьезным нововведением в Java 7, которое позволило проложить путь для реализации динамических языков поверх JVM, используя динамические вызовы методов (dynamic method invocation). Позже, в Java 8, это также позволило реализовать лямбда-выражения и ссылки на методы (method reference), а также улучшить конкатенацию строк в Java 9.

В двух словах, техника, которую я собираюсь описать ниже, использует LambdaMetafactory и MethodHandle для динамического создания реализации интерфейса Function. В Function есть единственный метод, который делегирует вызов фактическому целевому методу с кодом, определенным внутри лямбды.

В данном случае, целевой метод — это геттер, который имеет прямой доступ к полю, которое мы хотим прочитать. Также, я должен сказать, что если вы хорошо знакомы с нововведениями, которые появились в Java 8, то вы найдете приведенные ниже фрагменты кода довольно простыми. В противном случае, код может показаться сложным с первого взгляда.

Посмотрим на самодельный JavaBeanUtil


Приведенный ниже метод getFieldValue — это утилитный метод, используемый для чтения значений из поля JavaBean. Он принимает объект JavaBean и имя поля. Имя поля может быть простым (например, fieldA)или вложенным, разделенным точками (например, nestedJavaBean.nestestJavaBean.fieldA).

private static final Pattern FIELD_SEPARATOR = Pattern.compile("\\.");
    private static final MethodHandles.Lookup LOOKUP = MethodHandles.lookup();
    private static final ClassValue<Map<String, Function>> CACHE = new ClassValue<Map<String, Function>>() {
        @Override
        protected Map<String, Function> computeValue(Class<?> type) {
            return new ConcurrentHashMap<>();
        }
    };

    public static <T> T getFieldValue(Object javaBean, String fieldName) {
        return (T) getCachedFunction(javaBean.getClass(), fieldName).apply(javaBean);
    }

    private static Function getCachedFunction(Class<?> javaBeanClass, String fieldName) {
        final Function function = CACHE.get(javaBeanClass).get(fieldName);
        if (function != null) {
            return function;
        }
        return createAndCacheFunction(javaBeanClass, fieldName);
    }

    private static Function createAndCacheFunction(Class<?> javaBeanClass, String path) {
        return cacheAndGetFunction(path, javaBeanClass,
                createFunctions(javaBeanClass, path)
                        .stream()
                        .reduce(Function::andThen)
                        .orElseThrow(IllegalStateException::new)
        );
    }

    private static Function cacheAndGetFunction(String path, Class<?> javaBeanClass, Function functionToBeCached) {
        Function cachedFunction = CACHE.get(javaBeanClass).putIfAbsent(path, functionToBeCached);
        return cachedFunction != null ? cachedFunction : functionToBeCached;
    }    


Для улучшения производительности я кэширую функцию, созданную динамически, которая в действительности и будет читать значение из поля с именем fieldName. В методе getCachedFunction, как вы видите, есть “быстрый” путь, использующий ClassValue для кэширования, и “медленный” путь createAndCacheFunction, который выполняется, если значение в кэше не найдено.

Метод createFunctions вызывает метод, возвращающий список функций, которые будут преобразованы в цепочку с помощью Function::andThen. Связывание функций друг с другом в цепочку можно представить в виде вложенных вызовов, похожих на getNestedJavaBean().getNestJavaBean().getNestJavaBean().getFieldA(). После этого мы просто помещаем функцию в кэш, вызывая метод cacheAndGetFunction.
Если посмотреть внимательнее на создание функции, то нам нужно пройтись по полям в path следующим образом:

private static List<Function> createFunctions(Class<?> javaBeanClass, String path) {
    List<Function> functions = new ArrayList<>();
    Stream.of(FIELD_SEPARATOR.split(path))
            .reduce(javaBeanClass, (nestedJavaBeanClass, fieldName) -> {
                Tuple2<? extends Class, Function> getFunction = createFunction(fieldName, nestedJavaBeanClass);
                functions.add(getFunction._2);
                return getFunction._1;
            }, (previousClass, nextClass) -> nextClass);
    return functions;
}

private static Tuple2<? extends Class, Function> createFunction(String fieldName, Class<?> javaBeanClass) {
    return Stream.of(javaBeanClass.getDeclaredMethods())
            .filter(JavaBeanUtil::isGetterMethod)
            .filter(method -> StringUtils.endsWithIgnoreCase(method.getName(), fieldName))
            .map(JavaBeanUtil::createTupleWithReturnTypeAndGetter)
            .findFirst()
            .orElseThrow(IllegalStateException::new);
}


Вышеупомянутый метод createFunctions для каждого поля fieldName и класса, в котором он объявлен, вызывает метод createFunction, который ищет нужный геттер, используя javaBeanClass.getDeclaredMethods(). Как только геттер найден, он преобразуется в кортеж Tuple (Tuple из библиотеки Vavr), который содержит возвращаемый геттером тип, и динамически созданную функцию, которая будет вести себя так, как если бы она сама была геттером.
Создание кортежа выполняется с методом createTupleWithReturnTypeAndGetter в сочетании с методом createCallSite следующим образом:

private static Tuple2<? extends Class, Function> createTupleWithReturnTypeAndGetter(Method getterMethod) {
    try {
        return Tuple.of(
                getterMethod.getReturnType(),
                (Function) createCallSite(LOOKUP.unreflect(getterMethod)).getTarget().invokeExact()
        );
    } catch (Throwable e) {
        throw new IllegalArgumentException("Lambda creation failed for getterMethod (" + getterMethod.getName() + ").", e);
    }
}

private static CallSite createCallSite(MethodHandle getterMethodHandle) throws LambdaConversionException {
    return LambdaMetafactory.metafactory(LOOKUP, "apply",
            MethodType.methodType(Function.class),
            MethodType.methodType(Object.class, Object.class),
            getterMethodHandle, getterMethodHandle.type());
}


В двух вышеупомянутых методах я использую константу с именем LOOKUP, которая является просто ссылкой на MethodHandles.Lookup. С ее помощью я могу создать прямую ссылку на метод (direct method handle), основанную на ранее найденном геттере. И, наконец, созданный MethodHandle передается методу createCallSite, в котором для функции создается тело лямбды с использованием LambdaMetafactory. Оттуда, в конечном счете, мы можем получить экземпляр CallSite, который является “хранителем” функции.
Обратите внимание, что для сеттеров можно использовать аналогичный подход, используя BiFunction вместо Function.

Бенчмарк


Для измерения производительности я использовал замечательный инструмент JMH (Java Microbenchmark Harness), который, вероятно, будет частью JDK 12 (Примечание переводчика: да, jmh вошел в java 9). Как вы, возможно, знаете, результат зависит от платформы, поэтому для справки: я буду использовать 1x6 i5-8600K 3,6 ГГц и Linux x86_64, а также Oracle JDK 8u191 и GraalVM EE 1.0.0-rc9.
Для сравнения я выбрал библиотеку Apache Commons BeanUtils, широко известную большинству Java-разработчиков, и одну из ее альтернатив под названием Jodd BeanUtil, которая, как утверждается, работает почти на 20% быстрее.

Код бенчмарка выглядит следующим образом:

@Fork(3)
@Warmup(iterations = 5, time = 3)
@Measurement(iterations = 5, time = 1)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@State(Scope.Thread)
public class JavaBeanUtilBenchmark {

    @Param({
            "fieldA",
            "nestedJavaBean.fieldA",
            "nestedJavaBean.nestedJavaBean.fieldA",
            "nestedJavaBean.nestedJavaBean.nestedJavaBean.fieldA"
    })
    String fieldName;
    JavaBean javaBean;

    @Setup
    public void setup() {
        NestedJavaBean nestedJavaBean3 = NestedJavaBean.builder().fieldA("nested-3").build();
        NestedJavaBean nestedJavaBean2 = NestedJavaBean.builder().fieldA("nested-2").nestedJavaBean(nestedJavaBean3).build();
        NestedJavaBean nestedJavaBean1 = NestedJavaBean.builder().fieldA("nested-1").nestedJavaBean(nestedJavaBean2).build();
        javaBean = JavaBean.builder().fieldA("fieldA").nestedJavaBean(nestedJavaBean1).build();
    }

    @Benchmark
    public Object invokeDynamic() {
        return JavaBeanUtil.getFieldValue(javaBean, fieldName);
    }

    /**
     * Reference: http://commons.apache.org/proper/commons-beanutils/
     */
    @Benchmark
    public Object apacheBeanUtils() throws Exception {
        return PropertyUtils.getNestedProperty(javaBean, fieldName);
    }

    /**
     * Reference: https://jodd.org/beanutil/
     */
    @Benchmark
    public Object joddBean() {
        return BeanUtil.declared.getProperty(javaBean, fieldName);
    }

    public static void main(String... args) throws IOException, RunnerException {
        Main.main(args);
    }
}


В бенчмарке определено четыре сценария для разных уровней вложенности поля. Для каждого поля JMH выполнит 5 итераций по 3 секунды для разогрева, а затем 5 итераций по 1 секунде для фактического измерения. Каждый сценарий будет повторяться 3 раза для получения более качественных измерений.

Результаты


Давайте начнем с результатов, собранных для JDK 8u191:


Oracle JDK 8u191

Самый худший сценарий, использующий подход invokedynamic, намного быстрее, чем самый быстрый из двух других библиотек. Это огромная разница, и если вы сомневаетесь в результатах, вы можете всегда скачать исходный код и поиграться с ним, как вам нравится.

Теперь посмотрим, как работает тот же тест с GraalVM EE 1.0.0-rc9.


GraalVM EE 1.0.0-rc9

Полные результаты можно посмотреть здесь с красивым JMH Visualizer.

Наблюдения


Такая большая разница получилась из-за того, что JIT-компилятор хорошо знает CallSite и MethodHandle и может их заинлайнить (inline), в отличие от подхода с reflection. Кроме того, вы можете увидеть, насколько перспективен GraalVM. Его компилятор делает действительно потрясающую работу, способную значительно повысить производительность reflection.

Если вам интересно и вы хотите покопаться глубже, я призываю вас взять код из моего репозитория на Github. Имейте в виду, я не советую вам делать самодельный JavaBeanUtil, чтобы использовать его в продакшене. Моя цель — просто показать мой эксперимент и возможности, которые мы можем получить из invokedynamic.

На этом перевод подошел к концу, а всех желающих мы приглашаем 13 июня на бесплатный вебинар, в рамках которого рассмотрим, чем Docker может быть полезен Java-разработчику: как сделать docker-образ с java-приложением и как с ним взаимодействовать.
OTUS. Онлайн-образование
665,56
Цифровые навыки от ведущих экспертов
Поделиться публикацией

Похожие публикации

Комментарии 0

Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

Самое читаемое