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