Привет! Меня зовут Григорий Юрков, и я уже несколько лет работаю в инфраструктурной команде Яндекс Маркета. Два года назад мы начали разрабатывать свой легковесный DI-фреймворк Scout, который предоставляет выразительный Kotlin DSL. Он не генерирует код, а делает всю работу в рантайме.
Недавний переход с compile-time-библиотеки Dagger 2 на нашу привёл к замедлению старта приложения. Подробнее об этом и о том пути, который мы прошли от идеи до публикации в опенсорс, можно прочитать в статье моего коллеги Александра Миронычева.
В этой статье мы будем подробно рассматривать то, как применение байт-кода помогло сохранить скорость на том же уровне и спасти проект по миграции на Scout.
Весь код в этой статье упрощён для понимания. Также не стоит забывать, что библиотека постоянно изменяется и улучшается. Если вы хотите посмотреть настоящий код, который представлен в библиотеке, то прошу в наш GitHub.
Почему стало медленнее
Чтобы вникнуть в проблему, которая возникла перед нами, предлагаю немного познакомиться с библиотекой. Вот так выглядит самый простой пример кода на Scout:
class MyClass
val scope = scope("my-scope") {
factory<String> { "my-string" }
factory<MyClass> { MyClass() }
}
fun main() {
println(scope.get<String>()) // "my-string"
println(scope.get<MyClass>()) // MyClass
}
Мы создаём factory
для двух типов — String
и MyClass
. Эти factory
лежат в общем Scope
под названием my-scope
. С помощью get()
мы можем получить указанные типы, используя зарегистрированные в Scope
фабрики.
Простейшая реализация Scope
выглядела бы так:
typealias Key = Class<*>
object Keys {
@JvmStatic fun create(keyClass: Class<*>): Key = keyClass
}
class Scope {
internal val factories = HashMap<Key, () -> Any?>()
inline fun <reified T> factory(noinline factory: () -> T) {
val key: Key = Keys.create(T::class.java)
factories[key] = factory
}
inline fun <reified T> get() {
val key: Key = Keys.create(T::class.java)
return scope.factories[key].invoke()
}
}
Этот код сильно упрощён, но смысл остаётся тот же: мы просто берем HashMap и по классу складываем туда наши factory, а потом по тому же классу достаём и аллоцируем объект.
Такой код работает сильно медленнее Dagger 2, и тому есть две основные причины:
Мы используем
Class
в качестве ключа.Вызов
T::class.java
триггеритclass loading
.
С первым вроде всё понятно: HashMap
постоянно вызывает hashCode()
и equals()
, чтобы класть и доставать объекты. Для Class
эти функции не такие быстрые, как хотелось бы.
Со вторым всё интереснее. Когда мы вызываем MyClass::class.java
, чтобы получить Class
, мы триггерим загрузку этого класса в оперативную память. Это означает, что Java Virtual Machine (или Android Runtime) нужно прочитать класс из JAR- или DEX-файла и запарсить его. Этот процесс очень долгий, и мы бы хотели его избежать как минимум на этапе создания factory
.
Как сделать быстрее
Мы можем убить двух птиц одним камнем, заменив ключи нашей мапы на Int
. Тогда мы избавимся от HashMap
в пользу чего-то более быстрого, а также избавимся от class loading
. Осталось самое сложное: как заменить Class
на Int
?
Первое, что пришло в голову, — написать плагин на компилятор Kotlin, чтобы он заменял Class
на Int
. Но, во-первых, было совершенно непонятно, как подходить к этой проблеме. Во-вторых, мы опасались, что будем сильно зависеть от версии Kotlin и его внутреннего API, а это непременно доставит нам хлопот в будущем.
Тогда мне пришла в голову идея: а что, если заменитьtypealias Key = Class<>
на typealias Key = Int
и кидать ошибку в функции create
?
Попробуем изменить код:
typealias Key = Int // Замена с Class<*> на Int
object Keys {
@JvmStatic fun create(keyClass: Class<*>): Key {
throw NotImplementedError() // Кидаем ошибку
}
}
// Scope остался неизменным
class Scope {
internal val factories = HashMap<Key, () -> Any?>()
inline fun <reified T> factory(noinline factory: () -> T) {
val key: Key = Keys.create(T::class.java)
factories[key] = factory
}
inline fun <reified T> get() {
val key: Key = Keys.create(T::class.java)
return scope.factories[key].invoke()
}
}
Вы спросите: «А зачем кидать ошибку? Как тогда это должно работать?»
Логика проста: мы напишем процессор, который после компиляции кода в байт-код будет заменять вызовы Key.create
на константы, и исключение не будет кидаться вообще. А если уж оно и произойдёт, то это будет служить маркером того, что процессор отработал неверно.
Теперь посмотрим на байт-код нашего примера, преобразованный в Java-код для наглядности:
class MainKt {
public static final Scope scope = new Scope();
static {
int key;
// Это байт-код для factory<String>
key = Keys.create(String.class); // Сейчас этот метод кинет исключение, если всё оставить как есть
scope.factories.put(key, new Lambda1());
// А это байт-код для factory<MyClass>
key = Keys.create(MyClass.class);
scope.factories.put(key, new Lambda2());
}
public static void main(String[] args) {
int key;
// Это вывод строки
key = Keys.create(String.class);
System.out.println(scope.factories.get(key).invoke());
// А это вывод MyClass
key = Keys.create(MyClass.class);
System.out.println(scope.factories.get(key).invoke());
}
}
Теперь, когда процессор будет проходиться по байт-коду, он будет встречать вызовы типа Keys.create(SomeClass.class)
и заменять их на константы. Условимся, что String
— это 1, а MyClass
— это 2. В итоге получаем следующий изменённый байт-код:
class MainKt {
public static final Scope scope = new Scope();
static {
int key;
// Это байт-код для factory<String>
key = 1;
scope.factories.put(key, new Lambda1());
// А это байт-код для factory<MyClass>
key = 2;
scope.factories.put(key, new Lambda2());
}
public static void main(String[] args) {
int key;
// Это вывод строки
key = 1;
System.out.println(scope.factories.get(key).invoke());
// А это вывод MyClass
key = 2;
System.out.println(scope.factories.get(key).invoke());
}
}
Вызовов метода create
нигде не осталось, так как мы всё заменили на константы. Исключения не кидаются, и всё работает!
А дальше я расскажу, как написать такой процессор и правильно его применять.
Как устроен байт-код
Байт-код — стандартное промежуточное представление, в которое компьютерная программа может быть переведена автоматическими средствами. Так говорит «Википедия». В случае JVM он хранится в .class
-файлах, которые чаще всего можно найти упакованными в JAR-файлы (по сути, это обычный ZIP-архив).
Прежде чем перейдём к устройству байт-кода, расскажу о том, что такое обратная польская запись (ОПЗ). Дело в том, что байт-код работает по такому же принципу. Если вы знаете, что это такое, то можете пропустить эту часть.
Обратная польская запись
Допустим, у нас есть простое математическое выражение:
(1 + 2) * (3 + 4)
Минус такой записи в том, что у разных операторов — разные приоритеты (поэтому и нужны скобки). Также разные операторы могут быть разных типов: префиксными, бинарными, постфиксными. Ещё отдельно от операторов существуют функции. Логично, что в таком виде компьютеру будет сложно вычислять значение выражения. Поэтому представим его в виде ОПЗ:
1 2 + 3 4 + *
Все операторы и функции пишутся после операндов, и теперь у них одинаковый приоритет (поэтому скобки не нужны).
Исполнить такое выражение можно с помощью обычной стековой машины. Алгоритм следующий:
Идём по математическому выражению с начала.
Если встречаем константу,
Tо кладем её в стек.
Иначе, если встречаем оператор или функцию (в ОПЗ это, в принципе, одни и те же вещи),
То достаём из стека нужное количество операндов, выполняем операцию и кладём в стек получившееся значение.
В конце в стеке остаётся наш результат.
Вернёмся к байт-коду.
JVM — это навороченная стековая машина, которая читает подряд инструкции, а потом кладёт в стек или достаёт из него значения. Приведу пример:
System.out.println(1 + 2 * 3);
Напишем байт-код этого Java-кода (он может не полностью соответствовать тому, что генерирует Java). После каждой инструкции я буду выводить стек, чтобы было понятно, как исполняется байт-код.
// Стек: пустой
GETFIELD java.lang.System.out // Загружаем значение из статического поля в стек
// Стек: PrintStream
LDC 2 // Загружаем константы в стек
LDC 3
// Стек: PrintStream, 2, 3
IMULT // Выполняем умножение и кладём результат в стек
// Стек: PrintStream, 6
LDC 1 // Загружаем единичку
// Стек: PrintStream, 6, 1
IADD // Выполняем сложение
// Стек: PrintStream, 7
INVOKEVIRTUAL println // Вызываем нестатический метод println на объекте PrintStream с аргументом 7
// Стек остался пустым, а в консоли напечаталось число 7
Этот код познакомил нас со следующими базовыми инструкциями:
LDC
— загрузка константы в стек.IMULT
,IADD
— операции сложения и умножения. БукваI
в начале означает операцию над целыми числами. Для double есть аналогичные с буквойD
.INVOKEVIRTUAL
— вызов нестатического метода. Для вызова статического используетсяINVOKESTATIC
.GETFIELD
— получение нестатического поля. Для статического естьGETSTATIC
.
Также хочется отметить такие немаловажные инструкции, как LOAD
и STORE
. Первая загружает переменную в стек, а вторая выгружает из стека в переменную.
Многие из инструкций используют пул констант (не путайте с пулом строк — у них вообще разные назначения). Это обычная таблица, которая содержит в себе все константы текущего класса. Сюда можно включить строки, числа, ссылки на методы, ссылки на классы.
Например, INVOKESTATIC 42
означает, что в пуле констант под номером 42 лежит ссылка на метод. Так JVM определяет, какой метод нужно вызвать. Для простоты обычно сразу пишут название метода, чтобы не нужно было лезть в пул констант.
Такая же история и с LDC
. У неё тоже единственный аргумент — индекс на пул констант, по которому она сможет определить, какую константу загрузить в стек.
Этих базовых знаний будет достаточно, чтобы написать наш процессор.
Как пропатчить байт-код
После того как мы разобрались с устройством байт-кода, нужно понять, какие инструкции нам надо заменить. Итак, у нас есть следующий код:
Keys.create(SomeClass.class);
Если посмотреть на байт-код, то тут Java или Kotlin генерируют всего две инструкции:
LDC SomeClass
INVOKESTATIC Keys.create
В LDC
передаётся ссылка на класс, поэтому она загружает в стек наш Class
-объект, а INVOKESTATIC
просто вызывает метод create
. Вместо этого мы хотим загрузить обычную Int
-константу. Для этого подойдёт SIPUSH
-инструкция:
SIPUSH 42
Это такая же инструкция, как и LDC
, которая загружает константу в стек, но она не использует ссылку на пул констант, а сразу же после себя содержит значение. Минус в том, что её аргумент ограничен 2 байтами, поэтому значения не могут быть больше 2^15 (32 768), но нам пока этого хватит.
Во всей этой магии по замене инструкций нам сильно поможет библиотека BCEL (Byte Code Engineering Library). Она предоставляет очень удобный API, поэтому я выбрал именно её. Сначала напишем метод, который пробежится по всем методам класса и достанет оттуда связанный список инструкций:
fun modifyClass(file: File) {
// Парсим .class-файл
val parser = ClassParser(file.path)
val javaClass = parser.parse()
val classGen = ClassGen(javaClass)
// Получаем пул констант
val constantPoolGen = classGen.constantPool
for (method in classGen.methods) {
val methodGen = MethodGen(method, classGen.className, constantPoolGen)
modifyMethod(methodGen.instructionList, constantPoolGen)
// Сохраняем изменения в методе
classGen.replaceMethod(method, methodGen.method)
}
// Сохраняем изменения в классе
classGen.javaClass.dump(FileOutputStream(file))
}
Теперь реализуем modifyMethod()
, который будет заменять инструкции:
fun modifyMethod(
list: InstructionList,
constantPoolGen: ConstantPoolGen
) {
var instruction: InstructionHandle? = null
while (true) {
// Перебираем все инструкции — в этой библиотеке они представлены связанным списком
instruction = (if (instruction == null) list.start else instruction.next) ?: break
// Первая инструкция — это LDC
val ldc = instruction.instruction as? LDC ?: continue
// Убеждаемся, что LDC-инструкция загружает константу класса
val objectType = ldc.getValue(constantPoolGen) as? ObjectType ?: continue
// Узнаём имя этого класса
val className = objectType.className ?: continue
// Следующей инструкцией должна быть INVOKESTATIC
val invokestatic = instruction.next?.instruction as? INVOKESTATIC ?: continue
// Убеждаемся, что мы вызываем метод Keys.create(Class<*>): Int
if (invokestatic.getLoadClassType(constantPoolGen).className != "Keys") continue
if (invokestatic.getName(constantPoolGen) != "create") continue
if (invokestatic.getSignature(constantPoolGen) != "(Ljava/lang/Class)I") continue
// Сигнатура (Ljava/lang/Class)I означает, что метод принимает один аргумент с типом Class, а возвращает Int
// Определяем индекс для нашего класса: просто берём следующий свободный
var index = indexMap[className]
if (index == null) {
index = indexMap.size
indexMap[className] = index // Сохраняем индекс под текущее имя класса
}
// Удаляем инструкцию LDC, заменяем её на инструкцию NOP, которая ничего не делает
instruction.instruction = InstructionConst.NOP
// Заменяем INVOKESTATIC-инструкцию на SIPUSH, которая будет пушить в стек наш индекс
instruction.next.instruction = SIPUSH(index.toShort())
}
}
Наш процессор готов!
Однако теперь возникает вопрос: откуда нам взять .class
-файлы для их изменения? С JVM всё просто: JAR-файл содержит нужные .class
-файлы. А что касается Android, то внутри APK у нас лежат .dex
-файлы. Это тоже байт-код, но не Java, а Dalvik. Он компилируется из байт-кода Java, и в этом случае нам нужно вставить процессор перед этим шагом.
Как интегрировать процессор в систему сборки
Поскольку мы, как и многие разработчики, используем систему сборки Gradle, внедрим наш процессор именно в неё.
Чтобы начать работу с Gradle, достаточно понять: основная единица работы в нём — это Task
. Таски могут быть связаны: например, задача компиляции байт-кода Dalvik зависит от задачи компиляции Kotlin в байт-код Java. Все задачи в Gradle объединены в проекты, но мы их называем просто модулями.
Акцентируем внимание на таске compileKotlin
. Он обрабатывает наши .kt
-файлы (исходники Kotlin), создавая .class
-файлы (байт-код JVM), а это именно то, что нам нужно.
В корневом файле build.gradle.kts
добавим следующий код:
allprojects {
// Проходимся по всем таскам всех проектов.
tasks.configureEach {
// Отфильтровываем все таски с названием compileKotlin
if (name == "compileKotlin") {
// doLast вызывает лямбду сразу после того,
// как таск compileKotlin закончит своё выполнение
doLast {
// Проходимся по всем выходным папкам таска compileKotlin
// Там и должны лежать наши .class-файлы
outputs.files.forEach { output ->
output.walk()
.filter { file -> file.isFile && file.extension == "class" } // Фильтруем все .class-файлы
.forEach { file ->
// Изменяем наши классы методом, который мы описали ранее
// Этот метод должен лежать где-то в модуле buildSrc, чтобы он был виден во всех Gradle-скриптах
modifyClass(file)
}
}
}
}
}
}
Теперь наш процессор будет запускаться всегда, когда мы компилируем код Kotlin. Неважно, для чего нам это понадобится — для компиляции Android или JVM-приложения, — он всегда будет запускаться и модифицировать байт-код.
Результаты
Вы спросите, как наши изменения повлияли на скорость?
Это скриншот перформанс-тестов Android-приложения Яндекс Маркета. После переноса всего проекта на Int-ключи мы вернулись к прежним значениям времени старта приложения. На скриншоте также видно предыдущую попытку ускорения, о которой можно прочитать в первой статье про Scout.
Работа с байт-кодом оказалась увлекательным занятием. Он достаточно прост для внесения изменений и не так страшен, как чистый ассемблер. JVM проверяет байт-код и в случае ошибки в генерации выдаёт исключение VerifyError
с детальным описанием возникшей проблемы.
Я надеюсь, что этот материал поможет вам впоследствии рассматривать свои задачи через призму байт-кода, который, как видно на нашем примере, может оказаться весьма полезным инструментом.