Reflection — мощный механизм, который часто понимают неправильно. Эта статья развивает введение в Core Reflection API, представленное в материале «Reflection for the modern Java programmer», и рассматривает две важные дополнительные темы: как reflection реализован в HotSpot JVM и какие изменения были внесены в reflection в последних версиях платформы Java.

Обсуждение начинается с изучения упрощенной формы кода механизма reflection из JDK. Код в следующих примерах напоминает реализацию в Java 8, но для ясности из него убрана часть сложности. Эта базовая версия используется во всех версиях Java, кроме самых новых. (В одной из следующих статей будут рассмотрены последние изменения.)

Начнем с метода invoke() в Method, который выглядит следующим образом:

public Object invoke(Object obj, Object… args)
    throws IllegalAccessException, IllegalArgumentException,
           InvocationTargetException {

  if (!override) {
    if (!Reflection.quickCheckMemberAccess(clazz, modifiers)) {
      Class<?> caller = Reflection.getCallerClass();
      checkAccess(caller, clazz, obj, modifiers);
    }
  }
  MethodAccessor ma = methodAccessor;
  if (ma == null) {
    ma = acquireMethodAccessor();
  }
  return ma.invoke(obj, args);
}

Сначала мы проверяем, установлен ли флаг override; он будет установлен, если был вызван setAccessible(). Затем создаем ссылку на объект MethodAccessor, после чего вызов invoke() делегируется ему.

Примечание: многие классы в этом разделе не находятся в API-пакете java.base, поэтому их нельзя вызывать напрямую в современном Java-коде. Например, MethodAccessor находится в jdk.internal.reflect.

Интерфейс MethodAccessor — ключ к возможности рефлексивного вызова. Он выступает в роли простого делегата.

public interface MethodAccessor {
    /** Matches specification in {@link java.lang.reflect.Method} */
    public Object invoke(Object obj, Object[] args)
        throws IllegalArgumentException, InvocationTargetException;
}

При первом вызове этого кода метод acquireMethodAccessor() создает экземпляр типа DelegatingMethodAccessorImpl, который реализует MethodAccessor, следующим образом:

import java.lang.reflect.InvocationTargetException;

/** Delegates its invocation to another MethodAccessorImpl and can
    change its delegate at runtime. */

class DelegatingMethodAccessorImpl extends MethodAccessorImpl {
  private MethodAccessorImpl delegate;

  DelegatingMethodAccessorImpl(MethodAccessorImpl delegate) {
    setDelegate(delegate);
  }

  public Object invoke(Object obj, Object[] args)
        throws IllegalArgumentException, InvocationTargetException {
    return delegate.invoke(obj, args);
  }

  void setDelegate(MethodAccessorImpl delegate) {
    this.delegate = delegate;
  }
}

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

class NativeMethodAccessorImpl extends MethodAccessorImpl {
  private Method method;
  private DelegatingMethodAccessorImpl parent;
  private int numInvocations;

  // ...

  public Object invoke(Object obj, Object[] args)
          throws IllegalArgumentException, InvocationTargetException {

    if (++numInvocations >
          ReflectionFactory.inflationThreshold()) {
      MethodAccessorImpl acc = (MethodAccessorImpl)
          new MethodAccessorGenerator()
            .generateMethod(method.getDeclaringClass(),
                            method.getName(),
                            method.getParameterTypes(),
                            method.getReturnType(),
                            method.getExceptionTypes(),
                            method.getModifiers());
        parent.setDelegate(acc);
    }

    return invoke0(method, obj, args);
  }

  private static native Object invoke0(Method m, Object obj, Object[] args);

  // ...
}

Этот код содержит блок if, который будет выполнен после достижения порога вызовов, например после того, как рефлексивный метод был вызван определенное число раз. Если порог вызовов еще не достигнут, код продолжает выполнение с нативным вызовом.

После достижения порога NativeMethodAccessorImpl использует фабрику генерации кода, содержащуюся в MethodAccessorGenerator.generateMethod(), чтобы создать пользовательский класс, содержащий байткод, который вызывает “target” рефлексивного вызова.

После создания экземпляра этого динамически созданного класса вызов setDelegate() использует ссылку на родительский accessor более высокого уровня, чтобы заменить текущий объект на acc — вновь созданный пользовательский объект.

Дело в том, что по техническим причинам, связанным с верификацией классов, JVM должна знать об особой природе reflective accessor классов. Поэтому в иерархии наследования есть специальный accessor класс, который служит для JVM маркером. Точные детали здесь нам не интересны.

Что важно заметить: В целом описанный механизм представляет собой компромисс с точки зрения производительности: некоторые рефлексивные вызовы выполняются всего несколько раз, поэтому процесс генерации кода может оказаться очень дорогим или расточительным. С другой стороны, переход из Java в нативный вызов медленнее, чем выполнение в чистой Java. Такой подход позволяет среде выполнения избегать генерации кода до тех пор, пока не станет вероятно, что рефлексивный вызов будет выполняться сравнительно часто.

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

Размышляя о reflection

