Какой самый быстрый способ прочитать геттер из Java-класса, не зная этого класса во время компиляции? Фреймворки Java часто делают это — много. И это может напрямую влиять на их производительность. Итак, давайте оценим различные подходы, такие как отражение, дескрипторы методов и генерация кода.
Случаи использования
Предположим, у нас есть простой Person
класс с именем и адресом:
public class Person {
...
public String getName() {...}
public Address getAddress() {...}
}
И мы хотим использовать одну из платформ, такую как:
- XStream , JAXB или Jackson для сериализации экземпляров в XML или JSON
- JPA / Hibernate для хранения людей в базе данных
- OptaPlanner для назначения адресов (если они туристы или бездомные)
Ни один из этих фреймворков не знает 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 звонков
- Точно так же, если вы пишете 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 , стоимость отражения выскакивает, как больной большой палец:
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.JavaCompiler
API мы можем генерировать код прямого доступа во время выполнения:
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 процентов выше . И профилирование (с использованием выборки) также выглядит намного лучше:
Обратите внимание, что в нормальных случаях использования такой прирост производительности вряд ли будет обнаружен из-за огромных потребностей ЦП в реально сложном вычислении баллов.
Одним из недостатков генерации кода во время выполнения является то, что он приводит к заметной стоимости начальной загрузки (как будет обсуждаться позже), особенно если сгенерированный код не компилируется массово. Итак, я все еще надеюсь, что когда-нибудь MethodHandles
получим так же быстро, как прямой доступ, просто чтобы избежать затрат на загрузку и боли зависимости.
LambdaMetafactory
На Reddit я получил красноречивое предложение использовать LambdaMetafactory
:
Приступить 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
Ваш пробег может варьироваться.
Удачного кодирования!