После прочтения заголовка у кого-то наверняка возникнет весьма логичный вопрос: «Кто такая эта ваша рефлексия и зачем её ускорять?»
И если первая часть будет волновать только совсем уж откровенных неофитов (ответ тут), то вторая точно нуждается в пояснении.
К текущему моменту рефлексия (и особенно рефлективные вызовы методов) так или иначе используется в прорве самых разных фреймворков, библиотек и просто любых приложениях, по какой-либо причине требующих динамические возможности.
Однако в 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; } } }
Где подвох?
Чудес не бывает, и получая в чём-то преимущество, мы вынуждены платить чем-то другим.
Невозможность обойти проверки доступа
Так как вызовы происходят внутри машины, все упаковываемые сущности обязаны быть видны для упаковщика. Это автоматически отсекает возможность использования обоих подходов для различных хаков, возможных ранее с рефлексией (например, вызов приватных методов класса).
Ресурсоёмкий процесс подготовки
Генерация прокси-классов - дело не быстрое, и занимает достаточно существенное время. В целом, этот подход не подразумевает постоянную переупаковку метода: один раз подготовил, всё время вызываешь.
Выводы
Рефлексия – незаменимый инструмент, но слишком тяжёлый, чтобы быть вызванным в рантайме.
Мета-лямбды – не слишком универсально, но максимально быстро.
Динамические прокси – абсолютно универсально, но медленнее, чем мета-лямбды.
Также стоит помнить о том, что многие вещи могут быть реализованы без рефлексии, и это будет намного лучше, чем любые её оптимизации.
Спасибо за внимание!
