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