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

—

от автора

Введение

Привет, Хабр.

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

Ну что же, поехали!

Постановка задачи

Имеем в наличии jdk 17, хотим вызывать методы класса по имени и таким же образом обращаться к полям.

Характеристики машины

Intel Core i5-9400f, 16 GB ОЗУ, Windows 11.

Замеры различных способов вызова методов

Не будем повторяться, вновь вводя и разъясняя все используемые понятия, вспоминая историю развития рефлексии и периодически пугаясь от её неторопливости.

Поэтому тех читателей, кто не поймёт происходящее, или какую-либо его часть, попрошу обратиться к первоначальной статье, упомянутой в начале.

Что касается методов замера – ошибки повторять мы тоже не будем, поэтому в этот раз используем православный jmh.

Итак, встречайте! Бессмертная тройка претендентов на нашей бенчмарк-арене:

  • Рефлексия. Древний способ, появившийся вместе с сотворением мира выходом jdk 1.0. Чрезвычайно мощный и чрезвычайно медленный (или уже нет? интрига, однако :)).

  • Мета-лямбды. Встроенный в sdk способ, добавленный в один из релизов jdk 8. Довольно ограниченный, так как позволяет вызывать только методы с известной во время компиляции сигнатурой, зато самый быстрый из всех трёх.

  • Динамическое проксирование. Сторонний способ, предоставленный в нашем случае моей библиотекой jeflect. Повторяет функционал рефлективных вызовов за исключением того, что целевой метод должен быть виден относительно класс-лоадера, загружающего прокси. Проще говоря, приватные (или, например, package-private) методы он вызывать не сможет. Медленнее, чем мета-лямбды, но быстрее, чем рефлексия.

Издеваться над испытуемыми будем с помощью вот этого безобидного класса:

public final class Calculator {     public int add(int left, int right) {         return left + right;     }      public String about() {         return "Your first project™";     } }

Вероятно, кому-то покажется, что двух методов слишком много, но на самом деле для выявления всех особенностей и слабых мест каждого способа их чрезвычайно мало.

По крайней мере, этого хватит, чтобы создать некоторое подобие справедливого соревнования.

Метод about универсально удобен для всех троих, а add добавит проблем рефлексии и динамическим прокси — им придётся потратить дополнительное время на упаковку/распаковку аргументов плюс заставит боксить примитивы.

Наконец, напишем бенчмарки для обоих методов.

Для about:

