Введение
Привет, Хабр.
С момента выхода в свет предыдущей статьи прошёл год с хвостиком, и у меня наконец-то дошли руки до написания исправленной версии, учитывающей предыдущие косяки с замером времени вызова и несправедливо забытую тему доступа к полям классов.
Ну что же, поехали!
Постановка задачи
Имеем в наличии 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 на боксинг плюс "лишний" вызов метода.
Выводы
Ничего нового. Рефлексия хороша, и без неё мы и шагу ступить не можем, но как только ваша программа выходит из подготовительной стадии и переходит в режим дробилки данных, не надо её использовать. Пожалуйста.
Также в который раз напомню прописную истину – иногда ничего из вышеупомянутого не нужно. Совсем.
Быстрого вам кода.
Спасибо за внимание!
