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

в 17:05, , рубрики: java, jeflect, reflection

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

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

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

Однако в 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://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js