×Закрыть

Заметки на полях о Java Reflection API

Всем привет, меня зовут Евгений Кузьменко, я Android-разработчик и сегодня хочу рассказать о некоторых интересных моментах, с которыми можно столкнуться при работе с Java Reflection (далее просто рефлексия). Хочу обратить ваше внимание, что это не вводная статья, а скорее набор заметок из личного опыта, о которых будет интересно узнать, а еще это полезно для чуточку большего понимания, что же там происходит «под капотом».

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

Теперь по традиции, несколько слов, что же это такое рефлексия и зачем это все вообще надо. Итак, рефлексия — это средство языка программирования Java, необходимое для получения информации о загруженных в память классах, объектах, интерфейсах и последующей работе с ними на этапе выполнения программы. Зачем это надо? Обработка метаинформации о классах, свойствах, методах, параметрах, посредством обработки аннотаций (привет Retrofit); создание прокси-объектов, например для модульного-тестирования; изменение состояния и/или поведения системы посредством модификации свойств объектов; создание экземпляров классов по заданному типу и многое другое.

Работа с классами через Reflection API

Основным классом для работы с Reflection API является java.lang.Class<T>, экземпляр которого можно получить, например для java.lang.String, несколькими способами:

  • посредством вызова метода на строковом литерале “abc”.getClass(),
  • используя конструкцию Class.forName(“java.lang.String”),
  • через загрузчик классов,
  • просто указав String.class.

Все это и можно условно считать отражением (рефлексией) класса String на класс java.lang.Class<T>. Именно с его помощью мы можем получить всю информацию о загруженном классе такую как: методы класса и всей иерархии классов, реализованные интерфейсы, данные о полях класса, аннотации для которых указан @Retention(value= RetentionPolicy.RUNTIME). Ну вроде бы все понятно и легко, класс мы получили дальше делай все, что душе пожелается, но тут закрался один хитрый момент. При попытке получить класс с помощью вызова метода Class.forName(“com.example.СlassName”) мы можем получить исключение ClassNotFoundException. Хотя мы на 100% уверены, что он присутствует в системе. Как такое может быть? Чтобы ответить на этот вопрос надо немного разобраться с процессом загрузки классов. Конечно подробное обсуждение выходит за рамки данной статьи, но вот основная и упрощенная идея. Есть три основных загрузчика классов, они вызываются иерархически в следующем порядке: системный загрузчик, загрузчик расширений, базовый загрузчик. При загрузке класса происходит поиск данного класса в кэше системного загрузчика, и в случае успешного поиска он возвращает искомый класс, в противном случае — делегирует вышестоящему в иерархии загрузчику. Если мы дошли до базового загрузчика, но в кэше так и не оказалось искомого класса, то в обратном порядке загрузчики пытаются загрузить его, передавая управление уже вниз по иерархии, пока класс не будет загружен, если класс не удалось найти и загрузить будет выброшено исключение ClassNotFoundException.

Теперь важно понять два момента:

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

Логично, что пользовательский загрузчик тоже определяет собственное пространство имен для загружаемых классов. И вот тут и кроется ответ на наш вопрос, откуда же берется этот ClassNotFoundException, если класс загружен в память. Данный класс существует в другом пространстве имен, т.к. был загружен другим загрузчиком и возможно даже в другом процессе (привет WebViewChromium). Так вот метод Class.forName(“com.example.ClassName”) всегда использует загрузчик, с помощью которого он был загружен и выполняет поиск по своему пространству имен. Строго говоря, если пользовательские загрузчики следуют модели делегирования, то через них могут загружаться и классы вышестоящих загрузчиков путем делегирования загрузки, ну а если они не следуют этой модели, то нам необходимо явно указывать загрузчик классов, используя перегруженный метод Class.forName(“com.example.className”, true, classLoader).

Конкретно для Android-платформы мы также можем получить загрузчик классов другого приложения, используя следующий код:

Context someAppContext = context.createPackageContext(
"com.package.SomeClass",
      Context.CONTEXT_INCLUDE_CODE|Context.CONTEXT_IGNORE_SECURITY);

