Статьи

Отражение Java, но быстрее

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

Случаи использования

Предположим, у нас есть простой Personкласс с именем и адресом:

public class Person {
   ...

   public String getName() {...}
   public Address getAddress() {...}

}

И мы хотим использовать одну из платформ, такую ​​как:

  • XStream , JAXB или Jackson для сериализации экземпляров в XML или JSON
  • JPA / Hibernate для хранения людей в базе данных
  • OptaPlanner для назначения адресов (если они туристы или бездомные)

reflectionButFasterUseCase

Ни один из этих фреймворков не знает Personкласс. Таким образом, они не могут просто позвонить person.getName():

   // Framework code
   public Object executeGetter(Object object) {
      // Compilation error: class Person is unknown to the framework
      return ((Person) object).getName();
   }

Вместо этого код использует отражение, дескрипторы методов или генерацию кода.

Но  такой код называется очень много :

  • Если вы добавите 1000 разных людей в базу данных, JPA / Hibernate, вероятно, вызовет такой код 2000 раз:

    • 1000 звонков Person.getName()
    • еще 1000 звонков Person.getAddress()
  • Точно так же, если вы пишете 1000 разных людей в XML или JSON, скорее всего, будет 2000 вызовов XStream, JAXB или Jackson.

Очевидно, что когда такой код вызывается x раз в секунду, его производительность имеет значение .

Ориентиры

Используя JMH, я запустил ряд микро-тестов с использованием OpenJDK 1.8.0_111 в Linux на 64-битном 8-ядерном настольном компьютере Intel i7-4790 с 32 ГБ оперативной памяти. Тест JMH выполнялся с тремя вилами, пятью итерациями разогрева в 1 секунду и 20 итерациями измерения в 1 секунду. Все расходы на разогрев ушли; увеличение продолжительности итерации до пяти секунд практически не влияет на числа, представленные здесь.

Исходный код этого теста доступен в  этом репозитории GitHub .

TL; DR Результаты

  • Отражение Java медленно.
  • Ява  MethodHandles тоже медленная.
  • Сгенерированный код с javax.tools.JavaCompilerбыстро.
  • LambdaMetafactory работает довольно быстро. 

ПРИМЕЧАНИЕ. Эти наблюдения основаны на сценариях использования, которые я сравнивал с используемой рабочей нагрузкой. Ваш пробег может отличаться!

Итак, дьявол кроется в деталях. Давайте пройдемся по реализациям, чтобы подтвердить, что я применил типичные магические трюки, такие как setAccessible(true).

Реализации

Прямой доступ: базовый уровень

Я использовал обычный person.getName()вызов в качестве базовой линии:

public final class MyAccessor {

    public Object executeGetter(Object object) {
        return ((Person) object).getName();
    }

}

Это занимает около 2,6 наносекунд на операцию:

Benchmark           Mode  Cnt  Score   Error  Units
===================================================
DirectAccess        avgt   60  2.590 ± 0.014  ns/op

Прямой доступ, естественно, самый быстрый подход во время выполнения, без затрат на загрузку. Но он импортирует Personво время компиляции, поэтому он непригоден для всех сред.

отражение

Очевидный способ чтения фреймворка, который получает getter во время выполнения, не зная об этом заранее, — через Java Reflection:

public final class MyAccessor {

    private final Method getterMethod;

    public MyAccessor() {
        getterMethod = Person.class.getMethod("getName");
        // Skip Java language access checking during executeGetter()
        getterMethod.setAccessible(true);
    }

    public Object executeGetter(Object bean) {
        return getterMethod.invoke(bean);
    }

}

Добавление  setAccessible(true)вызова ускоряет эти рефлекторные вызовы, но даже в этом случае требуется 5,5 наносекунд на вызов.

Benchmark           Mode  Cnt  Score   Error  Units
===================================================
DirectAccess        avgt   60  2.590 ± 0.014  ns/op
Reflection          avgt   60  5.275 ± 0.053  ns/op

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

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

reflectionTspIncrementalCalculationSamplingProfiler

MethodHandles

 MethodHandle был введен в Java 7 для поддержки invokedynamic инструкций. Согласно Javadoc, это типизированная, непосредственно исполняемая ссылка на базовый метод. Звучит быстро, верно?

public final class MyAccessor {

    private final MethodHandle getterMethodHandle;

    public MyAccessor() {
        MethodHandles.Lookup lookup = MethodHandles.lookup();
        // findVirtual() matches signature of Person.getName()
        getterMethodHandle = lookup.findVirtual(Person.class, "getName", MethodType.methodType(String.class))
            // asType() matches signature of MyAccessor.executeGetter()
            .asType(MethodType.methodType(Object.class, Object.class));
    }