Можно увидеть больше деталей работы подсистемы reflection (и некоторые недавние изменения), применив reflection к самой подсистеме. Предположим, что в классе есть следующие два метода: один простой метод, который просто выводит сообщение, и один рефлексивный accessor для него:

public static void printStr() {
    System.out.println("Hello world");
}

public Method getMethodObj() throws NoSuchMethodException {
    var selfClazz = getClass();
    var toStr = selfClazz.getMethod("printStr");
    return toStr;
}

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

var m = self.getMethodObj();
Class<?> mClazz = m.getClass();
System.out.println(mClazz);
// This is necessary due to some aspects of lazy evaluation
m.invoke(null);

var f = mClazz.getDeclaredField("methodAccessor");
f.setAccessible(true);
Object ma = f.get(m);
System.out.println(ma.getClass());

Приведенный выше код при запуске с Java 11 дает следующий вывод:

$ java javamag.reflection.ex2.ReflectTheReflect
class java.lang.reflect.Method
Hello world
WARNING: An illegal reflective access operation has occurred
WARNING: Illegal reflective access by javamag.reflection.ex2.ReflectTheReflect (file:/Users/ben/projects/writing/Oracle/Articles/reflection/src/main/java/) to field java.lang.reflect.Method.methodAccessor
WARNING: Please consider reporting this to the maintainers of javamag.reflection.ex2.ReflectTheReflect
WARNING: Use --illegal-access=warn to enable warnings of further illegal reflective access operations
WARNING: All illegal access operations will be denied in a future release
class jdk.internal.reflect.DelegatingMethodAccessorImpl

Здесь нужно учитывать два момента.

Во-первых, необходим первоначальный рефлексивный вызов метода (как мы и сделали). Если этот первоначальный вызов опустить, то код завершится ошибкой. Это связано с ленивой инициализацией подсистемы reflection: по соображениям производительности объекты Method не заполняются, пока они не понадобятся.

Во-вторых, если теперь перейти на Java 17, код завершится ошибкой со следующим выводом:

$ java javamag.reflection.ex2.ReflectTheReflect
class java.lang.reflect.Method
Hello world
java.lang.NoSuchFieldException: methodAccessor
       at java.base/java.lang.Class.getDeclaredField(Class.java:2610)
       at javamag.reflection.ex2.ReflectTheReflect.main(ReflectTheReflect.java:15)

Сбой происходит из-за изменений в видимости, контроле доступа и reflection, как обсуждалось в статье «A peek into Java 17: Encapsulating the Java runtime internals». Больше нельзя считать, что reflection позволяет без ограничений копаться во внутреннем устройстве платформы.

Как reflection влияет на производительность

Легко представить, что гибкость reflection имеет свою цену с точки зрения производительности во время выполнения. Очевидный и немедленный вопрос, который приходит на ум: «Насколько велика эта цена?» Однако в этом вопросе есть скрытое предположение: что сам вопрос вообще осмыслен и корректно сформулирован. Это не всегда так просто.

Конечно, можно написать бенчмарк Java Microbenchmark Harness (JMH), который сравнит рефлексивный вызов с прямым. Такой бенчмарк мог бы выглядеть примерно следующим образом. Аннотации JMH находятся в пакете org.openjdk.jmh.annotations:

@State(Scope.Thread)
@BenchmarkMode(Mode.Throughput)
@OutputTimeUnit(TimeUnit.SECONDS)
public class SimpleReflectionBench {

    private static Method getTime = null;
    private static Object o = null;

    static {
        try {
            var clazz = ReflectionHolder.class;
            var ctor = clazz.getConstructor();
            o = ctor.newInstance();
            getTime = clazz.getMethod("getTime");
        } catch (Exception x) {
            throw new RuntimeException(x);
        }
    }

    @Benchmark
    public long runReflective() throws InvocationTargetException, IllegalAccessException {
        Object ret = getTime.invoke(o);
        return (long)ret;
    }

    @Benchmark
    public long runDirect() {
        var opt = (ReflectionHolder)o;
        return opt.getTime();
    }

    static class ReflectionHolder {
        public ReflectionHolder() {}

        public long getTime() {
            return System.currentTimeMillis();
        }
    }
}

Детали будут немного различаться в зависимости от используемой аппаратной платформы, но типичный результат будет порядка 23%-ного падения производительности.

Но ни одна реальная Java-программа не состоит всего из одного вызова. На практике этот код выполняется внутри процесса приложения (JVM), и вы не можете легко изолировать производительность выполняемого прикладного кода от JIT-компилятора, управления памятью и других подсистем среды выполнения, присутствующих в этом процессе приложения.

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

Например, одна из самых мощных трансформаций, выполняемых JIT-компилятором, — автоматический инлайнинг методов. Фактически это одна из первых оптимизаций, которые выполняются, потому что объединение тел методов открывает JIT-компилятору больше кода. Потенциально это позволяет проводить оптимизации, которые были бы невозможны, если бы JIT-компилятор мог видеть только код одного метода за раз.

Так вот к сожалению, рефлексивные вызовы в общем случае обычно не инлайнятся из-за своей динамической природы. Обратите внимание на слово «обычно», поскольку к этому утверждению есть оговорки.