Class<?> cl = Class.forName("com.package.SomeClass", true,
someAppContext.getClassLoader());

или создать экземпляр загрузчика классов из файлов *.apk или *.jar, используя PathClassLoader, DexClassLoader. Пример приведен ниже:

String dexPath = Environment.getExternalStorageDirectory().getAbsolutePath() + File.separator + "someName.jar";


PathClassLoader pathClassLoader = new PathClassLoader(dexPath, getClassLoader());
Class loadedClass1 = pathClassLoader.loadClass("com.example.loader.Class");

DexClassLoader dexClassLoader = new DexClassLoader(dexPath, getDir("dex", 0).getAbsolutePath(), null, getClassLoader());
Class loadedClass2 = dexClassLoader.loadClass("com.example.loader.Class");

Следует также вспомнить о вложенных классах и как такие классы загружать. Конечно, первое, что может прийти в голову — написать что-то вроде:

Class.forName(“com.example.OuterClass.NestedClass”);

Но правильно указать имя класса не получится, если не знать, как после компиляции будет выглядеть вложенный класс, а будет он иметь следующий вид com.example.OuterClass$NestedClass, а значит и загружен он будет точно также, т.е. чтоб такой класс загрузить нам нужно будет вызвать:

Class.forName(“com.example.OuterClass$NestedClass”)

Итак, мы загрузили класс, теперь проясним несколько моментов. Здесь главное понять вот что — getDeclaredMethod возвращает нам методы с любым спецификатором доступа и только для данного класса или интерфейса, а getMethod в свою очередь возвращает только публичные методы, но зато умеет искать методы в родительском классе. Вот и выходит, что универсальным решением выходит использование getDeclaredMethod, но с щепоткой рекурсии:

@Nullable
public static Method getMethod(Class<?> clazz, String methodName, Class<?>... params){
    if (clazz != null) {
        try {
            return clazz.getDeclaredMethod(methodName, params);
        } catch (NoSuchMethodException e) {
            return getMethod(clazz.getSuperclass(), methodName, params);
        }
    }
    return null;
}

Этот же подход можно применить и к методам getField(...) и getDeclaredField(...), т.к. они ведут себя точно также, только возвращают поля класса или интерфейса. Кстати о полях! Всем нам известно, что final поле не может быть изменено. Но мы можем это сделать с помощью рефлексии и вот пример кода:

void setStaticFinalField(Field field, Object newValue) throws Exception {
      field.setAccessible(true); // set private field as public
      Field modifiersField = Field.class.getDeclaredField("modifiers");
      modifiersField.setAccessible(true);
      modifiersField.setInt(field, field.getModifiers() & ~Modifier.FINAL);

      field.set(null, newValue);
}

Для статической переменной мы можем передать null в качестве первого аргумента методу field.set(...), принимающего объект, в котором мы хотим провести изменения. Но вот незадача, если запустить этот код в приложении под Android, то он не будет работать. Но это легко исправить, достаточно заменить имя поля modifiers на accessFlags и final поля поддадутся даже на Андроиде. Ладно, должен признаться, что с final полями на самом деле все немного сложнее. Рассмотрим простой пример:

public class TestClass {
    public final int a;
    public final int b;
    public static final int c = 10;

    public TestClass(int a) {
        this.a = a;
        this.b = 5;
    }

    public void printA() {
        System.out.println("a = " + a);
    }

    public void printB() {
        System.out.println("b = " + b);
    }

    public void printC() {
        System.out.println("c = " + c);
    }
}