    public Object executeGetter(Object bean) {
        return getterMethodHandle.invokeExact(bean);
    }

}

Ну, к сожалению,  MethodHandle это даже медленнее, чем отражение в OpenJDK 8. Это занимает 6,1 наносекунды на операцию, так что это на 136 процентов медленнее, чем прямой доступ.

Benchmark           Mode  Cnt  Score   Error  Units
===================================================
DirectAccess        avgt   60  2.590 ± 0.014  ns/op
Reflection          avgt   60  5.275 ± 0.053  ns/op
MethodHandle        avgt   60  6.100 ± 0.079  ns/op

Использование lookup.unreflectGetter(Field)вместо lookup.findVirtual(…)не имеет заметной разницы. Я надеюсь, что это  MethodHandle  будет так же быстро, как прямой доступ в будущих версиях Java.

Статический методHandles

Я также провел тест  MethodHandle в статическом поле. JVM может творить больше магии со статическими полями, как объяснил Алексей Шипилёв . Алексей и Джон О’Хара правильно указали, что в исходном тесте неправильно использовались статические поля, поэтому я исправил это. Вот исправленные результаты:

Benchmark           Mode  Cnt  Score   Error  Units
===================================================
DirectAccess        avgt   60  2.590 ± 0.014  ns/op
MethodHandle        avgt   60  6.100 ± 0.079  ns/op
StaticMethodHandle  avgt   60  2.635 ± 0.027  ns/op

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

public final class MyAccessors {

    private static final MethodHandle handle1; // Person.getName()
    private static final MethodHandle handle2; // Person.getAge()
    private static final MethodHandle handle3; // Company.getName()
    private static final MethodHandle handle4; // Company.getAddress()
    private static final MethodHandle handle5; // ...
    private static final MethodHandle handle6;
    private static final MethodHandle handle7;
    private static final MethodHandle handle8;
    private static final MethodHandle handle9;
    ...
    private static final MethodHandle handle1000;

}

Если наша структура имеет дело с иерархией классов домена с четырьмя получателями, она заполнит первые четыре поля. Однако, если он имеет дело с 100 классами доменов с 20 получателями в каждом, всего 2000 получателей, он потерпит крах из-за отсутствия статических полей.

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

Сгенерированный код с javax.tools.JavaCompiler

В Java можно скомпилировать и запустить сгенерированный код Java во время выполнения. Итак, с помощью javax.tools.JavaCompilerAPI мы можем генерировать код прямого доступа во время выполнения:

public abstract class MyAccessor {

    // Just a gist of the code, the full source code is linked in a previous section
    public static MyAccessor generate() {
        final String String fullClassName = "x.y.generated.MyAccessorPerson$getName";
        final String source = "package x.y.generated;\n"
                + "public final class MyAccessorPerson$getName extends MyAccessor {\n"
                + "    public Object executeGetter(Object bean) {\n"
                + "        return ((Person) object).getName();\n"
                + "    }\n"
                + "}";
        JavaFileObject fileObject = new ...(fullClassName, source);

        JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
        ClassLoader classLoader = ...;
        JavaFileManager javaFileManager = new ...(..., classLoader)
        CompilationTask task = compiler.getTask(..., javaFileManager, ..., singletonList(fileObject));
        boolean success = task.call();
        ...
        Class compiledClass = classLoader.loadClass(fullClassName);
        return compiledClass.newInstance();
    }

    // Implemented by the generated subclass
    public abstract Object executeGetter(Object object);

}

Полный исходный код намного длиннее и доступен в этом репозитории GitHub . Для получения дополнительной информации о том, как использовать javax.tools.JavaCompiler, посмотрите на странице 2 этой статьи или этой статьи . В Java 8 требуется tools.jarпуть к классу, который автоматически устанавливается в JDK. В Java 9 требуется модуль java.compilerв модуле pathpath. Кроме того, необходимо позаботиться о том, чтобы он не генерировал classlist.mfфайл в рабочем каталоге и использовал правильный ClassLoader.

Кроме того javax.tools.JavaCompiler, подобные подходы могут использовать ASM или CGLIB, но они выводят зависимости Maven и могут иметь разные результаты производительности.

В любом случае сгенерированный код так же быстр, как прямой доступ:

Benchmark           Mode  Cnt  Score   Error  Units
===================================================
DirectAccess        avgt   60  2.590 ± 0.014  ns/op
JavaCompiler        avgt   60  2.726 ± 0.026  ns/op

Итак, когда я снова запустил эту задачу Traveling Salesman в  OptaPlanner , на этот раз с использованием генерации кода для доступа к переменным планирования, скорость вычисления баллов была в целом на 18 процентов выше . И профилирование (с использованием выборки) также выглядит намного лучше:

codeGenerationTspIncrementalCalculationSamplingProfiler