Как вы уже видели, реализация reflection генерирует Java-байткод (с помощью MethodAccessorGenerator.generateMethod()) для вызова. Это может упростить JIT-компилятору инлайнинг рефлексивного вызова при выполнении определенных условий, например если объект Method закреплен в статическом final-поле, а целевой метод является статическим (или имеет однозначно известный тип получателя).

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

Главный вывод отсюда: нужно ставить под сомнение, что означает упрощенная цифра вроде 23% — и означает ли она вообще что-либо.

Цитируя Брайана Гетца: «Страшная вещь в микробенчмарках заключается в том, что они всегда выдают число, даже если это число бессмысленно. Они что-то измеряют; мы просто не уверены, что именно».

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

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

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

Совет по производительности довольно часто оказывается всего лишь обходным путем для какой-нибудь особенности среды выполнения. И если разработчики приложений знают об этой особенности, то, весьма вероятно, о ней знают и разработчики JVM — и работают над ее исправлением. Чтобы проиллюстрировать это на примере reflection, можно спросить: «Какие механизмы уровня JVM задействованы и могут усложнить общую картину влияния?»

На рис. 1 показана одна строка Java-кода, которая выполняет рефлексивный вызов для объекта Method, обозначенного как M, с аргументами x и y. Она аннотирована так, чтобы показать основные аспекты поведения во время выполнения, которые могут повлиять на производительность выполнения вызова.

Рисунок 1. Влияние JVM на reflection
Рисунок 1. Влияние JVM на reflection

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

  • Boxing происходит в нескольких разных местах.

  • Call site для рефлексивного вызова считается мегаморфным (множество возможных реализаций метода), потому что в реализации до Java 17 у каждого экземпляра Method есть отдельный, динамически созданный method accessor объект (как мы уже видели).

  • Поле methodAccessor в Method является volatile, поэтому его повторное чтение обязательно. Следовательно, вызов invoke() приводит к косвенному обращению и виртуальной диспетчеризации на делегате.

  • Проверки доступности метода выполняются при каждом вызове.

Чтобы разобраться немного глубже, давайте изучим сигнатуру метода invoke() в Method.

public Object invoke(Object obj, Object... args)
        throws IllegalAccessException, IllegalArgumentException, InvocationTargetException

Это самая общая сигнатура Java-метода, которую только можно написать, — и так и должно быть. Рефлексивные вызовы могут иметь любую сигнатуру, и эта информация о типах в общем случае будет доступна только во время выполнения. Поэтому если Method представляет возможность вызвать метод, а invoke() представляет сам вызов, совершенно логично, что сигнатура должна быть настолько общей.

Система типов Java не имеет единого корня (если учесть примитивы), поэтому любые появляющиеся примитивы будут обрабатываться через boxing.

Отдельно важна проверка доступности, потому что контроль доступа обычно проверяется во время загрузки классов (а любые некорректно ведущие себя классы, пытающиеся его нарушить, не загружаются). Использование рефлексивного кода меняет эту картину, и у Core Reflection API есть пара слабых мест (или неизбежных зол — в зависимости от того, как вы к ним относитесь).

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

  • Можно нарушить правила языка Java, позволив вызывающему коду выборочно отключать контроль доступа с помощью setAccessible().

Поэтому реализация Method должна проверять при каждом вызове, требуется ли для объекта Method проверка контроля доступа (и выполнять ее, если требуется). Делать это необходимо при каждом вызове, потому что после предыдущего вызова для объекта Method мог быть вызван setAccessible().

Эти проверки доступности влияют на производительность, что можно увидеть, изменив бенчмарк следующим образом:

static {
        try {
            var clazz = ReflectionHolder.class;
            var ctor = clazz.getConstructor();
            o = ctor.newInstance();
            getTime = clazz.getMethod("getTime");
            // Disable access control checking
            getTime.setAccessible(true);
        } catch (Exception x) {
            throw new RuntimeException(x);
        }
    }

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

Как уже говорилось, правильный объект изучения производительности — все приложение целиком. Это предполагает, что вы могли бы, например, перекомпилировать JDK с изменением кода так, чтобы контроль доступа всегда игнорировался. Теоретически это дало бы более точное число как минимум для одного аспекта рефлексивной производительности в совокупности.

Однако если бы вы так сделали, могли бы вы действительно быть уверены, что глобальное отключение проверок рефлексивного контроля доступа нигде не меняет семантику приложения — или библиотек, от которых оно зависит? В конце концов, возможно, используемый вами фреймворк применяет умную стратегию, которая исследует возможные методы для рефлексивного вызова и полагается на семантику контроля доступа для повышения производительности.

Если бы это было так, как бы вы об этом узнали?

Java 18 меняет все

Все, что обсуждалось в этой статье, относится к Java вплоть до версии 17 включительно. Однако начиная с Java 18 появилась совершенно новая реализация reflection.

Присоединяйтесь к русскоязычному сообществу разработчиков на Spring Boot в телеграм — Spring АйО, чтобы быть в курсе последних новостей из мира разработки на Spring Boot и всего, что с ним связано.