public class ReflectionTest {
    public static void main(String[] args) {
        try {
            TestClass test = new TestClass(1);
            System.out.println("before");
            test.printA();
            test.printB();
            test.printC();

            System.out.println("after");

            setFinalField(TestClass.class.getField("a"), 2, test);
            test.printA();

            setFinalField(TestClass.class.getField("b"), 7, test);
            test.printB();

            setFinalField(TestClass.class.getField("c"), 100, null);
            test.printC();

        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    

static void setFinalField(Field field, Object newValue, Object receiver) throws Exception {
      Field modifiersField = Field.class.getDeclaredField("modifiers");
      modifiersField.setAccessible(true);
   	modifiersField.setInt(field, field.getModifiers() & ~Modifier.FINAL);

      field.set(receiver, newValue);
    }
}

Так вот после выполнения данного кода, в консоль будет выведено следующее:

before
a = 1
b = 5
c = 10
after
a = 2
b = 7
c = 10

И внимательный читатель заметит, что мы-то присвоили константе с значение 100, но в выводе консоли значение как было 10, так и осталось. Дело в том, что мы имеем дело с оптимизирующим компилятором javac, который с целью ускорения наших с вами программ, производит некие улучшения нашего кода. В данном случае компилятор пытается провести встраивание констант, которое работает для примитивных типов и java.lang.String. Что это значит? Если на этапе компиляции компилятор уверен, что это константа, и он точно знает ее значение (как в нашем случае с константой с), то просто происходит замена обращения к этой константе на ее значение. Более наглядно это можно увидеть в байткоде. Смотрим, как выглядят методы printB() и printC():

public printB()V
   L0
    LINENUMBER 20 L0
    GETSTATIC java/lang/System.out : Ljava/io/PrintStream;
    NEW java/lang/StringBuilder
    DUP
    INVOKESPECIAL java/lang/StringBuilder.<init> ()V
    LDC "b = "
    …

public printC()V
   L0
    LINENUMBER 24 L0
    GETSTATIC java/lang/System.out : Ljava/io/PrintStream;
    LDC "c = 10"
    …

Нас интересует инструкция LDC, вот здесь и тут можно о ней почитать. Как видим, в приведенном выше примере, в первом случае в пул констант помещается просто строка, а во втором случае уже строка со встроенным значением 10, поэтому наши изменения с помощью рефлексии и не дают видимого результата. А что в Андроиде? А там все аналогично, ведь мы знаем, что сначала java классы компилируются с помощью javac и только потом в DEX байткод. JIT компилятор тоже может производить свои оптимизации на этапе выполнения программы, поэтому это тоже нужно держать в уме. Ну ладно, а что там с остальными final ссылочными типами, которые мы меняем с помощью рефлексии? Строго говоря, изменить final поле можно сразу после создания объекта и до того, как другие потоки получат на него ссылочку, в таком случае все будет гарантированно работать. Но ведь нам-то надо менять когда-то потом, и мы можем это сделать, и оно по идее будет работать, благодаря memory barrier. Ну и что касается Андроида, то, начиная с версии 4.0 (Ice Cream Sandwich), он должен следовать JSR-133(Java Memory Model).

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

Proxy и InvocationHandler

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

Foo f = (Foo) Proxy.newProxyInstance(Foo.class.getClassLoader, 
new Class[] { Foo.class }, handler);

Хорошая новость — мы можем перехватывать вызовы методов данного прокси. А зачем это нужно, ведь мы же можем создать свой экземпляр интерфейса и добавить туда необходимую логику, допустим трассировку вызова методов! Да, разумеется, мы можем, но представьте, что нужно взять некий интерфейс, который существует только в рантайме и в исходном коде нет к нему доступа, да еще этот интерфейс содержит метод обратного вызова, и нам надо знать, когда он вызывается. Вот тут и пригодится нам Proxy с InvocationHandler. Вот пример создания InvocationHandler:

public class SampleInvocationHandler  implements InvocationHandler{
    private Object obj;
    public  SampleInvocationHandler(Object obj) {
        this.obj = obj;
    }
    public Object invoke(Object proxy, Method m, Object[] args)...{
if(m.getName().startsWith("get")){
System.out.println("...get Method Executing...");
}        	
return m.invoke(obj, args);
//return null; //bad idea
    }
}

В данном примере метод invoke(...) будет вызываться всякий раз при вызове любого метода нашего прокси-объекта. Здесь нужно обратить внимание на возвращаемое значение метода invoke(...). Мы не всегда можем располагать объектом obj, а если в интерфейсе, для которого мы сгенерировали прокси, всего один метод, который возвращает void, то может показаться хорошей идеей возвращать null в методе invoke(...). Но тут кроется ошибка, которая может проявить себя намного позже. Просто для сгенерированного прокси добавляются еще стандартные методы класса Object, т.к. все классы от него наследуются по умолчанию. И выходит, что допустим при вызове метода equals(...) или toString() будет возвращаться null, и это приведет к ошибке времени выполнения!

Kotlin и рефлексия

Я думаю многие уже так или иначе присматривались к Kotlin, может даже уже и успели написать несколько приложений, используя его как основной язык программирования. Конечно компания JetBrains позаботилась о совместимости своего детища с Java, но что там с рефлексией? Ведь базовые типы отличаются у этих двух языков, у Kotlin базовый тип Any, а не Object. Да и если мы попытаемся выудить класс с помощью Int::class, то получим KClass… Но мы же только подключили Jackson(Gson?!?) и хотим получать Class, а не KClass! Успокойтесь, выход есть и даже несколько! Смотрим на пример:

val a = 1::class.java //int
val b = 1::class.javaObjectType //class java.lang.Integer
val c = 1::class.javaPrimitiveType //int
val d = 1.javaClass //int

Так, давайте разбираться. В Kotlin все является объектом, а значит мы можем себе легко позволить написать что-то вроде 1::class, 1.2.compareTo(1) и т.д., и с этим все понятно. Теперь у нас с вами в распоряжении есть четыре способа получить класс, но в чем сила брат различие, спросите вы? Подробно разбирать, как происходит процесс маппинга классов Java в Kotlin и обратно мы не будем, т.к. на эту тему можно написать отдельную статью (кстати, может стоит ее написать?) просто рассмотрим вкратце отличия, чтоб было общее понимание. Итак 1::class.java всегда возвращает нам Class<T>, который ассоциирован с данным типом/объектом на уровне стандартной библиотеки языка. Второй пример 1::class.javaObjectType вернет уже объектный/ссылочный тип, а не примитив. Ведь всем нам известно, что в языке Java есть примитивный тип int и ссылочный тип Integer, который так нам необходим для полноценной работы с коллекциями. Т.е. это свойство как раз и возвращает нам именно обертки для примитивных типов в Java. Третий вариант 1::class.javaPrimitiveType вернет снова int, тут важно понять вот что — Kotlin уже внутри содержит маппинг на примитивные типы Java и возвращает их. Если попытаться получить примитивный тип от String, то данное свойство вернет нам null. Четвертый способ быстро получить тип — это использовать 1.javaClass, он будет работать аналогично 1::class.java и, если посмотреть на исходный код данного свойства, то там просто происходит приведение текущего типа в java.lang.Object и взятие его класса с помощью метода getClass().

Более детальную информацию можно получить в официальной документации, а также обратить внимание на описание содержимого пакета kotlin.reflect

Java 7 и новое API для непрямого вызова методов

Теперь две новости — хорошая и плохая. Начну с хорошей — есть альтернативный путь для непрямого вызова методов, не используя рефлексию, а плохая — разработчикам под платформу Андроид этот путь закрыт. Да, конечно, мы можем в проекте использовать switch со строками, ромбовидный оператор и это как бы Java 7, но все мы в душе понимаем, что это лишь «синтаксический обман», а что-то большее спрятано от нас. Вот это именно такой случай с пакетом java.lang.invoke. Android Studio даже будет специально игнорировать этот пакет, чтоб у нас не было соблазна его использовать. Если покопаться в исходниках Android, то можно наткнуться вот на это, а активность по коммитам показывает что работа идет. Вывод — Google работает над этим, ну а время покажет. Ладно, хватит об Андроиде, давайте попробуем разобраться, в чем же основная идея данного механизма вызова методов. Идея в том, что теперь можно получить типизированную ссылку на метод (конструктор, поле) — дескриптор метода. Чтоб было понятнее перейдем к примеру:

MethodHandles.Lookup lookup = MethodHandles.lookup();
MethodHandle toStrMH = lookup.findVirtual(Object.class,"toString", 
MethodType.methodType(String.class));

//String str = (String) toStrMH.invokeExact((Object) this);
String str = (String) toStrMH.invoke(this);

MethodHandles.lookup() определяет контекст поиска метода. Т.е. определив его в своем классе, мы получаем доступ ко всем методам своего класса и к другим методам, к которым мы можем получить доступ непосредственно из нашего класса. Из этого выходит, что мы не можем получить доступ к закрытым методам системных классов, к которым могли бы достучаться через рефлексию. MethodHandle — это и есть дескриптор метода, который включает в себя неизменяемый экземпляр типа MethodType, содержащий возвращаемый тип и набор параметров данного метода. Ну и собственно с помощью методов invokeExact() и invoke() мы можем вызвать метод, на который и указывает MethodHandle. Отличаются они тем, что invokeExact() принимает в качестве аргумента объект именно того типа, который ожидает получить базовый метод, а в нашем случае это тип java.lang.Object. Метод invoke() менее строгий и может проводить дополнительные преобразования над аргументом, с целью подогнать его под необходимый тип. Конечно, нельзя не упомянуть о том, что это все стало возможным благодаря введению новой инструкции invokedynamic и для любознательных рекомендую посмотреть данный доклад.

Java 9

Как подсказали в комментариях к данной статье, в Java 9 появились модули. Что это и чем чревато для нашего кода, использующего рефлексию? Модуль — это именованный, самоописываемый набор кода и данных. С введением модулей, также расширяются правила организации доступа к исходному коду. Каждый модуль содержит файл module-info.java, в котором указаны имя модуля, список всех пакетов, которые считаются публичным API этого модуля и список модулей, от которых зависит данный модуль. Так вот важный момент в том, что публичные классы, которые содержатся в модуле, но не входят в публичный API этого модуля, т.е. находятся в других пакетах, которые не были объявлены в файле module-info.java как экспортируемые — не будут доступны за пределами этого модуля. И вот тут нам не поможет рефлексия. Но зато мы сможем в рантайме получать информацию о модуле, вызвав метод getModule() на экземпляре класса java.lang.Class<T>, который соответствует необходимому нам классу. Здесь можно ознакомиться с так называемым базовым модулем, который будет доступен по умолчанию всем модулям, а значит и будет подвластен рефлексии.

Выводы

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

Почитать по теме

10 комментариев

Подписаться на комментарииОтписаться от комментариев Комментарии могут оставлять только пользователи с подтвержденными аккаунтами.

Да, рефлексия она такая)) Особенно забавно выходит, когда ты где-то заюзал ее, чтобы получить доступ к какому-то свойству класса, и в дебаг-билде все будет чики-пики. А потом собираешь через какое-то время релиз, и оно где-то да как выстрелит после прогарда — сидишь и думаешь: «что за фигня». Лезешь в код и сразу же фейспалм)

В Java 9 еще добавятся модули

я так розумію, в Java 9 setAccessible планують „трохи” обмежити:

This method cannot be used to enable access to private members, members with default (package) access, protected instance members, or protected constructors when the declaring class is in a different module to the caller and the package containing the declaring class is not open to the caller’s module.

Да, спасибо за дополнение!

я бы еще добавил упоминание о MethodHandle классе

Да, спасибо! Обязательно добавлю.

что же там происходит «под капотом».

Когда прочитал это, грешным делом подумал, что статья будет о С++ имплементации JVM ;)

Да, надо было все-таки написать «что же там происходит „в багажнике“.», ведь до капота надо еще добраться, а т.к. это еще и рефлексия, то и сторона будет правильно выбрана.... =)

Подробно разбирать, как происходит процесс маппинга классов Java в Kotlin и обратно мы не будем, т.к. на эту тему можно написать отдельную статью (кстати, может стоит ее написать?)

Всеми руками за

Подписаться на комментарии