package com.github.romanqed;  import com.github.romanqed.jeflect.lambdas.Lambda; import com.github.romanqed.jeflect.lambdas.LambdaFactory; import com.github.romanqed.jeflect.meta.LambdaType; import com.github.romanqed.jeflect.meta.MetaFactory; import com.github.romanqed.jfunc.Exceptions; import org.openjdk.jmh.annotations.*; import org.openjdk.jmh.infra.Blackhole;  import java.lang.reflect.Method; import java.util.concurrent.TimeUnit; import java.util.function.Function;  @BenchmarkMode(Mode.AverageTime) @OutputTimeUnit(TimeUnit.NANOSECONDS) @Warmup public class NoArgumentBench {     // Подготавливаем всё необходимое     private static final Calculator CALC = new Calculator();     private static final Method ABOUT = Exceptions.suppress(() -> Calculator.class.getDeclaredMethod("about"));     private static final MetaFactory META_FACTORY = new MetaFactory();     @SuppressWarnings("unchecked")     private static final Function<Calculator, String> META_LAMBDA = META_FACTORY.packLambdaMethod(             LambdaType.fromClass(Function.class),             ABOUT     );     private static final LambdaFactory LAMBDA_FACTORY = new LambdaFactory();     private static final Lambda LAMBDA = LAMBDA_FACTORY.packMethod(ABOUT);      static {         // Отключаем для рефлексии проверки доступа         ABOUT.setAccessible(true);     }      // Обычный вызов для сравнения     @Benchmark     public void benchPlainCall(Blackhole blackhole) {         blackhole.consume(CALC.about());     }      // Вызов через рефлексию     @Benchmark     public void benchReflection(Blackhole blackhole) throws Exception {         blackhole.consume(ABOUT.invoke(CALC));     }      // Вызов с помощью мета-лямбд     @Benchmark     public void benchMetaLambdas(Blackhole blackhole) {         blackhole.consume(META_LAMBDA.apply(CALC));     }      // Вызов с помощью динамических прокси     @Benchmark     public void benchProxies(Blackhole blackhole) throws Throwable {         blackhole.consume(LAMBDA.invoke(CALC));     } } 

И для add:

package com.github.romanqed;  import com.github.romanqed.jeflect.lambdas.Lambda; import com.github.romanqed.jeflect.lambdas.LambdaFactory; import com.github.romanqed.jeflect.meta.LambdaType; import com.github.romanqed.jeflect.meta.MetaFactory; import com.github.romanqed.jfunc.Exceptions; import org.openjdk.jmh.annotations.*; import org.openjdk.jmh.infra.Blackhole;  import java.lang.reflect.Method; import java.util.concurrent.TimeUnit;  @BenchmarkMode(Mode.AverageTime) @OutputTimeUnit(TimeUnit.NANOSECONDS) @Warmup public class TwoArgumentBench {     // Лямбда-интерфейс для мета-лямбд     public interface Adder {         int add(Calculator calculator, int left, int right);     }      // Подготавливаем всё необходимое     private static final Calculator CALC = new Calculator();     private static final Method ADD = Exceptions.suppress(             () -> Calculator.class.getDeclaredMethod("add", int.class, int.class)     );     private static final MetaFactory META_FACTORY = new MetaFactory();     private static final Adder META_LAMBDA = META_FACTORY.packLambdaMethod(             LambdaType.fromClass(Adder.class),             ADD     );     private static final LambdaFactory LAMBDA_FACTORY = new LambdaFactory();     private static final Lambda LAMBDA = LAMBDA_FACTORY.packMethod(ADD);      static {         // Отключаем для рефлексии проверки доступа         ADD.setAccessible(true);     }      // Обычный вызов для сравнения     @Benchmark     public void benchPlainCall(Blackhole blackhole) {         blackhole.consume(CALC.add(5, 6));     }      // Вызов через рефлексию     @Benchmark     public void benchReflection(Blackhole blackhole) throws Exception {         blackhole.consume(ADD.invoke(CALC, 5, 6));     }      // Вызов с помощью мета-лямбд     @Benchmark     public void benchMetaLambdas(Blackhole blackhole) {         blackhole.consume(META_LAMBDA.add(CALC, 5, 6));     }      // Вызов с помощью динамических прокси     @Benchmark     public void benchProxies(Blackhole blackhole) throws Throwable {         blackhole.consume(LAMBDA.invoke(CALC, new Object[]{5, 6}));     } } 

И да, в отличие от прошлой статьи бенчить рефлексию с включенными проверками доступа мы не будем, ибо грешно смеяться над инвалидами.

Момент истины, запускаем jmh (jmh version 1.36, vm version JDK 17.0.7, OpenJDK 64-Bit Server VM, 17.0.7+7-LTS)… *барабанная дробь*

Benchmark                          Mode  Cnt  Score   Error  Units NoArgumentBench.benchMetaLambdas   avgt   25  0,388 ± 0,003  ns/op NoArgumentBench.benchPlainCall     avgt   25  0,388 ± 0,002  ns/op NoArgumentBench.benchProxies       avgt   25  0,388 ± 0,003  ns/op NoArgumentBench.benchReflection    avgt   25  2,280 ± 0,075  ns/op TwoArgumentBench.benchMetaLambdas  avgt   25  0,388 ± 0,003  ns/op TwoArgumentBench.benchPlainCall    avgt   25  0,387 ± 0,003  ns/op TwoArgumentBench.benchProxies      avgt   25  0,520 ± 0,005  ns/op TwoArgumentBench.benchReflection   avgt   25  7,170 ± 0,038  ns/op

Ожидаемо, безоговорочная победа присуждается мета-лямбдам. Практически идентичны обычным вызовам (учитывая погрешность в виде возможных накладных расходов на работу jmh).

Следующими идут прокси, прекрасно показавшие себя на вызовах без параметров и слегка сдавшие позиции из-за танцев с боксингом. Медленнее в ~1.3 раза, при отсутствии параметров идентичны обычным вызовам.

Почётное третье место достаётся рефлексии – не помог даже допинг в виде интринсинков и многочисленных улучшений в JNI. Медленнее в ~5.9 – ~18.5 раз.

Итоги подведены, вопрос окончательно закрыт, и можно переходить к полям.

(Разумеется, существует ещё кодогенерация, но об этом, пожалуй, как-нибудь в другой раз).

Разговоры о полях

В отличие от методов, которым целиком посвящена не только моя предыдущая статья, но и много другого контента, поля часто обходят стороной.

Основная причина этому – пресловутые принципы ООП, благодаря которым редко когда за пределами класса общение с полем происходит напрямую.

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

Поэтому, раз уж мы убедились в важности (относительной, конечно) данного вопроса, давайте заглянем в sdk в поисках нужного инструмента.

Что же мы там видим? Field#get и Field#set. Немного, но это честная работа… Или нет?

Прежде чем бросаться сломя голову запускать 100500 бенчмарков, воспользуемся главным преимуществом открытого исходного кода и заглянем под капот метода get.

@CallerSensitive @ForceInline // to ensure Reflection.getCallerClass optimization public Object get(Object obj) throws IllegalArgumentException, IllegalAccessException {     if (!override) {         Class<?> caller = Reflection.getCallerClass();         checkAccess(caller, obj);     }     return getFieldAccessor(obj).get(obj); }

Что мы здесь видим? Во-первых, небезызвестный checkAccess, повышающий накладные расходы в среднем в два раза. Во-вторых, нельзя не заметить, что доступ к полю осуществляется с помощью какой-то реализации интерфейса FieldAccessor (jdk.internal.reflect.FieldAccessor), создаваемой в недрах рефлексии.

Воспользовавшись отладчиком, чтобы долго не разбираться в хитросплетениях индусского подкапотного кода, добираемся до UnsafeFieldAccessorFactory, всё из того же пакета (напомню, что в других реализациях JVM может вообще не быть этих классов). Преодолев очень много if-else (YandereDev, ты ли это?) и утилитных реализаций, добираемся до jdk.internal.misc.Unsafe и узнаем, как реализован рефлективный доступ к полю в Hotspot.

Итак, здесь тоже замешан JNI. В определенном смысле, конечно, надежда есть (на интринсинки и прочие оптимизации), но что-то подсказывает, что результат бенчмарка нам не понравится.

Кстати, вот он.

package com.github.romanqed;  import com.github.romanqed.jfunc.Exceptions; import org.openjdk.jmh.annotations.*; import org.openjdk.jmh.infra.Blackhole;  import java.lang.reflect.Field; import java.util.concurrent.TimeUnit;  @BenchmarkMode(Mode.AverageTime) @OutputTimeUnit(TimeUnit.NANOSECONDS) @Warmup public class FieldBench {     // Класс с полем     public static class FieldHolder {         public final String helloWorld = "Hello, world!";     }      private static final FieldHolder HOLDER = new FieldHolder();      private static final Field FIELD = Exceptions.suppress(() -> FieldHolder.class.getField("helloWorld"));      static {         // Отключаем проверки доступа         FIELD.setAccessible(true);     }      // Обычный доступ к полю     @Benchmark     public void benchPlainGet(Blackhole blackhole) {         blackhole.consume(HOLDER.helloWorld);     }      // Доступ к полю через рефлексию     @Benchmark     public void benchReflectGet(Blackhole blackhole) throws Exception {         blackhole.consume(FIELD.get(HOLDER));     } }

Результаты же…

Benchmark                   Mode  Cnt  Score   Error  Units FieldBench.benchPlainGet    avgt   25  0,389 ± 0,010  ns/op FieldBench.benchReflectGet  avgt   25  2,345 ± 0,142  ns/op

Страшно, очень страшно, если бы мы знали, что это такое, мы не знаем, что это такое. В ~6 раз медленнее.

Кто виноват и что делать?

Насчёт первого вопроса всё не так однозначно, а вот второй даже не стоит – конечно же писать свою реализацию FieldAccessor’а!

Динамическая генерация accessor’а

С первого взгляда задача выглядит нетривиальной, однако на самом деле у нас к данному моменту остаётся всего два пути: кодогенерация и генерация прокси-классов «на лету».

Кодогенерация приводит нас к необходимости создания скрипта на питоне плагина для конкретного сборщика, а это последняя вещь, к которой вообще следует обращаться (что, если потенциальный пользователь нашей библиотеки использует другой сборщик? или вообще не использует?), поэтому остаётся только генерация прокси-классов.

Для тех, кто не читал предыдущую статью или не знает jvm ассемблер, напомню несколько важных вещей, что пригодятся дальше:

  • JVM – стековая машина, и все операции выполняет исходя из данных, расположенных на операнд-стеке.

  • Полный документированный каталог опкодов машины можно найти тут

  • Встроенного средства генерации байт-кода в языке не предусмотрено, поэтому используем эту библиотечку (или любую другую на ваш вкус, в крайнем случае можно сразу заполнять массив байтами).

По традиции, сначала напишем интерфейс, от которого и будем отталкиваться:

interface FieldAccessor {     Object get(Object instance);      void set(Object instance, Object value); }

А теперь, чтобы было легче писать ассемблерный код, добавим ещё для себя простенькую реализацию:

final class AccessorImpl implements FieldAccessor {     public AccessorImpl() {         super();     }      @Override     public Object get(Object instance) {         return ((FieldOwner) instance).value;     }      @Override     public void set(Object instance, Object value) {         ((FieldOwner) instance).value = (FieldType) value;     } }

Внимательные читатели заметят, что в этом скетче отсутствует одна очень важная вещь, и будут совершенно правы. К этому ещё вернёмся, а пока реализуем искомый генератор.

package com.github.romanqed;  import org.objectweb.asm.ClassWriter; import org.objectweb.asm.Opcodes; import org.objectweb.asm.Type;  import java.lang.reflect.Field;  public final class ProxyGenerator {     private static final Type OBJECT = Type.getType(Object.class);     private static final Loader LOADER = new Loader();      public interface FieldAccessor {         Object get(Object instance);          void set(Object instance, Object value);     }      static class Loader extends ClassLoader {         public Class<?> define(String name, byte[] buffer) {             return defineClass(name, buffer, 0, buffer.length);         }     }      public static FieldAccessor createAccessor(Field field) {         // Генерируем имя будущего прокси         var name = "Accessor" + (field.getDeclaringClass().getName() + field.getName()).hashCode();         // Проверяем, а не загружено ли уже такое прокси         Class<?> clazz;         try {             clazz = LOADER.loadClass(name);         } catch (Exception e) {             clazz = null;         }         // Если загружено, то просто создаем экземпляр         if (clazz != null) {             try {                 return (FieldAccessor) clazz.getDeclaredConstructor().newInstance();             } catch (Exception e) {                 throw new RuntimeException(e);             }         }         // А если нет, то начинаем генерировать.         // Создаём генератор будущего прокси-класса,         // задаём режим автоматического вычисления максимальных размеров стека и локальных переменных         var writer = new ClassWriter(ClassWriter.COMPUTE_MAXS);         // Объявляем заголовок класса         // public final class AccessorImpl implements FieldAccessor {         writer.visit(Opcodes.V11,                 Opcodes.ACC_PUBLIC | Opcodes.ACC_FINAL,                 name,                 null,                 OBJECT.getInternalName(),                 new String[]{Type.getInternalName(FieldAccessor.class)});         // Объявляем пустой конструктор         // public AccessorImpl()         var ctor = writer.visitMethod(Opcodes.ACC_PUBLIC,                 "<init>",                 "()V",                 null,                 null);         // {         ctor.visitCode();         // Загружаем super         ctor.visitVarInsn(Opcodes.ALOAD, 0);         // Вызываем его         ctor.visitMethodInsn(Opcodes.INVOKESPECIAL,                 OBJECT.getInternalName(),                 "<init>",                 "()V",                 false);         // Не забываем обязательный return; в конце, который компилятор обычно добавляет за нас         ctor.visitInsn(Opcodes.RETURN);         // }         ctor.visitMaxs(0, 0);         ctor.visitEnd();         // Имплементируем метод get         // @Override public Object get(Object instance)         var get = writer.visitMethod(Opcodes.ACC_PUBLIC,                 "get",                 Type.getMethodDescriptor(OBJECT, OBJECT),                 null,                 null);         // {         get.visitCode();         // Загружаем объект, содержащий поле         // 1 индекс, потому что 0 индекс у виртуального метода ведет на this         get.visitVarInsn(Opcodes.ALOAD, 1);         // Приводим объект к его типу (сейчас из-за сигнатуры метода он стал Object'ом)         get.visitTypeInsn(Opcodes.CHECKCAST, Type.getInternalName(field.getDeclaringClass()));         // Получаем поле         get.visitFieldInsn(Opcodes.GETFIELD,                 Type.getInternalName(field.getDeclaringClass()),                 field.getName(),                 Type.getDescriptor(field.getType()));         // Возвращаем его         get.visitInsn(Opcodes.ARETURN);         // }         get.visitMaxs(0, 0);         get.visitEnd();         // Имплементируем метод set         // @Override public void set(Object instance, Object value)         var set = writer.visitMethod(Opcodes.ACC_PUBLIC,                 "set",                 Type.getMethodDescriptor(Type.VOID_TYPE, OBJECT, OBJECT),                 null,                 null);         // {         set.visitCode();         // Загружаем объект, содержащий поле         set.visitVarInsn(Opcodes.ALOAD, 1);         // Приводим объект к его типу (сейчас из-за сигнатуры метода он стал Object'ом)         set.visitTypeInsn(Opcodes.CHECKCAST, Type.getInternalName(field.getDeclaringClass()));         // Загружаем будущее значение поля         set.visitVarInsn(Opcodes.ALOAD, 2);         // Приводим его к нужному типу         set.visitTypeInsn(Opcodes.CHECKCAST, Type.getInternalName(field.getType()));         // Обновляем поле         set.visitFieldInsn(Opcodes.PUTFIELD,                 Type.getInternalName(field.getDeclaringClass()),                 field.getName(),                 Type.getDescriptor(field.getType()));         // Не забываем return;         set.visitInsn(Opcodes.RETURN);         // }         set.visitMaxs(0, 0);         set.visitEnd();         // Получаем наш сгенерированный класс в виде массива байтов         var bytes = writer.toByteArray();         // Загружаем байт-код в JVM         clazz = LOADER.define(name, bytes);         try {             return (FieldAccessor) clazz.getDeclaredConstructor().newInstance();         } catch (Exception e) {             throw new RuntimeException(e);         }     } } 

Проверяем:

package com.github.romanqed;  public class Main {     public static class Data {         public String value;     }      public static void main(String[] args) throws NoSuchFieldException {         var accessor = ProxyGenerator.createAccessor(Data.class.getField("value"));         var obj = new Data();         accessor.set(obj, "hello");         System.out.println(accessor.get(obj));     } }

Удивительно, оно работает! А теперь представим, что нам нужно не строковое поле, а целочисленное…

Exception in thread "main" java.lang.VerifyError: Bad type on operand stack Exception Details:   Location:     Accessor865096057.get(Ljava/lang/Object;)Ljava/lang/Object; @7: areturn   Reason:     Type integer (current frame, stack[0]) is not assignable to reference type   Current Frame:     bci: @7     flags: { }     locals: { 'Accessor865096057', 'java/lang/Object' }     stack: { integer }   Bytecode:     0000000: 2bc0 000e b400 12b0 

Упс. Мистер программист? Мистер боксинг примитивов передаёт привет *БАХ*

Чтобы это исправить, понадобится немного шаманской магии. Конкретно в этом случае с int’ом, его необходимо паковать в Integer следующим образом:

// Упаковка Integer.valueOf(int); // Распаковка integer.intValue();

Добавим это в наш генератор — упаковку в get, а распаковку в set.

package com.github.romanqed;  import org.objectweb.asm.ClassWriter; import org.objectweb.asm.Opcodes; import org.objectweb.asm.Type;  import java.lang.invoke.MethodType; import java.lang.reflect.Field;  public final class ProxyGenerator {     private static final Type OBJECT = Type.getType(Object.class);     private static final Loader LOADER = new Loader();      public interface FieldAccessor {         Object get(Object instance);          void set(Object instance, Object value);     }      static class Loader extends ClassLoader {         public Class<?> define(String name, byte[] buffer) {             return defineClass(name, buffer, 0, buffer.length);         }     }      private static Class<?> wrap(Class<?> c) {         return MethodType.methodType(c).wrap().returnType();     }      public static FieldAccessor createAccessor(Field field) {         // Генерируем имя будущего прокси         var name = "Accessor" + (field.getDeclaringClass().getName() + field.getName()).hashCode();         // Проверяем, а не загружено ли уже такое прокси         Class<?> clazz;         try {             clazz = LOADER.loadClass(name);         } catch (Exception e) {             clazz = null;         }         // Если загружено, то просто создаем экземпляр         if (clazz != null) {             try {                 return (FieldAccessor) clazz.getDeclaredConstructor().newInstance();             } catch (Exception e) {                 throw new RuntimeException(e);             }         }         // А если нет, то начинаем генерировать.         // Создаём генератор будущего прокси-класса,         // задаём режим автоматического вычисления максимальных размеров стека и локальных переменных         var writer = new ClassWriter(ClassWriter.COMPUTE_MAXS);         // Объявляем заголовок класса         // public final class AccessorImpl implements FieldAccessor {         writer.visit(Opcodes.V11,                 Opcodes.ACC_PUBLIC | Opcodes.ACC_FINAL,                 name,                 null,                 OBJECT.getInternalName(),                 new String[]{Type.getInternalName(FieldAccessor.class)});         // Объявляем пустой конструктор         // public AccessorImpl()         var ctor = writer.visitMethod(Opcodes.ACC_PUBLIC,                 "<init>",                 "()V",                 null,                 null);         // {         ctor.visitCode();         // Загружаем super         ctor.visitVarInsn(Opcodes.ALOAD, 0);         // Вызываем его         ctor.visitMethodInsn(Opcodes.INVOKESPECIAL,                 OBJECT.getInternalName(),                 "<init>",                 "()V",                 false);         // Не забываем обязательный return; в конце, который компилятор обычно добавляет за нас         ctor.visitInsn(Opcodes.RETURN);         // }         ctor.visitMaxs(0, 0);         ctor.visitEnd();         // Имплементируем метод get         // @Override public Object get(Object instance)         var get = writer.visitMethod(Opcodes.ACC_PUBLIC,                 "get",                 Type.getMethodDescriptor(OBJECT, OBJECT),                 null,                 null);         // {         get.visitCode();         // Загружаем объект, содержащий поле         // 1 индекс, потому что 0 индекс у виртуального метода ведет на this         get.visitVarInsn(Opcodes.ALOAD, 1);         // Приводим объект к его типу (сейчас из-за сигнатуры метода он стал Object'ом)         get.visitTypeInsn(Opcodes.CHECKCAST, Type.getInternalName(field.getDeclaringClass()));         // Получаем поле         get.visitFieldInsn(Opcodes.GETFIELD,                 Type.getInternalName(field.getDeclaringClass()),                 field.getName(),                 Type.getDescriptor(field.getType()));         // Проверяем, если вдруг тип поля примитив         var retType = field.getType();         if (retType.isPrimitive()) {             // Получаем оберточный тип, синонимичный примитиву (например, int -> Integer)             var wrapper = Type.getType(wrap(retType));             // Вызываем его статический метод valueOf             get.visitMethodInsn(Opcodes.INVOKESTATIC,                     wrapper.getInternalName(),                     "valueOf",                     Type.getMethodDescriptor(wrapper, Type.getType(retType)),                     false);         }         // Возвращаем его         get.visitInsn(Opcodes.ARETURN);         // }         get.visitMaxs(0, 0);         get.visitEnd();         // Имплементируем метод set         // @Override public void set(Object instance, Object value)         var set = writer.visitMethod(Opcodes.ACC_PUBLIC,                 "set",                 Type.getMethodDescriptor(Type.VOID_TYPE, OBJECT, OBJECT),                 null,                 null);         // {         set.visitCode();         // Загружаем объект, содержащий поле         set.visitVarInsn(Opcodes.ALOAD, 1);         // Приводим объект к его типу (сейчас из-за сигнатуры метода он стал Object'ом)         set.visitTypeInsn(Opcodes.CHECKCAST, Type.getInternalName(field.getDeclaringClass()));         // Загружаем будущее значение поля         set.visitVarInsn(Opcodes.ALOAD, 2);         var type = field.getType();         // Проверяем, если вдруг тип поля примитив         var toCast = type.isPrimitive() ? wrap(type) : type;         // Приводим его к нужному типу         set.visitTypeInsn(Opcodes.CHECKCAST, Type.getInternalName(toCast));         // Распаковываем если надо примитив         if (type.isPrimitive()) {             set.visitMethodInsn(Opcodes.INVOKEVIRTUAL,                     Type.getInternalName(toCast),                     type.getName() + "Value",                     Type.getMethodDescriptor(Type.getType(type)),                     false);         }         // Обновляем поле         set.visitFieldInsn(Opcodes.PUTFIELD,                 Type.getInternalName(field.getDeclaringClass()),                 field.getName(),                 Type.getDescriptor(field.getType()));         // Не забываем return;         set.visitInsn(Opcodes.RETURN);         // }         set.visitMaxs(0, 0);         set.visitEnd();         // Получаем наш сгенерированный класс в виде массива байтов         var bytes = writer.toByteArray();         // Загружаем байт-код в JVM         clazz = LOADER.define(name, bytes);         try {             return (FieldAccessor) clazz.getDeclaredConstructor().newInstance();         } catch (Exception e) {             throw new RuntimeException(e);         }     } }

Повторная проверка показывает, что всё сделано правильно.

Усложним задачу – теперь поле становится статическим.

package com.github.romanqed;  public class Main {     public static class Data {         public static String value;     }      public static void main(String[] args) throws NoSuchFieldException {         var accessor = ProxyGenerator.createAccessor(Data.class.getField("value"));         accessor.set(null, "1");         System.out.println(accessor.get(null));     } }

Босс, мы упали:

Exception in thread "main" java.lang.IncompatibleClassChangeError: Expected non-static field com.github.romanqed.Main$Data.value at Accessor865096057.set(Unknown Source) at com.github.romanqed.Main.main(Main.java:10)

Ничего страшного, просто добавим проверку на наличие модификатора static:

package com.github.romanqed;  import org.objectweb.asm.ClassWriter; import org.objectweb.asm.Opcodes; import org.objectweb.asm.Type;  import java.lang.invoke.MethodType; import java.lang.reflect.Field; import java.lang.reflect.Modifier;  public final class ProxyGenerator {     private static final Type OBJECT = Type.getType(Object.class);     private static final Loader LOADER = new Loader();      public interface FieldAccessor {         Object get(Object instance);          void set(Object instance, Object value);     }      static class Loader extends ClassLoader {         public Class<?> define(String name, byte[] buffer) {             return defineClass(name, buffer, 0, buffer.length);         }     }      private static Class<?> wrap(Class<?> c) {         return MethodType.methodType(c).wrap().returnType();     }      public static FieldAccessor createAccessor(Field field) {         // Генерируем имя будущего прокси         var name = "Accessor" + (field.getDeclaringClass().getName() + field.getName()).hashCode();         // Проверяем, а не загружено ли уже такое прокси         Class<?> clazz;         try {             clazz = LOADER.loadClass(name);         } catch (Exception e) {             clazz = null;         }         // Если загружено, то просто создаем экземпляр         if (clazz != null) {             try {                 return (FieldAccessor) clazz.getDeclaredConstructor().newInstance();             } catch (Exception e) {                 throw new RuntimeException(e);             }         }         var isStatic = Modifier.isStatic(field.getModifiers());         // А если нет, то начинаем генерировать.         // Создаём генератор будущего прокси-класса,         // задаём режим автоматического вычисления максимальных размеров стека и локальных переменных         var writer = new ClassWriter(ClassWriter.COMPUTE_MAXS);         // Объявляем заголовок класса         // public final class AccessorImpl implements FieldAccessor {         writer.visit(Opcodes.V11,                 Opcodes.ACC_PUBLIC | Opcodes.ACC_FINAL,                 name,                 null,                 OBJECT.getInternalName(),                 new String[]{Type.getInternalName(FieldAccessor.class)});         // Объявляем пустой конструктор         // public AccessorImpl()         var ctor = writer.visitMethod(Opcodes.ACC_PUBLIC,                 "<init>",                 "()V",                 null,                 null);         // {         ctor.visitCode();         // Загружаем super         ctor.visitVarInsn(Opcodes.ALOAD, 0);         // Вызываем его         ctor.visitMethodInsn(Opcodes.INVOKESPECIAL,                 OBJECT.getInternalName(),                 "<init>",                 "()V",                 false);         // Не забываем обязательный return; в конце, который компилятор обычно добавляет за нас         ctor.visitInsn(Opcodes.RETURN);         // }         ctor.visitMaxs(0, 0);         ctor.visitEnd();         // Имплементируем метод get         // @Override public Object get(Object instance)         var get = writer.visitMethod(Opcodes.ACC_PUBLIC,                 "get",                 Type.getMethodDescriptor(OBJECT, OBJECT),                 null,                 null);         // {         get.visitCode();         if (!isStatic) {             // Загружаем объект, содержащий поле             // 1 индекс, потому что 0 индекс у виртуального метода ведет на this             get.visitVarInsn(Opcodes.ALOAD, 1);             // Приводим объект к его типу (сейчас из-за сигнатуры метода он стал Object'ом)             get.visitTypeInsn(Opcodes.CHECKCAST, Type.getInternalName(field.getDeclaringClass()));         }         // Получаем поле         get.visitFieldInsn(isStatic ? Opcodes.GETSTATIC : Opcodes.GETFIELD,                 Type.getInternalName(field.getDeclaringClass()),                 field.getName(),                 Type.getDescriptor(field.getType()));         // Проверяем, если вдруг тип поля примитив         var retType = field.getType();         if (retType.isPrimitive()) {             // Получаем оберточный тип, синонимичный примитиву (например, int -> Integer)             var wrapper = Type.getType(wrap(retType));             // Вызываем его статический метод valueOf             get.visitMethodInsn(Opcodes.INVOKESTATIC,                     wrapper.getInternalName(),                     "valueOf",                     Type.getMethodDescriptor(wrapper, Type.getType(retType)),                     false);         }         // Возвращаем его         get.visitInsn(Opcodes.ARETURN);         // }         get.visitMaxs(0, 0);         get.visitEnd();         // Имплементируем метод set         // @Override public void set(Object instance, Object value)         var set = writer.visitMethod(Opcodes.ACC_PUBLIC,                 "set",                 Type.getMethodDescriptor(Type.VOID_TYPE, OBJECT, OBJECT),                 null,                 null);         // {         set.visitCode();         if (!isStatic) {             // Загружаем объект, содержащий поле             set.visitVarInsn(Opcodes.ALOAD, 1);             // Приводим объект к его типу (сейчас из-за сигнатуры метода он стал Object'ом)             set.visitTypeInsn(Opcodes.CHECKCAST, Type.getInternalName(field.getDeclaringClass()));         }         // Загружаем будущее значение поля         set.visitVarInsn(Opcodes.ALOAD, 2);         var type = field.getType();         // Проверяем, если вдруг тип поля примитив         var toCast = type.isPrimitive() ? wrap(type) : type;         // Приводим его к нужному типу         set.visitTypeInsn(Opcodes.CHECKCAST, Type.getInternalName(toCast));         // Распаковываем если надо примитив         if (type.isPrimitive()) {             set.visitMethodInsn(Opcodes.INVOKEVIRTUAL,                     Type.getInternalName(toCast),                     type.getName() + "Value",                     Type.getMethodDescriptor(Type.getType(type)),                     false);         }         // Обновляем поле         set.visitFieldInsn(isStatic ? Opcodes.PUTSTATIC : Opcodes.PUTFIELD,                 Type.getInternalName(field.getDeclaringClass()),                 field.getName(),                 Type.getDescriptor(field.getType()));         // Не забываем return;         set.visitInsn(Opcodes.RETURN);         // }         set.visitMaxs(0, 0);         set.visitEnd();         // Получаем наш сгенерированный класс в виде массива байтов         var bytes = writer.toByteArray();         // Загружаем байт-код в JVM         clazz = LOADER.define(name, bytes);         try {             return (FieldAccessor) clazz.getDeclaredConstructor().newInstance();         } catch (Exception e) {             throw new RuntimeException(e);         }     } }

И… Это всё. Совсем. Теперь этот код учитывает все возможные случаи, кроме, конечно наличия модификатора final, при котором было бы неплохо вообще не имплементировать метод set:

package com.github.romanqed;  import org.objectweb.asm.ClassWriter; import org.objectweb.asm.Opcodes; import org.objectweb.asm.Type;  import java.lang.invoke.MethodType; import java.lang.reflect.Field; import java.lang.reflect.Modifier;  public final class ProxyGenerator {     private static final Type OBJECT = Type.getType(Object.class);     private static final Loader LOADER = new Loader();      public interface FieldAccessor {         Object get(Object instance);          default void set(Object instance, Object value) {             throw new IllegalStateException("Field is final");         }     }      static class Loader extends ClassLoader {         public Class<?> define(String name, byte[] buffer) {             return defineClass(name, buffer, 0, buffer.length);         }     }      private static Class<?> wrap(Class<?> c) {         return MethodType.methodType(c).wrap().returnType();     }      public static FieldAccessor createAccessor(Field field) {         // Генерируем имя будущего прокси         var name = "Accessor" + (field.getDeclaringClass().getName() + field.getName()).hashCode();         // Проверяем, а не загружено ли уже такое прокси         Class<?> clazz;         try {             clazz = LOADER.loadClass(name);         } catch (Exception e) {             clazz = null;         }         // Если загружено, то просто создаем экземпляр         if (clazz != null) {             try {                 return (FieldAccessor) clazz.getDeclaredConstructor().newInstance();             } catch (Exception e) {                 throw new RuntimeException(e);             }         }         var isStatic = Modifier.isStatic(field.getModifiers());         // А если нет, то начинаем генерировать.         // Создаём генератор будущего прокси-класса,         // задаём режим автоматического вычисления максимальных размеров стека и локальных переменных         var writer = new ClassWriter(ClassWriter.COMPUTE_MAXS);         // Объявляем заголовок класса         // public final class AccessorImpl implements FieldAccessor {         writer.visit(Opcodes.V11,                 Opcodes.ACC_PUBLIC | Opcodes.ACC_FINAL,                 name,                 null,                 OBJECT.getInternalName(),                 new String[]{Type.getInternalName(FieldAccessor.class)});         // Объявляем пустой конструктор         // public AccessorImpl()         var ctor = writer.visitMethod(Opcodes.ACC_PUBLIC,                 "<init>",                 "()V",                 null,                 null);         // {         ctor.visitCode();         // Загружаем super         ctor.visitVarInsn(Opcodes.ALOAD, 0);         // Вызываем его         ctor.visitMethodInsn(Opcodes.INVOKESPECIAL,                 OBJECT.getInternalName(),                 "<init>",                 "()V",                 false);         // Не забываем обязательный return; в конце, который компилятор обычно добавляет за нас         ctor.visitInsn(Opcodes.RETURN);         // }         ctor.visitMaxs(0, 0);         ctor.visitEnd();         // Имплементируем метод get         // @Override public Object get(Object instance)         var get = writer.visitMethod(Opcodes.ACC_PUBLIC,                 "get",                 Type.getMethodDescriptor(OBJECT, OBJECT),                 null,                 null);         // {         get.visitCode();         if (!isStatic) {             // Загружаем объект, содержащий поле             // 1 индекс, потому что 0 индекс у виртуального метода ведет на this             get.visitVarInsn(Opcodes.ALOAD, 1);             // Приводим объект к его типу (сейчас из-за сигнатуры метода он стал Object'ом)             get.visitTypeInsn(Opcodes.CHECKCAST, Type.getInternalName(field.getDeclaringClass()));         }         // Получаем поле         get.visitFieldInsn(isStatic ? Opcodes.GETSTATIC : Opcodes.GETFIELD,                 Type.getInternalName(field.getDeclaringClass()),                 field.getName(),                 Type.getDescriptor(field.getType()));         // Проверяем, если вдруг тип поля примитив         var retType = field.getType();         if (retType.isPrimitive()) {             // Получаем оберточный тип, синонимичный примитиву (например, int -> Integer)             var wrapper = Type.getType(wrap(retType));             // Вызываем его статический метод valueOf             get.visitMethodInsn(Opcodes.INVOKESTATIC,                     wrapper.getInternalName(),                     "valueOf",                     Type.getMethodDescriptor(wrapper, Type.getType(retType)),                     false);         }         // Возвращаем его         get.visitInsn(Opcodes.ARETURN);         // }         get.visitMaxs(0, 0);         get.visitEnd();         if (!Modifier.isFinal(field.getModifiers())) {             // Имплементируем метод set             // @Override public void set(Object instance, Object value)             var set = writer.visitMethod(Opcodes.ACC_PUBLIC,                     "set",                     Type.getMethodDescriptor(Type.VOID_TYPE, OBJECT, OBJECT),                     null,                     null);             // {             set.visitCode();             if (!isStatic) {                 // Загружаем объект, содержащий поле                 set.visitVarInsn(Opcodes.ALOAD, 1);                 // Приводим объект к его типу (сейчас из-за сигнатуры метода он стал Object'ом)                 set.visitTypeInsn(Opcodes.CHECKCAST, Type.getInternalName(field.getDeclaringClass()));             }             // Загружаем будущее значение поля             set.visitVarInsn(Opcodes.ALOAD, 2);             var type = field.getType();             // Проверяем, если вдруг тип поля примитив             var toCast = type.isPrimitive() ? wrap(type) : type;             // Приводим его к нужному типу             set.visitTypeInsn(Opcodes.CHECKCAST, Type.getInternalName(toCast));             // Распаковываем если надо примитив             if (type.isPrimitive()) {                 set.visitMethodInsn(Opcodes.INVOKEVIRTUAL,                         Type.getInternalName(toCast),                         type.getName() + "Value",                         Type.getMethodDescriptor(Type.getType(type)),                         false);             }             // Обновляем поле             set.visitFieldInsn(isStatic ? Opcodes.PUTSTATIC : Opcodes.PUTFIELD,                     Type.getInternalName(field.getDeclaringClass()),                     field.getName(),                     Type.getDescriptor(field.getType()));             // Не забываем return;             set.visitInsn(Opcodes.RETURN);             // }             set.visitMaxs(0, 0);             set.visitEnd();         }         // Получаем наш сгенерированный класс в виде массива байтов         var bytes = writer.toByteArray();         // Загружаем байт-код в JVM         clazz = LOADER.define(name, bytes);         try {             return (FieldAccessor) clazz.getDeclaredConstructor().newInstance();         } catch (Exception e) {             throw new RuntimeException(e);         }     } }

А теперь то, ради чего мы делали всё это.

Замеры различных способов доступа к полю

Уже знакомый код бенчмарка с новым участником:

package com.github.romanqed;  import com.github.romanqed.jfunc.Exceptions; import org.openjdk.jmh.annotations.*; import org.openjdk.jmh.infra.Blackhole;  import java.lang.reflect.Field; import java.util.concurrent.TimeUnit;  @BenchmarkMode(Mode.AverageTime) @OutputTimeUnit(TimeUnit.NANOSECONDS) @Warmup public class FieldBench {     // Класс с полем     public static class FieldHolder {         public final String helloWorld = "Hello, world!";     }      // Подготавливаем всё     private static final FieldHolder HOLDER = new FieldHolder();      private static final Field FIELD = Exceptions.suppress(() -> FieldHolder.class.getField("helloWorld"));      private static final ProxyGenerator.FieldAccessor ACCESSOR = ProxyGenerator.createAccessor(FIELD);      static {         // Отключаем проверки доступа         FIELD.setAccessible(true);     }      // Обычный доступ     @Benchmark     public void benchPlainGet(Blackhole blackhole) {         blackhole.consume(HOLDER.helloWorld);     }      // Рефлективный доступ     @Benchmark     public void benchReflectGet(Blackhole blackhole) throws Exception {         blackhole.consume(FIELD.get(HOLDER));     }      // Доступ с помощью только что написанного генератора accessor'ов     @Benchmark     public void benchCustomAccessor(Blackhole blackhole) {         blackhole.consume(ACCESSOR.get(HOLDER));     } }

Результаты не могут не радовать:

Benchmark                       Mode  Cnt  Score   Error  Units FieldBench.benchCustomAccessor  avgt   25  0,518 ± 0,017  ns/op FieldBench.benchPlainGet        avgt   25  0,390 ± 0,009  ns/op FieldBench.benchReflectGet      avgt   25  2,319 ± 0,076  ns/op

Accessor оказался медленнее всего в ~1.32 раза!

Способ обойти проверки доступа

В общем случае, к сожалению (или к счастью), такого не существует. Однако Hotspot всегда славился своими многочисленными хаками в sun api, и благодаря эрудированному человеку из комментариев (спасибо тебе, @novoselov) я узнал об этой статье, рассказывающей о некоторых из них.

В том числе и о маркерном классе MagicAccessor, при обнаружении которого в родителях класса линковщик машины пропускает проверки доступа.

Другими словами, после этого любой метод унаследованного класса сможет получить прямой доступ к любому классу и к любому его члену вне зависимости от использованных модификаторов видимости.

Звучит слишком незаконно, я знаю.

И видимо поэтому Oracle решила убрать этот хак из jdk, начиная с 11 версии.

Несмотря на это, в 8 jdk он по-прежнему доступен и, хотя наша статья посвящена новым версиям jdk, слишком крут, чтобы его не попробовать.

Вот что получилось (для экономии места в и без того уже раздутой статье я опустил оставшиеся практически неизменными части кода):

public static FieldAccessor createAccessor(Field field) {     ...     // Создаём генератор будущего прокси-класса,     // задаём режим автоматического вычисления максимальных размеров стека и локальных переменных     ClassWriter writer = new ClassWriter(ClassWriter.COMPUTE_MAXS);     // Пытаемся найти магический класс     Class<?> accessor;     try {         accessor = Class.forName("sun.reflect.MagicAccessorImpl");     } catch (ClassNotFoundException e) {         accessor = null;     }     // Выбираем родительский класс - если найден MagicAccessor, используем его, если нет - Object     Class<?> parent = accessor == null ? Object.class : accessor;     // Объявляем заголовок класса     // public final class AccessorImpl implements FieldAccessor {     writer.visit(Opcodes.V1_8,             Opcodes.ACC_PUBLIC | Opcodes.ACC_FINAL,             name,             null,             Type.getInternalName(parent),             new String[]{Type.getInternalName(FieldAccessor.class)});     // Объявляем пустой конструктор     /*     public AccessorImpl()      */     MethodVisitor ctor = writer.visitMethod(Opcodes.ACC_PUBLIC,             "<init>",             "()V",             null,             null);     // {     ctor.visitCode();     // Загружаем super     ctor.visitVarInsn(Opcodes.ALOAD, 0);     // Вызываем его     ctor.visitMethodInsn(Opcodes.INVOKESPECIAL,             Type.getInternalName(parent),             "<init>",             "()V",             false);     // Не забываем обязательный return; в конце, который компилятор обычно добавляет за нас     ctor.visitInsn(Opcodes.RETURN);     // }     ctor.visitMaxs(0, 0);     ctor.visitEnd();     ... }

Проверяем…

package com.github.romanqed;  public class Main {     private static final int VALUE = 10;      public static void main(String[] args) throws NoSuchFieldException {         ProxyGenerator.FieldAccessor accessor = ProxyGenerator.createAccessor(Main.class.getDeclaredField("VALUE"));         System.out.println(accessor.get(null));     } } 

И это действительно работает! В консоль послушно выводится «10».

Для чистоты эксперимента запустим на всякий случай на 17 jvm, и убедимся, что халява кончилась:

Exception in thread "main" java.lang.IllegalAccessError: class Accessor-1024114085 tried to access private field com.github.romanqed.Main.VALUE (Accessor-1024114085 is in unnamed module of loader com.github.romanqed.ProxyGenerator$Loader @2a84aee7; com.github.romanqed.Main is in unnamed module of loader 'app') at Accessor-1024114085.get(Unknown Source) at com.github.romanqed.Main.main(Main.java:8)

Результаты в цифрах

Для удобства читателей объединим результаты всех сделанных бенчмарков в единую таблицу и взглянем на них ещё раз.

Действие

Скомпилированный код, ns

Java-рефлексия, ns

Мета-лямбды, ns

Динамические прокси, ns

Вызов метода ()Ljava/lang/String;

~0.388

~2.280

~0.388

~0.388

Вызов метода (II)I

~0.388

~7.170

~0.388

~0.520

Доступ к полю

~0.389

~2.332

—

~0.518

Рефлексия откровенно плоха в обоих дисциплинах, что неудивительно, если взглянуть на реализацию JNI (вот в этой статье можно получить общее представление).

Также легко объясняется загадочная просадка более чем в 3 (!) раза при вызове метода, содержащего в параметрах типы-примитивы. Дело в том, что их распаковка и упаковка требует вызовов из api, и нативному коду, вызываемому через JNI, приходится идти обратно на поклон к JVM, отчего возникают дополнительные накладные расходы. Кроме того, свою роль также играет необходимость просто перенести параметры метода из java-массива [Ljava/lang/Object; в вызов метода.

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

На самом деле, под капотом мета-лямбды содержат обыкновенный генератор адаптеров, вызывающих искомый метод. Что-то вроде этого:

// MyClass.java <-- Ваш класс, из которого будет вызываться метод callMe public class MyClass {     public void callMe() {...}  }  // MyAdapter.java <-- Адаптер, который сгенерирует фабрика мета-лямбд // и отдаст вам как некую реализацию желаемого лямбда-интерфейса,  // подходящего по сигнатуре public class MyAdapter implements Consumer<MyClass> {     public void accept(MyClass obj) { obj.callMe(); } }

Тут просто-напросто буквально нечему тормозить.

Что касается динамических прокси, при вызове метода в первом случае создаётся код, чуть менее чем полностью аналогичный мета-лямбдам, что и даёт идентичное время.

А вот во втором случае в копилку накладных расходов добавляются инстанцирование массива под параметры, упаковка и извлечение параметров, а также боксинг примитивов, выражающийся в вызовах вроде Integer#intValue().

Критичным это не становится, потому что там, где требуется вызывать метод с заранее неизвестной сигнатурой, потенциальные накладные расходы на подготовку его параметров с лихвой перекроют разницу.

В случае с доступом к полям объяснения полученных замеров полностью повторяют вышесказанное – рефлективное время содержит жирный след JNI, а прокси тратят лишние ~0.200 ns на боксинг плюс «лишний» вызов метода.

Выводы

Ничего нового. Рефлексия хороша, и без неё мы и шагу ступить не можем, но как только ваша программа выходит из подготовительной стадии и переходит в режим дробилки данных, не надо её использовать. Пожалуйста.

Также в который раз напомню прописную истину – иногда ничего из вышеупомянутого не нужно. Совсем.

Быстрого вам кода.

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


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


Комментарии

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

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