Вызов методов через reflection

    Все программисты на Java явно или неявно пользуются reflection для вызова методов. Даже если вы не делали этого сами, это за вас наверняка делают библиотеки или фреймворки, которые вы используете. Давайте посмотрим, как этот вызов устроен внутри и насколько это быстро. Будем глядеть в OpenJDK 8 с последними обновлениями.


    Начать изучение стоит собственно с метода Method.invoke. Там делается три вещи:


    • Проверяются права доступа на метод.
    • Создаётся и запоминается MethodAccessor, если его ещё нет (если данный метод ещё не вызывали через reflection).
    • Вызывается MethodAccessor.invoke.

    Проверка прав доступа состоит из двух частей. Быстрая проверка устанавливает, что и метод, и содержащий его класс имеют модификаторы public. Если это не так, то проверяется, что у вызывающего класса есть доступ к данному методу. Чтобы узнать вызывающий класс, используется приватный метод Reflection.getCallerClass(). Некоторые люди, кстати, любят им пользоваться в своём коде. В Java 9 появится публичный Stack-Walking API и в высшей степени разумно будет перейти на него.


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


    Давайте посмотрим, сколько разные сценарии съедают времени. Напишем простой класс с публичным и непубличным методами:


    public static class Person {
        private String name;
    
        Person(String name) { this.name = name; }
    
        public String getName1() { return name; }
    
        protected String getName2() { return name; }
    }

    Напишем JMH-тест, параметризованный двумя флагами: accessible и nonpublic. Такой у нас будет подготовка:


    Method method;
    Person p;
    
    @Setup
    public void setup() throws Exception {
        method = Person.class.getDeclaredMethod(nonpublic ? "getName2" : "getName1");
        method.setAccessible(accessible);
        p = new Person(String.valueOf(Math.random()));
    }

    И сам бенчмарк:


    @Benchmark
    public String reflect() throws Exception {
        return (String) method.invoke(p);
    }

    Я вижу вот такие результаты (3 форка, 5x500ms прогрев, 10x500ms измерение):


    (accessible) (nonpublic) Time
    true true 5,062 ± 0,056 ns/op
    true false 5,042 ± 0,032 ns/op
    false true 6,078 ± 0,039 ns/op
    false false 5,835 ± 0,028 ns/op

    Действительно если выполнен setAccessible(true), то получается быстрее всего. При этом без разницы, публичный метод или нет. Если же setAccessible(false), то оба теста медленнее и непубличный метод чуть медленнее публичного. Впрочем я ожидал, что различие будет сильнее. Главным образом тут помогает, что Reflection.getCallerClass() — это интринсик JIT-компилятора, который в большинстве случаев подменяется просто константой во время компиляции: если JIT-компилятор инлайнит вызов Method.invoke, он знает, в какой метод он его инлайнит, а значит и знает, что должен вернуть getCallerClass(). Дальше проверка по сути сводится к сравнению пакета вызываемого и вызывающего класса. Если бы пакет был разный, проверялась бы ещё иерархия классов.


    Что же происходит дальше? Дальше нужно создать объект MethodAccessor. Кстати, несмотря на то что Person.class.getMethod("getName") всегда вернёт новый экземпляр объекта Method, используемый внутри MethodAccessor будет переиспользован через поле root, что, конечно, приятно. Тем не менее сам getMethod существенно медленнее вызова, поэтому если вы планируете вызывать метод несколько раз, разумно хранить объект Method.


    Созданием MethodAccessor'а занимается ReflectionFactory. Здесь мы видим два сценария, которые контролируются глобальными настройками JVM:


    • Если установлена опция -Dsun.reflect.noInflation=true (по умолчанию выключена), то сразу генерируется вспомогательный класс, который и будет запускать целевой метод.
    • Иначе создаётся обёртка DelegatingMethodAccessorImpl, внутрь которой помещается NativeMethodAccessorImpl. В свою очередь он считает, сколько раз данный метод вызывали. Если количество вызовов превысило порог, заданный через -Dsun.reflect.inflationThreshold (по умолчанию 15), то происходит «раздувание» аксессора: генерируется вспомогательный класс, как и в первом сценарии. Если же порог не достигнут, вызов идёт честно через JNI. Хотя реализация на стороне C++ тривиальна, накладные расходы на JNI весьма высоки.

    Давайте посмотрим, что будет с нашим тестом, если включить -Dsun.reflect.noInflation=true и если использовать только JNI (для этого зададим большой порог -Dsun.reflect.inflationThreshold=100000000):


    (accessible) (nonpublic) Default noInflation JNI-only
    true true 5,062 ± 0,056 4,935 ± 0,375 195,960 ± 1,873
    true false 5,042 ± 0,032 4,914 ± 0,329 194,722 ± 1,151
    false true 6,078 ± 0,039 5,638 ± 0,050 196,196 ± 0,910
    false false 5,835 ± 0,028 5,520 ± 0,042 194,626 ± 0,918

    Здесь и далее все результаты в наносекундах на операцию. Как и ожидалось, JNI существенно медленнее, поэтому включать такой режим неоправданно. Любопытно, что режим noInflation оказался чуть быстрее. Это происходит из-за того, что отсутствует DelegatingMethodAccessorImpl, который убирает необходимость в одной косвенной адресации. По умолчанию вызов проходит через Method → DelegatingMethodAccessorImpl → GeneratedMethodAccessorXYZ, а с этой опцией цепочка сокращается до Method → GeneratedMethodAccessorXYZ. Вызов Method → DelegatingMethodAccessorImpl мономорфный и легко девиртуализируется, но косвенная адресация всё равно остаётся.


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


    if(polymorph) {
        Method method2 = Person.class.getMethod("toString");
        Method method3 = Person.class.getMethod("hashCode");
        for(int i=0; i<3000; i++) {
            method2.invoke(p);
            method3.invoke(p);
        }
    }

    Обратите внимание, что мы никак не меняли код, производительность которого мы измеряем. Мы просто сделали несколько тысяч с виду бесполезных вызовов перед этим. Однако эти бесполезные вызовы немного подпортят картинку: JIT видит, что вариантов много, и не может подставить единственный возможный, делая теперь честный виртуальный вызов. Результаты будут такие (poly — вариант с превращением вызова метода в полиморфный, на JNI не влияет):


    (acc) (nonpub) Default Default/poly noInflation noInflation/poly JNI-only
    true true 5,062 ± 0,056 6,848 ± 0,031 4,935 ± 0,375 6,509 ± 0,032 195,960 ± 1,873
    true false 5,042 ± 0,032 6,847 ± 0,035 4,914 ± 0,329 6,490 ± 0,037 194,722 ± 1,151
    false true 6,078 ± 0,039 7,855 ± 0,040 5,638 ± 0,050 7,661 ± 0,049 196,196 ± 0,910
    false false 5,835 ± 0,028 7,568 ± 0,046 5,520 ± 0,042 7,111 ± 0,058 194,626 ± 0,918

    Как видно, виртуальный вызов добавляет около 1,5-1,8 нс на моём железе — даже больше, чем проверки доступа. Важно помнить, что поведение виртуальной машины в микробенчмарке может существенно отличаться от поведения в реальном приложении, и по возможности воссоздавать условия, близкие к реальности. Здесь, конечно, от реальности всё ещё далеко: как минимум, все нужные объекты в L1-кэше процессора и сборка мусора не происходит, потому что мусора нет.


    Некоторые могут подумать, круто, мол, что с -Dsun.reflect.noInflation=true всё становится быстрее. Пусть всего на 0,3 нс, но всё же. Да плюс первые 15 вызовов ускорятся. Да и рабочий набор чуть уменьшился, кэш процессора экономим — сплошные плюсы! Добавим опцию в продакшн и заживём! Так делать не надо. В бенчмарке мы протестировали один сценарий, а в природе бывают и другие. Например, какой-нибудь код может по одному разу вызывать множество разных методов. С этой опцией аксессор будет генерироваться сразу на первом вызове. А сколько это стоит? Сколько времени генерируется аксессор?


    Чтобы это оценить, можно через reflection очищать приватное поле Method.methodAccessor (очистив предварительно Method.root), принудив инициализировать аксессор заново. Запись поля через reflection хорошо оптимизирована, поэтому от этого тест сильно не замедлится. Получаем такие результаты. Верхняя строка — ранее полученные результаты (polymorph, accessible), для сравнения:


    (test) Default noInflation JNI
    invoke 6,848 ± 0,031 6,509 ± 0,032 195,960 ± 1,873
    reset+invoke 227,133 ± 9,159 100195,746 ± 2060,810 236,900 ± 2,042

    Как видим, если аксессор сбрасывать, то по дефолту производительность становится немного хуже, чем в варианте с JNI. А вот если мы от JNI полностью отказываемся, то получаем 100 микросекунд на запуск метода. Генерация и загрузка класса в рантайме по сравнению с однократным вызовом метода (даже через JNI), конечно, чудовищно медленна. Поэтому дефолтное поведение «пробовать 15 раз через JNI и только потом генерировать класс» кажется в высшей степени разумным.


    Вообще помните, что нет волшебной опции, которая ускорит любое приложение. Если бы она была, она бы была включена по умолчанию. Какой смысл её прятать от людей? Возможно, есть опция, которая ускорит конкретно ваше приложение, но не принимайте на веру любые советы типа «врубите -XX:+MakeJavaFaster, и всё будет летать».


    Как же выглядят эти сгенерированные аксессоры? Байткод генерируется в классе MethodAccessorGenerator с использованием довольно тривиального низкоуровневого API ClassFileAssembler, которое чем-то похоже на урезанную библиотеку ASM. Классам даются имена вида sun.reflect.GeneratedMethodAccessorXYZ, где XYZ — глобальный синхронизированный счётчик, вы их можете увидеть в стектрейсах и отладчике.


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


    try { Files.write(Paths.get(name+".class"), bytes); } catch(Exception ex) {}

    После этого можно смотреть на класс в декомпиляторе. Для нашего метода getName1() сгенерировался такой код (декомпилятор FernFlower и ручное переименование переменных):


    public class GeneratedMethodAccessor1 extends MethodAccessorImpl {
        public GeneratedMethodAccessor1() {}
    
        public Object invoke(Object target, Object[] args) throws InvocationTargetException {
            if(target == null) {
                throw new NullPointerException();
            } else {
                Person person;
                try {
                    person = (Person)target;
                    if(args != null && args.length != 0) {
                        throw new IllegalArgumentException();
                    }
                } catch (NullPointerException | ClassCastException ex) {
                    throw new IllegalArgumentException(ex.toString());
                }
    
                try {
                    return person.getName1();
                } catch (Throwable ex) {
                    throw new InvocationTargetException(ex);
                }
            }
        }
    }

    Заметьте, сколько приходится делать дополнительных вещей. Надо проверить, что нам передали непустой объект нужного типа и передали пустой список аргументов или null вместо списка аргументов (не все знают, но при вызове через reflection метода без аргументов мы можем передать null вместо пустого массива). При этом надо аккуратно соблюдать контракт: если вместо объекта передали null, то выкинуть NullPointerException. Если передали объект другого класса, то IllegalArgumentException. Если произошло исключение при выполнении person.getName1(), то тогда InvocationTargetException. И это ещё у метода нет аргументов. А если они есть? Вызовем, например, такой метод (для разнообразия теперь статический и возвращающий void):


    class Test {
        public static void test(String s, int x) {}
    }

    Теперь кода существенно больше:


    public class GeneratedMethodAccessor1 extends MethodAccessorImpl {
        public GeneratedMethodAccessor1() {}
    
        public Object invoke(Object target, Object[] args) throws InvocationTargetException {
            String s;
            int x;
            try {
                if(args.length != 2) {
                    throw new IllegalArgumentException();
                }
    
                s = (String)args[0];
                Object arg = args[1];
                if(arg instanceof Byte) {
                    x = ((Byte)arg).byteValue();
                } else if(arg instanceof Character) {
                    x = ((Character)arg).charValue();
                } else if(arg instanceof Short) {
                    x = ((Short)arg).shortValue();
                } else {
                    if(!(arg instanceof Integer)) {
                        throw new IllegalArgumentException();
                    }
    
                    x = ((Integer)arg).intValue();
                }
            } catch (NullPointerException | ClassCastException ex) {
                throw new IllegalArgumentException(ex.toString());
            }
    
            try {
                Test.test(s, x);
                return null;
            } catch (Throwable ex) {
                throw new InvocationTargetException(ex);
            }
        }
    }

    Заметьте, что вместо int мы имеем право передать Byte, Short, Character или Integer, и всё это обязано преобразоваться. Именно здесь преобразование и идёт. Такой блок будет добавляться для каждого примитивного аргумента, где возможно расширяющее преобразование. Теперь также понятно, зачем в catch ловится NullPointerException: он может возникнуть при анбоксинге и тогда мы обязаны также выдать IllegalArgumentException. Зато благодаря тому, что метод статический, нас совершенно не волнует, что в параметре target. Ну и появилась строчка return null, потому что наш метод возвращает void. Вся эта магия аккуратно расписана в MethodAccessorGenerator.emitInvoke.


    Вот так примерно и работает вызов методов. Похожим образом устроен вызов конструкторов. Также этот код частично переиспользован для десериализации объектов. Когда аксессор уже существует, с точки зрения JVM он не особо отличается от кода, который вы бы написали сами вручную, поэтому рефлекшн начинает работать весьма быстро.


    В заключение отмечу, что начиная с Java 7 появилось API java.lang.invoke которое тоже позволяет вызывать методы динамически, но работает оно совсем по-другому.

    Поделиться публикацией
    Похожие публикации
    Ой, у вас баннер убежал!

    Ну. И что?
    Реклама
    Комментарии 14
    • +1

      Видел подобные тесты, которые показывают, что т.н. новый "reflection API" в Java 8 и выше, довольно быстр, что не может не радовать. Но MethodHandle (конкретно в Oracle JDK 8 build 65) вызывал у нас в проекте ошибку OutOfMemory, аналогично ситуации, описанной здесь: https://bugs.openjdk.java.net/browse/JDK-7021343.
      Пришлось отказаться от его использования.

      • +1

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

        • +2
          MethodHandle.invoke, хоть и реализован по-другому, но делает примерно всё то же, что и Method.invoke, за исключением проверок доступа. Поэтому большого выигрыша сами по себе MethodHandles не дают. Чтобы получить максимальную выгоду от java.lang.invoke, имеет смысл MethodHandle преобразовать в экземпляр интерфейса с помощью LambdaMetafactory. И тогда вызов будет столь же быстрым, как и обычный invokeinterface.
        • +3
          реализация на стороне C++ тривиальна
          Ой ли? :)

          Вся сложность нативной рефлексии скрыта именно внутри JVM кода, чему посвящён целый класс на 1000+ строк кода. Здесь и ресолвинг метода, и преобразование типов, и всевозможные проверки, и перекладывание аргументов. И всё это интерпретируется каждый раз заново для каждого вызова, на что как раз и тратится уйма времени.
          • 0

            Согласен, коряво написал. Я имел в виду, что сам native-метод короткий. Понятно, что эти 200 наносекунд куда-то уходят, значит, где-то есть код, который в это время выполняется, и он не очень короткий.

          • 0

            А что будет в генерированном классе если вызываемый через рефлекшн метод бросает interrupted exception?

            • 0

              Как видно из генерированного кода, абсолютно любое исключение в вызываемом методе обернётся в InvocationTargetException.

            • 0

              Хотя какая разница. Клиент должен это хэндлить.

              • 0
                Хорошая статья. Можно ли сказать, что для производительности лучше избегать вызова методов через рефлексию, если в этом нет необходимости?
                • +1

                  Если нет необходимости, конечно, лучше избегать. Хотя бы ради читаемости кода. Всяко же лучше p.getName(), чем Person.class.getMethod("getName").invoke(p)? :-)


                  С восьмой джавы некоторые сценарии динамического вызова с рефлекшном можно заменить на ссылки на методы. Скажем, раньше вы куда-то передавали класс и имя метода, который вам должен что-то возвращать: doSomething(Person.class, "getName") и внутри делали вот это всё clazz.getMethod. Теперь это можно заменить на doSomething(Person::getName) (тип аргумента Supplier<String>). Это сделает код более читаемым и быстрым.

                • 0
                  Возможно кто-то знает, есть ли для андроида решения вроде MethodHandle, позволяющее избежать генерации вызываемого кода руками?
                  • 0
                    На сколько я знаю, в андроиде есть поддержка лямбд и ссылок на методы, значит хендлы как то юзаются.
                    • 0
                      Я детально не в курсе, но вроде 8 Джава поддерживается только в Андроиде 7.0. А лямбды реализованы через анонимные классы.
                      • 0
                        https://developer.android.com/guide/platform/j8-jack.html
                        нужно еще новый компилятор юзать

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

                  Самое читаемое