Ускоряем java-рефлексию в 2022

от автора

После прочтения заголовка у кого-то наверняка возникнет весьма логичный вопрос: «Кто такая эта ваша рефлексия и зачем её ускорять?»

И если первая часть будет волновать только совсем уж откровенных неофитов (ответ тут), то вторая точно нуждается в пояснении.

К текущему моменту рефлексия (и особенно рефлективные вызовы методов) так или иначе используется в прорве самых разных фреймворков, библиотек и просто любых приложениях, по какой-либо причине требующих динамические возможности.

Однако в 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 этапа:

  1. Загрузка источника, содержащего вызываемый метод

  2. Подготовка всех аргументов в последовательном порядке

  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;         }     } }

Где подвох?

Чудес не бывает, и получая в чём-то преимущество, мы вынуждены платить чем-то другим.

Невозможность обойти проверки доступа

Так как вызовы происходят внутри машины, все упаковываемые сущности обязаны быть видны для упаковщика. Это автоматически отсекает возможность использования обоих подходов для различных хаков, возможных ранее с рефлексией (например, вызов приватных методов класса).

Ресурсоёмкий процесс подготовки

Генерация прокси-классов — дело не быстрое, и занимает достаточно существенное время. В целом, этот подход не подразумевает постоянную переупаковку метода: один раз подготовил, всё время вызываешь.

Выводы

Рефлексия – незаменимый инструмент, но слишком тяжёлый, чтобы быть вызванным в рантайме.

Мета-лямбды – не слишком универсально, но максимально быстро.

Динамические прокси – абсолютно универсально, но медленнее, чем мета-лямбды.

Также стоит помнить о том, что многие вещи могут быть реализованы без рефлексии, и это будет намного лучше, чем любые её оптимизации.

Спасибо за внимание!


ссылка на оригинал статьи https://habr.com/ru/post/665726/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *