В этой статье я расскажу о классах-двойниках в ART и использовании этого механизма для получения полного списка полей, методов и конструкторов других классов, а также конвертации конструктора в метод и его вызове на готовом объекте.
Содержание
Классы-двойники
В прошлой части мы познакомились с arrayCast`ом — он позволяет использовать один объект как совершенно другой. В основе данного явления лежит тот факт, что данные — это просто набор байтов, и как их интерпретировать, зависит лишь от нас. Этим очень часто пользуется виртуальная машина андроида, храня объекты как видоизменённые указатели на MirrorType — классы-двойники, которые представляют собой структуры языка C++ с определённым образом расставленными полями (чтобы макеты совпадали между языками).
Примеры двойников
// C++ mirror of java.lang.Object
class MANAGED LOCKABLE Object {
<...>
// The Class representing the type of the object.
HeapReference<Class> klass_;
// Monitor and hash code information.
uint32_t monitor_;
<...>
};
// C++ mirror of java.lang.Throwable
class MANAGED Throwable : public Object {
<...>
HeapReference<Object> backtrace_;
HeapReference<Throwable> cause_;
HeapReference<String> detail_message_;
HeapReference<Object> stack_trace_;
HeapReference<Object> suppressed_exceptions_;
<...>
};
// C++ mirror of java.lang.String
class MANAGED String final : public Object {
<...>
// If string compression is enabled, count_ holds the StringCompressionFlag in the
// least significant bit and the length in the remaining bits, length = count_ >> 1.
int32_t count_;
uint32_t hash_code_;
// Compression of all-ASCII into 8-bit memory leads to usage one of these fields
union {
uint16_t value_[0];
uint8_t value_compressed_[0];
};
<...>
};
Если сравнить порядок полей в исходном коде java и C++ классов, то можно заметить, что он не всегда совпадает. Это связанно с разными механизмами расстановки полей в этих языках. Чтобы удостовериться в правильности порядка виртуальная машина проходит через процедуру "ValidateFieldOrderOfJavaCppUnionClasses" — ряд внутренних тестов.
Мы будем создавать свои классы-двойники на языке java, так что можно считать, что все тесты пройдены заранее. Главное требование работоспособности такого класса — наличие всех полей с теми же типами и именами, что и в исходном классе, при этом модификаторы никак не влияют на расстановку.
Залезаем в класс
Внимание! Все дальнейшие действия для повторения требуют android 8.0+ и не будут работать на предыдущих версиях. Верхнее ограничение не известно, но на момент написания статьи с android 14 DP2 всё работает.
Далее нам необходимо сделать двойников нескольких классов из стандартной библиотеки:
Используемые в статье классы-двойники
public class ClassMirror {
public ClassLoader classLoader;
public Class<?> componentType;
public Object dexCache;
public Object extData;
public Object[] ifTable;
public String name;
public Class<?> superClass;
public Object vtable;
public long iFields;
public long methods;
public long sFields;
public int accessFlags;
public int classFlags;
public int classSize;
public int clinitThreadId;
public int dexClassDefIndex;
public volatile int dexTypeIndex;
public int numReferenceInstanceFields;
public int numReferenceStaticFields;
public int objectSize;
public int objectSizeAllocFastPath;
public int primitiveType;
public int referenceInstanceOffsets;
public int status;
public short copiedMethodsOffset;
public short virtualMethodsOffset;
}
public class AccessibleObjectMirror {
public boolean override;
}
public class FieldMirror extends AccessibleObjectMirror {
public int accessFlags;
public Class<?> declaringClass;
public int artFieldIndex;
public int offset;
public Class<?> type;
}
public class ExecutableMirror extends AccessibleObjectMirror {
public volatile boolean hasRealParameterData;
public volatile Parameter[] parameters;
public int accessFlags;
public long artMethod;
public Class<?> declaringClass;
public Class<?> declaringClassOfOverriddenMethod;
public int dexMethodIndex;
}
public class MethodHandleMirror {
public MethodType type;
// В этом поле есть разница между версиями,
// поэтому порядок может быть нарушен,
// но в данном случае это не важно, т.к.
// мы пользуемся этим классом только как "заполнителем"
public Object different;
public MethodHandleImplMirror cachedSpreadInvoker;
public int handleKind;
public long artFieldOrMethod;
}
public class MethodHandleImplMirror extends MethodHandleMirror {
public HandleInfoMirror info;
}
public class HandleInfoMirror {
public Member member;
public MethodHandleImplMirror handle;
}
Обратим внимание на поля iFields, sFields и methods в ClassMirror — они имеют тип long, и содержат численное значение нативных указателей на C++ класс LengthPrefixedArray.
template<typename T>
class LengthPrefixedArray {
<...>
uint32_t size_;
uint8_t data_[0];
<...>
};
Заметка: Аппаратные указатели могут иметь размер 32 или 64 бит, в зависимости от разрядности процессора, но все целочисленные типы java имеют константный размер, поэтому используется тот, что вмещает больше. На 32-битных машинах половина числа не используется — не очень экономно, зато просто.
В iFields и sFields этот массив хранит экземпляры ArtField, полностью описывающего поле. Префикс s означает статические поля, i — поля экземпляра. В methods хранятся ArtMethod`ы, описывающие все методы и конструкторы (Деление очень условное, ведь конструктор это всего лишь метод со специальным названием. Позже это знание нам ещё пригодится). Из указателей на эти структуры андроид умеет "материализовать" экземпляры Field, Method и Constructor — а ведь это именно то, что нам нужно!
Небольшое отступление
Для удобства, код, который не должен бросать исключений, но требует их обработки, можно обернуть в лямбду и вызвать с throwException из прошлой части статьи:
Обёртка
@FunctionalInterface
public interface TRun<T> {
public T run() throws Throwable;
}
public static <T> T nothrows_run(TRun<T> r) {
try {
return r.run();
} catch (Throwable th) {
Thrower.throwException(th);
throw new RuntimeException(th);
}
}
// void версия
@FunctionalInterface
public interface VTRun {
public void run() throws Throwable;
}
public static void nothrows_run(VTRun r) {
try {
r.run();
} catch (Throwable th) {
Thrower.throwException(th);
throw new RuntimeException(th);
}
}
Получаем все Executable
Если внимательно посмотреть на реализацию LengthPrefixedArray, то можно заметить, что непосредственное начало данных может иметь отступ для выравнивания.
static size_t OffsetOfElement(size_t index,
size_t element_size = sizeof(T),
size_t alignment = alignof(T)) {
DCHECK_ALIGNED_PARAM(element_size, alignment);
// Округление смещения до выравнивания
return RoundUp(offsetof(LengthPrefixedArray<T>, data_), alignment) + index * element_size;
}
Нам необходимо повторить эту функцию на java, но есть проблема — мы не знаем ни выравнивание, ни размер хранимых объектов. К счастью, можно подсмотреть готовые значения в тестовом классе.
Каждый метод и конструктор наследуется от общего для них класса Executable. Он содержит основную логику и все поля. Одно из них — artMethod — это готовый указатель на интересующую нас структуру. По его значению в двух ближайших методах можно узнать размер структуры, а дальше несложными математическими действиями получить смещение.
public class ArtMethod_Test {
public static final Method method_a =
nothrows_run(() -> Test.class.getDeclaredMethod("a"));
public static final Method method_b =
nothrows_run(() -> Test.class.getDeclaredMethod("b"));
public static void a() {}
public static void b() {}
}
long getArtMethod(Executable ex) {
// Приводим ex к типу ExecutableMirror
ExecutableMirror[] mirror = arrayCast(ExecutableMirror.class, ex);
return mirror[0].artMethod;
}
// Приводим ArtMethod_Test.class к типу ClassMirror
ClassMirror[] test = arrayCast(ClassMirror.class, ArtMethod_Test.class);
long am = getArtMethod(ArtMethod_Test.method_a);
long bm = getArtMethod(ArtMethod_Test.method_b);
// Размер каждого ArtMethod
int artMethodSize = (int) (bm - am);
// Размер в байтах uint32_t поля size_ в LengthPrefixedArray
final int size_field_length = 4;
// Смещение от начала указателя, до первого ArtMethod
int artMethodPadding = (int) (am - test[0].methods - size_field_length)
% artMethodSize + size_field_length;
Теперь у нас есть всё необходимое, чтобы насильно вытащить из класса все его Executable
// Класс, который понадобится далее
static final Class<MethodHandle> MethodHandleImplClass = nothrows_run(() -> {
return (Class<MethodHandle>) Class.forName("java.lang.invoke.MethodHandleImpl");
});
Executable[] getDeclaredExecutables0(Class<?> clazz) {
Objects.requireNonNull(clazz);
// Приводим класс к ClassMirror
ClassMirror[] clazz_mirror = arrayCast(ClassMirror.class, clazz);
long methods = clazz_mirror[0].methods;
// Указатель равен 0, здесь пусто
if (methods == 0) {
return new Executable[0];
}
// Читаем количество
int col = getInt(methods);
Executable[] out = new Executable[col];
if (out.length == 0) {
return out;
}
// Этот объект умеет "материализовать" Executable и Field
MethodHandle mh = allocateInstance(MethodHandleImplClass);
MethodHandleImplMirror[] mhm = arrayCast(MethodHandleImplMirror.class, mh);
for (int i = 0; i < col; i++) {
// Вставляем в специальное поле наш указатель
mhm[0].artFieldOrMethod = methods + artMethodPadding + artMethodSize * i;
// Обнуляем "материализатор"
mhm[0].info = null;
// Запускаем!
out[i] = MethodHandles.reflectAs(Executable.class, mh);
}
// Возвращаем то, что получилось
return out;
}
На чём же опробовать этого монстра? А чего мелочиться, давайте на Long! Там есть много интересного.
Arrays.stream(getDeclaredExecutables0(Long.class)).forEach((ex) -> {
System.out.print(Modifier.toString(ex.getModifiers()) + " ");
System.out.print(ex instanceof Method ? "method " : "constructor ");
System.out.print(ex.getName());
String parameters = Arrays.stream(ex.getParameterTypes())
.map(Class::getName)
.collect(Collectors.joining(", ", "(", ")"));
System.out.println(parameters);
});
// System.out:
// static constructor java.lang.Long()
// public constructor java.lang.Long(long)
// public constructor java.lang.Long(java.lang.String)
// public static method bitCount(long)
// <...>
// private static method toUnsignedBigInteger(long)
// <...>
// public method toString()
И первая же выведенная строка показывает, зачем надо было городить свою функцию вывода: static constructor! Да-да, это тот самый статический конструктор, который вызывается при инициализации класса, и который присваивает значения static переменным. Штатный toString не печатает этот модификатор, поэтому мы бы ничего не заметили. С private методами тоже справились, а это победа. Осталось только профильтровать вывод, отделив методы от конструкторов, но это я оставлю на самостоятельное написание (либо можно взять готовое решение).
Небольшое отступление 2
Методы и поля с модификатором private не очень охотно дают собой управлять. Как хорошо, что они наследуют от AccessibleObject прекрасный метод setAccessible. Ах, если бы только на нём не было защиты... Напишем свой!
void setAccessible(AccessibleObject ao, boolean value) {
AccessibleObjectMirror[] aom = arrayCast(AccessibleObjectMirror.class, ao);
aom[0].override = value;
}
Получаем все поля
Процедура практически идентична предыдущей, за исключением нескольких моментов. Первый из них — отсутствие поля artField и наличие метода getArtField. Забавно, ведь мы только что поняли как его получить и вызвать.
public class ArtField_Test {
public static final Field field_a =
nothrows_run(() -> Test.class.getDeclaredField("sa"));
public static final Field field_b =
nothrows_run(() -> Test.class.getDeclaredField("sb"));
public static int sa, sb;
}
Method mGetArtField = getDeclaredMethod(Field.class, "getArtField");
long getArtField(Field f) {
return (long) nothrows_run(() -> mGetArtField.invoke(f));
}
// Приводим ArtField_Test.class к типу ClassMirror
ClassMirror[] test = arrayCast(ClassMirror.class, ArtField_Test.class);
long af = getArtMethod(ArtField_Test.field_a);
long bf = getArtMethod(ArtField_Test.field_b);
// Размер каждого ArtField
int artFieldSize = (int) (bf - af);
// Размер в байтах uint32_t поля size_ в LengthPrefixedArray
final int size_field_length = 4;
// Смещение от начала указателя, до первого ArtField
// Обратите внимание, что используется sFields т.к. поле статическое
int artFieldPadding = (int) (am - test[0].sFields - size_field_length)
% artFieldSize + size_field_length;
Смещение и размер узнали? Создаём метод получение полей. У него есть особенность — статические поля и поля экземпляра хранятся в разных местах, поэтому за один раз всё сделать не удастся.
Field[] getDeclaredFields0(Class<?> clazz, boolean s) {
Objects.requireNonNull(clazz);
// Приводим класс к ClassMirror
ClassMirror[] clazz_mirror = arrayCast(ClassMirror.class, clazz);
// Выбираем, где искать
long fields = s ? clazz_mirror[0].sFields : clazz_mirror[0].iFields;
// Указатель равен 0, здесь пусто
if (fields == 0) {
return new Field[0];
}
// Читаем количество
int col = getInt(fields);
Field[] out = new Field[col];
if (out.length == 0) {
return out;
}
// Создаём "материализатор"
MethodHandle mh = allocateInstance(MethodHandleImplClass);
MethodHandleImplMirror[] mhh = arrayCast(MethodHandleImplMirror.class, mh);
for (int i = 0; i < col; i++) {
// Вставляем в специальное поле наш указатель
mhh[0].artFieldOrMethod = fields + artFieldPadding + artFieldSize * i;
// Обнуляем "материализатор"
mhh[0].info = null;
// Сигнал, что это поле, а не метод (подробности ниже)
mhh[0].handleKind = Integer.MAX_VALUE;
// Запускаем!
out[i] = MethodHandles.reflectAs(Field.class, mh);
}
// Возвращаем то, что получилось
return out;
}
// Объединение всех полей
Field[] getDeclaredFields(Class<?> clazz) {
Field[] sout = getDeclaredFields0(clazz, true);
Field[] iout = getDeclaredFields0(clazz, false);
Field[] all = new Field[sout.length + iout.length];
System.arraycopy(sout, 0, all, 0, sout.length);
System.arraycopy(iout, 0, all, sout.length, iout.length);
return out;
}
Здесь проявилась вторая особенность — reflectAs смотрит на вид того MethodHandle, что мы ему скармливаем, и в зависимости от этого создаёт метод или поле. Некорректные данные приводят к падению ВМ, а нам этого не надо.
Реализация getMemberInternal
java_lang_invoke_MethodHandleImpl.cc
static jobject MethodHandleImpl_getMemberInternal(JNIEnv* env, jobject thiz) {
<...>
if (handle_kind >= mirror::MethodHandle::kFirstAccessorKind) {
ArtField* const field = handle->GetTargetField();
<...>
} else {
ArtMethod* const method = handle->GetTargetMethod();
if (method->IsConstructor()) {
<...>
} else {
<...>
}
}
<...>
}
Проверка осуществляется сравнением handle_kind с неким kFirstAccessorKind. Мы его знаем? На самом деле да, но это значение потенциально может смениться в будущих версиях андроида. Поэтому поступим проще — мы точно знаем, что kFirstAccessorKind положительное число, и всё, что меньше него — метод, больше — поле. Тогда для первого берём 0, а для второго Integer.MAX_VALUE, и всё замечательно работает.
Испытаем на Object:
Arrays.stream(getDeclaredFields(Object.class)).forEach((f) -> {
System.out.println(f);
});
// System.out:
// private transient java.lang.Class java.lang.Object.shadow$_klass_
// private transient int java.lang.Object.shadow$_monitor_
Список небольшой, но зато какой! Эти поля есть у любых объектов, что системных, что пользовательских. Я уже упоминал их в прошлой части с описанием предназначения, поэтому останавливаться здесь не буду.
Конвертация конструктора в метод
Выше было упомянуто, что конструктор это специальный метод, но можно ли на практике перевести одно в другое? Да! Все поля находятся в классе Executable, а значит совпадают у конструкторов и методов. Их можно просто скопировать и посмотреть, что получится.
public static Method convertConstructorToMethod(Constructor<?> ct) {
// Создаём новый метод
Method out = allocateInstance(Method.class);
// Приводим его и конструктор к ExecutableMirror
ExecutableMirror[] eb = arrayCast(ExecutableMirror.class, ct, out);
// Копируем значения
eb[1].override = eb[0].override;
eb[1].accessFlags = eb[0].accessFlags;
eb[1].artMethod = eb[0].artMethod;
eb[1].hasRealParameterData = eb[0].hasRealParameterData;
eb[1].declaringClass = eb[0].declaringClass;
eb[1].dexMethodIndex = eb[0].dexMethodIndex;
eb[1].hasRealParameterData = eb[0].hasRealParameterData;
eb[1].parameters = eb[0].parameters;
return out;
}
Выглядит довольно просто, испытаем на тестовом классе
public class Test {
public Test() {}
}
Constructor<Test> c = nothrows_run(() -> Test.class.getDeclaredConstructor());
System.out.println(convertConstructorToMethod(c));
// System.out: public void Test.<init>()
Если это читают люди знакомые со smali, то они догадаются, почему так произошло. В байткоде конструктор отличается от обычного метода лишь специальным именем "<init>" и разрешением изменять final поля. Отдельно стоит отметить, что его возвращаемый тип — void, и реально конструктор не создаёт объект, а лишь вызывается на готовом. Теперь мы попробуем вызвать его сами:
public class Test {
public Test() {
System.out.println("hello test");
}
}
Constructor<Test> c = nothrows_run(() -> Test.class.getDeclaredConstructor());
Method m = convertConstructorToMethod(c);
Test obj = allocateInstance(Test.class);
nothrows_run(() -> m.invoke(obj));
nothrows_run(() -> m.invoke(obj));
nothrows_run(() -> m.invoke(obj));
// System.out:
// hello test
// hello test
// hello test
Нам удалось вызвать конструктор на одном и том же объекте несколько раз! А что будет, если при этом присваивается final поле?
public class Test {
private final double value;
public Test() {
value = Math.random();
System.out.println(value);
}
}
<...>
nothrows_run(() -> m.invoke(obj));
nothrows_run(() -> m.invoke(obj));
nothrows_run(() -> m.invoke(obj));
// System.out:
// 0.9965129743657091
// 0.3030674376485418
// 0.46234661822536893
Действительно, значение меняется каждый раз. Практического применения я этому, увы, не нашёл.
Статический конструктор
Помните, как мы нашли странный конструктор с модификатором static? Пора испытать его в разных ситуациях. Для начала необходимо его найти:
Constructor<?> getDeclaredStaticConstructor(Class<?> clazz) {
Constructor[] out = Arrays.stream(getDeclaredExecutables0(clazz))
.filter((exec) -> exec instanceof Constructor
&& Modifier.isStatic(exec.getModifiers()))
.toArray(Constructor[]::new);
if (out.length == 0) {
return null;
}
return out[0];
}
Обычный конструктор имеет метод newInstance, при вызове которого выделятся объект и на нём вызывается исполнение кода. Но что произойдёт если вызывать статический конструктор на экземпляре? А ведь именно это и произойдёт, если просто вызвать newInstance.
public class Test {
public int value = 100500;
static {
System.out.println("hello test");
}
}
Constructor<Test> c = getDeclaredStaticConstructor(Test.class);
Test obj = nothrows_run(() -> c.newInstance());
System.out.println(obj.value);
// System.out:
// hello test
// hello test
// 0
Всё произошло достаточно очевидным образом: сначала статический конструктор вызвался при инициализации класса, мы видим это в консоли как первое сообщение "hello test", затем был выделен новый объект, и второй раз вызвался статический конструктор, он напечатал ещё одно сообщение "hello test", а объект вернулся не инициализированным, с нулевыми полями, так как статический конструктор его не трогал. Это видно по нулевому значению, которое мы вывели позже. Для полной картины остался всего один шаг — перевести статический конструктор в метод и посмотреть на его поведение в таком виде:
public class Test {
public int value = 100500;
static {
System.out.println("hello test");
}
}
Constructor<Test> c = getDeclaredStaticConstructor(Test.class);
Method m = convertConstructorToMethod(c);
System.out.println(m);
nothrows_run(() -> m.invoke(null));
// System.out:
// static void Test.<clinit>()
// hello test
// hello test
Конструктор остался рабочим, но по прежнему вызывается 2 раза. А имя снова может быть известно знатокам smali — "<clinit>". Применения этому я опять не нашёл, но выглядит забавно.
На этом вторая часть подходит к концу.
Весь исходный код можно найти здесь.
Комментарии (5)
bodyawm
00.00.0000 00:00+1В джаве не хватает жесткого unsafe а-лz c#, где unsafe-операции - важная часть языка. Пока в джаве есть только java.nio.*, на шарпе я напрямую мог выделить кусок кучи, записать в неё например сгенерированную текстуру и отправить на гпу. Меши у меня тоже выгружались моментально, просто локами/анлоками массивов с вершинами/индексами. А в джаве приходилось заполнять вручную флоатбуфферы.
v7878 Автор
00.00.0000 00:00+3Вы не совсем правы, что его нет, потому что с вроде с 16 версии jdk есть занимательный инкубатор, который содержит полноценное околобезопсное API для доступа к нативной памяти и функциям. И небольшой спойлер - я занимаюсь его портированием на андроид (хотя скорее будет просто аналог, а не прямой порт)
kunix
00.00.0000 00:00+2Я сам регулярно использую reflection, но вот это ваше все - просто next level :)
v7878 Автор
00.00.0000 00:00+1Очень прошу делиться своим мнением о статье, найденный косяках, кривых формулировках или несоответствии теории и практики. Я только начинающих хабровчанин, и это моя вторая статья, так что ваши замечания помогут в улучшении качества контента
v7878 Автор
Очень прошу делиться своим мнением о статье, найденный косяках, кривых формулировках или несоответствии теории и практики. Я только начинающих хабровчанин, и это моя вторая статья, так что ваши замечания помогут в улучшении качества контента