После прочтения заголовка у кого-то наверняка возникнет весьма логичный вопрос: «Кто такая эта ваша рефлексия и зачем её ускорять?»
И если первая часть будет волновать только совсем уж откровенных неофитов (ответ тут), то вторая точно нуждается в пояснении.
К текущему моменту рефлексия (и особенно рефлективные вызовы методов) так или иначе используется в прорве самых разных фреймворков, библиотек и просто любых приложениях, по какой-либо причине требующих динамические возможности.
Однако в java рефлексия реализована не самым быстрым (зато надёжным) способом, а именно, через использование JNI-вызовов.
К сожалению, нельзя просто так взять и вызвать потенциально опасный бинарь, во-первых, потенциально несовместимый с внутренним миром машины, а во-вторых, способный без угрызений совести положить всё намертво лёгким взмахом segfault’а. Поэтому непосредственно моменту прямого вызова предшествует тонна инструкций, подготовляющих обе стороны к взаимодействию. Очевидно, не самый быстрый процесс.
Тем не менее, рефлексия работает именно так: машина «выходит наружу», копается в своих внутренностях и «возвращается обратно», доставляя пользователю полученную информацию или вызывая методы/конструкторы.
А теперь представьте примерное быстродействие какого-нибудь фреймворка, который в процессе работы постоянно осуществляет рефлективные вызовы…
Б-р-р! Ужасающая картина. Но, к счастью, есть способ всё исправить!
Постановка задачи
Задача такова – есть n методов с заранее неизвестной сигнатурой, необходимо найти их, получив рефлективное представление, и затем вызывать при наступлении определённого условия.
Очень просто, на первый взгляд, но на практике мы сталкиваемся с некоторыми трудностями, основная из которых – способ вызывать метод таким образом, чтобы расходы на вызов не обходились дороже, чем непосредственно исполнение тела метода.
Характеристики машины
Intel core i5-9400f, 16 GB ОЗУ, Windows 11
Проверяем рефлексию
Сейчас, к счастью, не 2005 год, и вызовы JNI больше не напоминают по скорости фазу stop-the-world GC. На том пути, что java прошла от появления JNI до настоящего времени, была проделана огромная работа по оптимизации и улучшению технологии (спасибо авторам project panama).
Так что, может, всё не так уж и плохо, и ускорять ничего не надо?
Проверим в первую очередь!
Java 17, простой класс A, содержащий в себе целочисленное поле value, которое можно сложить с другим числом с помощью вызова метода add.
Вызовем метод напрямую N раз, чтобы иметь данные, от которых будем отталкиваться в будущем. N для надёжности примем за 5 000 000.
public class Main {
public static void main(String[] args) {
final int N = 5000000;
final A a = new A();
long start = System.nanoTime();
for (int i = 0; i < N; ++i) {
a.add(i);
}
System.out.println(System.nanoTime() - start);
}
public static class A {
public int value = 0;
public void add(int x) {
value += x;
}
}
}
В результате получим примерно 5 000 000 ns (у меня получилось 4976700). Прекрасно! А что же там с рефлексией?
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
public class Main {
public static void main(String[] args) throws
NoSuchMethodException, InvocationTargetException, IllegalAccessException {
final int N = 5000000;
final A a = new A();
Method method = A.class.getDeclaredMethod("add", int.class);
long start = System.nanoTime();
for (int i = 0; i < N; ++i) {
method.invoke(a, i);
}
System.out.println(System.nanoTime() - start);
}
public static class A {
public int value = 0;
public void add(int x) {
value += x;
}
}
}
Запускаем, и… 71 085 900 ns! В 14 раз медленнее!
Кажется, ускорять всё-таки придётся…
Но откуда такое время? Во-первых, JNI. Во-вторых, проверки доступа. В-третьих, varargs, упаковывающиеся в массив и распаковывающиеся из него при вызове целевого метода.
Попробуем отключить проверки доступа:
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
public class Main {
public static void main(String[] args) throws
NoSuchMethodException, InvocationTargetException, IllegalAccessException {
final int N = 5000000;
final A a = new A();
Method method = A.class.getDeclaredMethod("add", int.class);
method.setAccessible(true);
long start = System.nanoTime();
for (int i = 0; i < N; ++i) {
method.invoke(a, i);
}
System.out.println(System.nanoTime() - start);
}
public static class A {
public int value = 0;
public void add(int x) {
value += x;
}
}
}
Уже 40 863 800 ns, примерно в 8 раз медленнее. Лучше, но всё равно не сахар.
Способ первый, мета-лямбды
В java 8 вместе с лямбдами была добавлена заодно интересная технология, позволяющая связывать любой метод с существующим лямбда-интерфейсом и получать на выходе прокси, работающее со скоростью прямого вызова. Это прекрасно, модно, молодёжно, но есть один существенный нюанс – сигнатура метода должна быть заранее известна.
То есть, такой способ потенциально не подходит для, например, веб-фреймворка: методы контроллера могут содержать неизвестное количество дополнительных параметров.
И хотя этот способ не совсем покрывает объявленную выше задачу, давайте измерим его скорость.
import java.lang.invoke.CallSite;
import java.lang.invoke.LambdaMetafactory;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
import java.lang.reflect.Method;
public class Main {
public static void main(String[] args) throws Throwable {
final int N = 5000000;
final A a = new A();
Method method = A.class.getDeclaredMethod("add", int.class);
MethodHandles.Lookup lookup = MethodHandles.lookup();
CallSite callSite = LambdaMetafactory.metafactory(
lookup,
"add",
MethodType.methodType(Adder.class, A.class),
MethodType.methodType(void.class, int.class),
lookup.unreflect(method),
MethodType.methodType(void.class, int.class)
);
Adder adder = (Adder) callSite.getTarget().bindTo(a).invoke();
long start = System.nanoTime();
for (int i = 0; i < N; ++i) {
adder.add(i);
}
System.out.println(System.nanoTime() - start);
}
public interface Adder {
void add(int x);
}
public static class A {
public int value = 0;
public void add(int x) {
value += x;
}
}
}
В результате 5776000 ns, всего в 1,15 раза хуже (примерно). Отличный результат!
И, к сожалению, быстрее уже не будет.
Собственно, на этом функционал встроенных решений исчерпан и дальше нам предстоит действовать самостоятельно.
Способ второй, динамическое проксирование
Если мы покопаемся в реализации мета-лямбд, мы увидим генерирование прокси-классов, имплементирующих конкретную лямбду. Тогда что мешает нам делать тоже самое, только для универсальной сигнатуры метода?
Правильно, нам мешает сложность генерирования байт-кода для jvm «на лету». Совсем немного поискав, утыкаемся в искомую утилиту – ASM. Также не помешает справочник по опкодам.
Напишем универсальный интерфейс, который будем имплементировать в дальнейшем:
public interface Lambda {
Object call(Object[] arguments) throws Throwable;
}
Выглядит правдоподобно, я в это верю, как говорится.
А теперь самое интересное. Предлагаю не прыгать с места в байт-код, а написать собственную тестовую реализацию, от которой мы в будущем будем отталкиваться.
Примерно так:
public class Proxy implements Lambda {
private final Main.A body;
public Proxy(Main.A body) {
this.body = body;
}
@Override
public Object call(Object[] arguments) {
body.add((Integer) arguments[0]);
return null;
}
}
Вроде всё хорошо, да? А вот и нет. С точки зрения java, код действительно отличный. А вот с точки зрения jvm – ни разу. Пока между этими двумя существует прослойка в виде компилятора, всё работает как надо. Но как только прослойка пропадает и за дело берёмся мы, нам необходимо помнить об одном очень существенном нюансе: боксинг примитивов. Поэтому доработаем наш код так, чтобы не забыть об этом:
public class Proxy implements Lambda {
private final Main.A body;
public Proxy(Main.A body) {
this.body = body;
}
@Override
public Object call(Object[] arguments) {
body.add(((Integer) arguments[0]).intValue());
return null;
}
}
Чудесно. Можно приступать к реализации прокси.
Как же будет выглядеть метод call, записанный в jvm-ассемблере?
Краткая справка. JVM – стековая машина, и все операции выполняет исходя из данных, расположенных на операнд-стеке.
Таким образом, вызов метода можно разбить на 3 этапа:
Загрузка источника, содержащего вызываемый метод
Подготовка всех аргументов в последовательном порядке
Непосредственно вызов метода
В нашем случае, это будет происходить следующим образом:
Загрузка объекта проксируемого класса
Загрузка массива аргументов
Загрузка содержимого ячейки массива
Каст содержимого
Вызов метода
Возврат значения, которое он вернул (или null в нашем случае)
Примерный скетч:
aload_0 // Загружаем this, чтобы извлечь поле body
getfield // Загружаем body
aload_1 // Загружаем массив из первого параметра метода
iconst_0 // Пушим в стек int-константу 0 (индекс элемента)
aaload // Загружаем из массива элемент по индексу 0
checkcast // Кастим Object в Integer
invokevirtual // Вызываем Integer::intValue(), распаковывая примитив
invokevirtual // Вызываем целевой метод из body
aconst_null // Помещаем в стек null
areturn // Возвращаем результат
Вроде ничего не забыли… Раз так, вооружаемся user’s guide’ом ASM и идём реализовывать прокси.
Получаем вот такой результат:
import org.objectweb.asm.ClassWriter;
import org.objectweb.asm.MethodVisitor;
import org.objectweb.asm.Opcodes;
public class Main {
public static void main(String[] args) throws Throwable {
final String OBJECT = "java/lang/Object";
// Создаём генератор нашего прокси-класса,
// указывая ему самому считать за нас максы
ClassWriter writer = new ClassWriter(ClassWriter.COMPUTE_MAXS);
// Объявляем собственно сам заголовок класса
writer.visit(
Opcodes.V1_8,
Opcodes.ACC_PUBLIC,
"Proxy",
null,
OBJECT,
new String[]{"Lambda"}
);
// Объявляем поле для хранения инстанса A
writer.visitField(Opcodes.ACC_PRIVATE, "body", "LMain$A;", null, null)
.visitEnd();
// Объявляем конструктор
MethodVisitor c = writer.visitMethod(Opcodes.ACC_PUBLIC, "<init>",
"(LMain$A;)V", null, null);
// Загружаем и вызываем super();
c.visitVarInsn(Opcodes.ALOAD, 0);
c.visitMethodInsn(Opcodes.INVOKESPECIAL, OBJECT, "<init>", "()V", false);
// Получаем this и загружаем переданный аргумент
c.visitVarInsn(Opcodes.ALOAD, 0);
c.visitVarInsn(Opcodes.ALOAD, 1);
// Присваиваем его в поле body
c.visitFieldInsn(Opcodes.PUTFIELD, "Proxy", "body", "LMain$A;");
c.visitInsn(Opcodes.RETURN);
c.visitMaxs(0, 0);
c.visitEnd();
// Реализуем метод
MethodVisitor m = writer.visitMethod(Opcodes.ACC_PUBLIC,
"call",
"([Ljava/lang/Object;)Ljava/lang/Object;",
null,
new String[]{"java/lang/Throwable"});
// Загружаем this, чтобы извлечь поле body
m.visitVarInsn(Opcodes.ALOAD, 0);
// Загружаем body
m.visitFieldInsn(Opcodes.GETFIELD, "Proxy", "body", "LMain$A;");
// Загружаем массив из первого параметра метода
m.visitVarInsn(Opcodes.ALOAD, 1);
// Пушим в стек int-константу 0 (индекс элемента)
m.visitInsn(Opcodes.ICONST_0);
// Загружаем из массива элемент по индексу 0
m.visitInsn(Opcodes.AALOAD);
// Кастим Object в Integer
m.visitTypeInsn(Opcodes.CHECKCAST, "java/lang/Integer");
// Вызываем Integer::intValue(), распаковывая примитив
m.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/lang/Integer", "intValue", "()I",
false);
// Вызываем целевой метод из body
m.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "Main$A", "add", "(I)V", false);
// Помещаем в стек null
m.visitInsn(Opcodes.ACONST_NULL);
// Возвращаем результат
m.visitInsn(Opcodes.ARETURN);
m.visitMaxs(0, 0);
m.visitEnd();
writer.visitEnd();
byte[] bytes = writer.toByteArray();
}
public static class A {
public int value = 0;
public void add(int x) {
value += x;
}
}
}
Осталось загрузить класс-лоадером получившееся прокси и можно идти тестировать!
Загрузить стандартными средствами класс не выйдет (метод defineClass protected), и нам придётся создать свой класс-лоадер. Впрочем, ничего сложного:
class Loader extends ClassLoader {
public Class<?> define(String name, byte[] buffer) {
return defineClass(name, buffer, 0, buffer.length);
}
}
Загружаем изделие, инстанцируем и проверяем скорость.
import org.objectweb.asm.ClassWriter;
import org.objectweb.asm.MethodVisitor;
import org.objectweb.asm.Opcodes;
public class Main {
public static void main(String[] args) throws Throwable {
...
Loader loader = new Loader();
Class<?> clazz = loader.define("Proxy", bytes);
final A a = new A();
Lambda lambda = (Lambda) clazz.getDeclaredConstructor(A.class).newInstance(a);
final int N = 5000000;
long start = System.nanoTime();
for (int i = 0; i < N; ++i) {
lambda.call(new Object[]{i});
}
System.out.println(System.nanoTime() - start);
}
public static class A {
public int value = 0;
public void add(int x) {
value += x;
}
}
}
class Loader extends ClassLoader {
public Class<?> define(String name, byte[] buffer) {
return defineClass(name, buffer, 0, buffer.length);
}
}
И… *барабанная дробь* 16806000 ns. Всего в 3 раза медленнее, чем прямые вызовы. Но откуда взялись эти 3 раза? Неужели прокси так замедляет?
Ответ кроется в конструкции new Object[]{i}. Попробуем вынести создание массива во вне:
import org.objectweb.asm.ClassWriter;
import org.objectweb.asm.MethodVisitor;
import org.objectweb.asm.Opcodes;
public class Main {
public static void main(String[] args) throws Throwable {
...
Loader loader = new Loader();
Class<?> clazz = loader.define("Proxy", bytes);
final A a = new A();
Lambda lambda = (Lambda) clazz.getDeclaredConstructor(A.class)
.newInstance(a);
final int N = 5000000;
long start = System.nanoTime();
Object[] arguments = new Object[]{5};
for (int i = 0; i < N; ++i) {
lambda.call(arguments);
}
System.out.println(System.nanoTime() - start);
}
public static class A {
public int value = 0;
public void add(int x) {
value += x;
}
}
}
class Loader extends ClassLoader {
public Class<?> define(String name, byte[] buffer) {
return defineClass(name, buffer, 0, buffer.length);
}
}
И получим 5736500 ns. Те же самые мета-лямбды, по факту.
Есть ли способ избежать расходов на инстанцирование массива? Не думаю, телепортировать аргументы машина, к сожалению, не умеет. Критично ли это? Тоже не особо, так как там, где это действительно неизбежно, расходы на подготовку аргументов скорее всего с лихвой перебьют расходы на new.
А можно проще?
Да, разумеется, вам не нужно каждый раз самостоятельно реализовывать генерацию прокси вручную, существуют утилиты, удобно инкапсулирующие этот процесс.
Рассмотрим всё то же самое на примере jeflect (тык)
Мета-лямбды
import com.github.romanqed.jeflect.ReflectUtil;
import com.github.romanqed.jeflect.meta.LambdaClass;
import java.lang.reflect.Method;
public class Main {
public static void main(String[] args) throws Throwable {
A a = new A();
Method method = A.class.getDeclaredMethod("add", int.class);
LambdaClass<Adder> clazz = LambdaClass.fromClass(Adder.class);
Adder adder = ReflectUtil.packLambdaMethod(clazz, method, a);
final int N = 5000000;
long start = System.nanoTime();
for (int i = 0; i < N; ++i) {
adder.add(i);
}
System.out.println(System.nanoTime() - start);
}
public interface Adder {
void add(int x);
}
public static class A {
public int value = 0;
public void add(int x) {
value += x;
}
}
}
Прокси
import com.github.romanqed.jeflect.Lambda;
import com.github.romanqed.jeflect.ReflectUtil;
import java.lang.reflect.Method;
public class Main {
public static void main(String[] args) throws Throwable {
A a = new A();
Method method = A.class.getDeclaredMethod("add", int.class);
Lambda lambda = ReflectUtil.packMethod(method, a);
final int N = 5000000;
long start = System.nanoTime();
for (int i = 0; i < N; ++i) {
lambda.call(new Object[]{i});
}
System.out.println(System.nanoTime() - start);
}
public static class A {
public int value = 0;
public void add(int x) {
value += x;
}
}
}
Нерассмотренное в статье прокси без привязки к конкретному объекту
import com.github.romanqed.jeflect.LambdaMethod;
import com.github.romanqed.jeflect.ReflectUtil;
import java.lang.reflect.Method;
public class Main {
public static void main(String[] args) throws Throwable {
A a = new A();
Method method = A.class.getDeclaredMethod("add", int.class);
LambdaMethod lambda = ReflectUtil.packLambdaMethod(method);
final int N = 5000000;
long start = System.nanoTime();
for (int i = 0; i < N; ++i) {
lambda.call(a, new Object[]{i});
}
System.out.println(System.nanoTime() - start);
}
public static class A {
public int value = 0;
public void add(int x) {
value += x;
}
}
}
Где подвох?
Чудес не бывает, и получая в чём-то преимущество, мы вынуждены платить чем-то другим.
Невозможность обойти проверки доступа
Так как вызовы происходят внутри машины, все упаковываемые сущности обязаны быть видны для упаковщика. Это автоматически отсекает возможность использования обоих подходов для различных хаков, возможных ранее с рефлексией (например, вызов приватных методов класса).
Ресурсоёмкий процесс подготовки
Генерация прокси-классов - дело не быстрое, и занимает достаточно существенное время. В целом, этот подход не подразумевает постоянную переупаковку метода: один раз подготовил, всё время вызываешь.
Выводы
Рефлексия – незаменимый инструмент, но слишком тяжёлый, чтобы быть вызванным в рантайме.
Мета-лямбды – не слишком универсально, но максимально быстро.
Динамические прокси – абсолютно универсально, но медленнее, чем мета-лямбды.
Также стоит помнить о том, что многие вещи могут быть реализованы без рефлексии, и это будет намного лучше, чем любые её оптимизации.
Спасибо за внимание!
Комментарии (22)
BugM
14.05.2022 22:38Погрузится в пучины LambdaMetafactory можно тут https://github.com/FasterXML/jackson-modules-base/tree/master/blackbird
Заодно переедете с устаревшего Afterburner на новый Blackbird.
Flowka
14.05.2022 23:20+2Спасибо за статью. Почему Вы не используете фреймворк для микробенчмаркинга (например, jmh)? Кроме того в 18ой джаве core reflections переписали на var handles.
https://openjdk.java.net/jeps/416lampa_torsherov Автор
14.05.2022 23:31Про var handles не знал, спасибо. Что насчет jmh, в данном случае его использование показалось излишним, все измерения сводились к вызову нескольких методов, да и точности используемого подхода вполне хватает.
vasyakolobok77
15.05.2022 20:09+1Дело не в точности, а в достоверности и воспроизводимости. System.nanoTime с кодом в цикле может показать что угодно, т.к. не учитывается оптимизации, jit, code-кеши и прочее. JMH же создан так, чтобы абстрагироваться от всего такого и дать прикладному разработчику инструмент для написания достоверных (хотя бы для его железа) бенчмарков. Так что рекомендую в будущем использовать его.
sandersru
15.05.2022 20:17Так они же не в цикле, а до-после... Ну да, могут врать, но не порядки же
Flowka
15.05.2022 20:57Могут быть порядки. Основной смысл использования какого-то стандартного инструментария для бенчмаркинга - это возможность замерить нужный участок кода на разогретой JVM. Посмотрите JEP, который я скидывал выше, там замеры сделаны именно с помощью JMH.
AlexunKo
14.05.2022 23:25А кеширование как вариант почему не рассматривается?
lampa_torsherov Автор
15.05.2022 00:38Каким образом кэширование поможет ускорить рефлективные вызовы метода? Имхо, максимум можно кэшировать получение объекта java.lang.reflect.Method, но это даст выигрыш лишь в скорости подготовки вызова.
Если вы говорите о кэшировании сгенерированных прокси-классов, чтобы не создавать их заново для одной и той же связки класс-метод, то это уже по сути детали конкретной реализации, которые были опущены, чтобы не загружать обзорную статью.
P. S. Если имеется ввиду кэширование получаемой из метода информации, дабы предотвратить излишние вызовы, то это материал для совершенно другой статьи.
CommanderTvis
14.05.2022 23:32+3Ну нет же, JNI тут ни при чем. Современные JVM содержат интринсики для методов рефлексии. Например, вот так обрабатываются методы типа java.lang.Class.isInterface:
https://github.com/openjdk/jdk/blob/master/src/hotspot/share/opto/library_call.cpp#L3565sshikov
15.05.2022 10:13А можете еще чуть подробнее пояснить свою мысль? Вы же показываете код на C++, то есть, так или иначе, это не байткод.
CommanderTvis
15.05.2022 10:32+1Ну смотрите. Байт-код можно либо интепретировать, либо компилировать в платформенный код. В обоих случаях мы можем вместо какого-то метода JVM вызвать просто какую-нибудь функцию самой JVM, чем и являются интринсики.
Ну а JNI - это сложнейший комбайн, который поддерживает потокобезопасность, открывает во внешний мир API JVM и делает еще кучу вещей, необходимых для стабильного FFI.sshikov
15.05.2022 11:13+1Я правильно понял, что этот код на C++, не компилируется в .so или .dll, и не вызывается через JNI, а является частью хотспот компилятора?
CommanderTvis
15.05.2022 11:18Да. Если бы для каждого вызова интринсика приходилось уходить в safe point, то это было бы больно.
souls_arch
15.05.2022 07:49+3Рефлексия в Джаве помимо пользы может нести в себе кучу багов, которые потом хрен поймаешь. А так же это - отличный инструмент в руках хакера/злоумышленника. Получить доступ можно к чему угодно.
GerrAlt
15.05.2022 21:27отличный инструмент в руках хакера/злоумышленника. Получить доступ можно к чему угодно.
Не могли бы развернуть вашу мысль? В текущем изложении не очень понятно в чем вы видите угрозу безопасности
karambaso
15.05.2022 14:15+1Занимательные замеры времени. Вывод же несколько ограниченный. Вся разница получается за счёт компиляции метода add в одну asm инструкцию вместо нескольких десятков для reflection-а. Ну а промежуточные значения есть лишь показатель способности методом тыка подсказать компилятору, как же правильно компилировать.
Ну и про "тяжесть" рефлексии. Просто не надо писать критический код с её использованием. И в подавляющем большинстве случаев для этого нет никакой необходимости. А некритический код, исполняемый за 1 микросекунду или за 10 наносекунд - абсолютно ни на что не влияет.
sandersru
15.05.2022 19:18+1Странно, что нет теста с Unsafe... Если вы уже решили прыгнуть в гигантский цикл(то есть массовые операции) то очевидно, что дальше только хардкор... И это не про суррогатный тест с рефлекшан
slav_perm
16.05.2022 00:12Как будто в этой строке не хватает использования i
lambda.call(arguments);
lampa_torsherov Автор
16.05.2022 00:14Для наглядной демонстрации влияния создания массива на скорость вызова был создан вне цикла "константный" массив, содержащий 5 в качестве аргумента.
remal
16.05.2022 10:34А почему вы считаете, что измерили именно вызов метода 'add'? Он может заинлайнится, цикл может быть оптимизирован, а, учитывая, что поле 'value' не читается, не удивлюсь, если окажется, что и вызов метода вместе с циклом были просто удалены
loltrol
Есть еще один способ, не волшебный, правда, и не всегда применимый, но мега-крутой - compile-time кодогенерация и post-compile weaving. Последний вообще позволяет получить перфоманс "как будто бы сам написал".