Обратите внимание, что в нормальных случаях использования такой прирост производительности вряд ли будет обнаружен из-за огромных потребностей ЦП в реально сложном вычислении баллов.

Одним из недостатков генерации кода во время выполнения является то, что он приводит к заметной стоимости начальной загрузки (как будет обсуждаться позже), особенно если сгенерированный код не компилируется массово. Итак, я все еще надеюсь, что когда-нибудь  MethodHandles  получим так же быстро, как прямой доступ, просто чтобы избежать затрат на загрузку и боли зависимости.

LambdaMetafactory

На Reddit я получил красноречивое предложение использовать LambdaMetafactory:

lambdaMetafactoryRedditResponse

Приступить LambdaMetafactoryк работе над нестатическим методом оказалось непросто из-за отсутствия документации и вопросов StackOverflow, но он работает:

public final class MyAccessor {

    private final Function getterFunction;

    public MyAccessor() {
        MethodHandles.Lookup lookup = MethodHandles.lookup();
        CallSite site = LambdaMetafactory.metafactory(lookup,
                "apply",
                MethodType.methodType(Function.class),
                MethodType.methodType(Object.class, Object.class),
                lookup.findVirtual(Person.class, "getName", MethodType.methodType(String.class)),
                MethodType.methodType(String.class, Person.class));
        getterFunction = (Function) site.getTarget().invokeExact();
    }

    public Object executeGetter(Object bean) {
        return getterFunction.apply(bean);
    }

}

И это выглядит хорошо:  LambdaMetafactory почти так же быстро, как прямой доступ. Это всего на 33 процента медленнее, чем прямой доступ, что намного лучше, чем отражение.

Benchmark           Mode  Cnt  Score   Error  Units
===================================================
DirectAccess        avgt   60  2.590 ± 0.014  ns/op
Reflection          avgt   60  5.275 ± 0.053  ns/op
LambdaMetafactory   avgt   60  3.453 ± 0.034  ns/op

Когда я снова запустил эту Задачу коммивояжера в OptaPlanner , на этот раз используя  LambdaMetafactory для доступа к переменным планирования скорость вычисления баллов в целом была на 9 процентов выше . Тем не менее, профилирование (с использованием выборки) по-прежнему показывает много executeGetter()времени, которое все еще меньше, чем с отражением.

По ненаучным оценкам стоимость метасмерта составляет около 2 КБ на лямбду, и мусор собирается нормально.

Стоимость начальной загрузки

Стоимость времени выполнения имеет наибольшее значение, так как нередко извлекают метод получения тысяч раз в секунду. Тем не менее, вопросы стоимости начальной загрузки, тоже, потому что нам нужно создать MyAccessorдля каждого добытчика в иерархии домена , который мы хотим , чтобы отразить более, такие как Person.getName(), Person.getAddress(), Address.getStreet(),Address.getCity().

 Reflection  и  MethodHandle  иметь пренебрежимую стоимость начальной загрузки. Ибо  LambdaMetafactoryэто все еще приемлемо. Моя машина создает около 25 тыс. Аксессоров в секунду. Но  JavaCompilerэто не так — моя машина создает только около 200 аксессоров в секунду.

Benchmark                    Mode  Cnt        Score        Error  Units
=======================================================================
Reflection Bootstrap         avgt   60      268.510 ±     25.271  ns/op //    0.3µs/op
MethodHandle Bootstrap       avgt   60     1519.177 ±     46.644  ns/op //    1.5µs/op
JavaCompiler Bootstrap       avgt   60  4814526.314 ± 503770.574  ns/op // 4814.5µs/op
LambdaMetafactory Bootstrap  avgt   60    38904.287 ±   1330.080  ns/op //   39.9µs/op

Этот тест не делает кеширование или усложнение.

Заключение

В этом исследовании рефлексия и (пригодность для использования)  MethodHandles в два раза медленнее, чем прямой доступ в OpenJDK 8. Сгенерированный код так же быстр, как прямой доступ, но это проблема. LambdaMetafactory почти так же быстро, как прямой доступ.

Benchmark           Mode  Cnt  Score   Error  Units
===================================================
DirectAccess        avgt   60  2.590 ± 0.014  ns/op
Reflection          avgt   60  5.275 ± 0.053  ns/op // 104% slower
MethodHandle        avgt   60  6.100 ± 0.079  ns/op // 136% slower
StaticMethodHandle  avgt   60  2.635 ± 0.027  ns/op //   2% slower
JavaCompiler        avgt   60  2.726 ± 0.026  ns/op //   5% slower
LambdaMetafactory   avgt   60  3.453 ± 0.034  ns/op //  33% slower

Ваш пробег может варьироваться.

Удачного кодирования!