Каждое Android-приложение начинается с байт-кода в формате DEX, который сам по себе процессор смартфона выполнить не может: его нужно превратить в машинные инструкции конкретной архитектуры, будь то ARM64 или x86_64. Именно здесь расходятся пути двух виртуальных машин, через которые прошла платформа за свою историю. Dalvik делал это на лету при каждом запуске программы, а пришедшая ей на смену ART переносит основную часть работы на этап установки, и это решение изменило скорость запуска приложений, расход батареи и саму архитектуру системы компиляции на миллиардах устройств.
Как Dalvik компилировал байт-код при каждом запуске приложения
Dalvik был штатной виртуальной машиной Android с первой версии системы, и первоначально работал как чистый интерпретатор без какой-либо компиляции байт-кода в машинный код. Модель just-in-time компиляции появилась в Dalvik только с выходом Android 2.2 Froyo в 2010 году: новый JIT-компилятор транслировал байт-код в машинный код прямо во время работы приложения и, по собственным оценкам инженеров Google, ускорял процессорно-зависимые сценарии в 2-5 раз по сравнению с предыдущей версией системы. Именно эта JIT-модель и стала тем режимом работы Dalvik, который затем сравнивают с AOT-подходом ART: когда пользователь открывал приложение, интерпретатор Dalvik читал DEX-байт-код и по ходу выполнения транслировал в машинный код только те методы, которые реально вызывались, причем делал это заново при каждом новом запуске программы. Такая схема экономила место на диске, потому что скомпилированный код нигде не хранился между сеансами, однако платой за экономию становилась задержка на старте: интерпретация и трансляция байт-кода отнимали процессорное время именно в момент, когда пользователь ждет отклика от приложения. Чем крупнее и сложнее было приложение, тем заметнее становилась эта задержка, а повторная компиляция одного и того же кода при каждом запуске означала, что процессор регулярно выполнял одну и ту же работу впустую. Внутри Dalvik использовалась регистровая архитектура виртуальной машины, в отличие от стековой модели классической Java-машины, что само по себе снижало число инструкций на операцию, но не устраняло главную проблему: трансляция байт-кода в машинные инструкции происходила заново после каждой перезагрузки процесса приложения, будь то обычный запуск пользователем или возврат системы из выгруженного состояния после нехватки памяти.
Что изменилось с появлением среды выполнения ART
Google представила ART экспериментальной опцией в Android 4.4 KitKat, официально вышедшем 31 октября 2013 года, и ее можно было включить вручную в разделе для разработчиков. Уже со следующей версией системы, Android 5.0 Lollipop, ART заменила Dalvik в качестве runtime по умолчанию на всех устройствах. Главное архитектурное отличие заключалось в переходе к ahead-of-time компиляции: вместо перевода байт-кода в машинный код при каждом запуске приложения ART выполняет эту трансляцию один раз, сразу при установке пакета. За компиляцию отвечает инструмент dex2oat, который принимает на входе DEX-файлы из APK и генерирует набор артефактов, готовых к исполнению на конкретной архитектуре устройства. В результате его работы на диске появляются файлы нескольких типов: OAT-файл в формате ELF с уже скомпилированным нативным кодом, VDEX с неупакованным DEX-байт-кодом и метаданными для ускоренной верификации, а также ART-файл с образом кучи, который ускоряет инициализацию приложения при запуске.
Разница в подходах заметна и в цифрах. Возьмем условное приложение с DEX-файлом на 20 мегабайт: при полной AOT-компиляции в режиме speed итоговый набор артефактов на диске может занимать 30-40 мегабайт с учетом нативного кода для конкретной архитектуры процессора, тогда как чисто интерпретируемая схема Dalvik обходилась исходными 20 мегабайтами DEX-байт-кода без каких-либо дополнительных файлов. Экономия по объему компиляции достигается за счет профильно-управляемого режима: если в профиль попадает лишь 10-15 процентов методов приложения, что типично для крупных программ с редко используемыми экранами и функциями, итоговый объем скомпилированного нативного кода может сократиться в несколько раз по сравнению с полной компиляцией всего пакета, при этом сохраняя основной выигрыш в скорости именно там, где он ощутим пользователем сильнее всего.
Почему AOT-компиляция ускоряет запуск, но замедляет установку
Выигрыш от AOT-подхода нагляден именно в момент запуска: раз машинный код уже лежит на диске в готовом виде, системе не нужно тратить процессорное время на трансляцию байт-кода перед первым же экраном приложения, поэтому холодный старт программ на ART в среднем заметно быстрее, чем на Dalvik при сопоставимом железе. Тот же принцип снижает нагрузку на процессор во время самой работы приложения, поскольку повторных циклов интерпретации байт-кода в критических участках кода становится меньше, а это положительно сказывается на расходе заряда батареи, особенно в приложениях, которые пользователь открывает по многу раз в день. Обратная сторона AOT-модели проявляется на этапе установки или обновления приложения: dex2oat должен обработать весь объем байт-кода заранее, поэтому процесс установки занимает больше времени, а на устройстве требуется дополнительное место на диске под скомпилированные артефакты, ведь рядом с исходным APK теперь хранится еще и нативный код для конкретного процессора.
Как гибридная модель Android 7 объединила AOT, JIT и интерпретацию
Полностью статичная AOT-компиляция всего кода при установке оказалась избыточной для методов, которые вызываются редко, и создавала лишнюю нагрузку на устройство именно тогда, когда пользователь ждет завершения установки. Начиная с Android 7 Nougat инженеры Google вернули в ART JIT-компилятор, но не отказались от AOT, а построили гибридную модель, где оба механизма дополняют друг друга. При первом запуске приложения интерпретатор и JIT-компилятор обрабатывают код по мере необходимости и одновременно собирают профиль реального использования, то есть список методов и классов, которые вызываются чаще всего. Когда устройство простаивает и заряжается, специальный фоновый демон компиляции запускает dex2oat повторно, но уже не для всего приложения целиком, а прицельно для методов из собранного профиля, компилируя их в нативный код и сохраняя результат для следующих запусков. Такой подход, называемый профильно-управляемой компиляцией, позволяет тратить время и место на диске именно на тот код, который реально важен для скорости работы конкретного пользователя, а не компилировать заранее весь объем байт-кода без разбора.
Как устройства вроде Pixel комбинируют облачные профили и локальную компиляцию
На современных устройствах цепочка компиляции устроена еще тоньше. Приложение из Google Play может поставляться вместе с файлом метаданных DEX с расширением dm, который содержит облачный профиль, собранный на основе статистики использования этого приложения у широкого круга пользователей. ART сразу AOT-компилирует методы из этого профиля еще до того, как человек хотя бы раз открыл программу, поэтому даже первый запуск оказывается быстрее, чем при полной холодной компиляции. Параллельно с этим локальный профиль конкретного устройства продолжает пополняться данными о том, как именно этот пользователь работает с приложением, и фоновый компилятор постепенно дооптимизирует те участки кода, которые не попали в облачный профиль, но оказались востребованы именно на этом смартфоне. Такая многоуровневая система сочетает предсказуемость AOT-компиляции с адаптивностью JIT и позволяет балансировать между временем установки, скоростью запуска и объемом занятого места на диске.
Разработчики получили возможность влиять на этот механизм напрямую через библиотеку Baseline Profiles из состава Jetpack. С ее помощью можно заранее описать список критических методов и классов, которые задействованы при запуске приложения и при переходе между самыми частыми экранами, и включить получившийся профиль прямо в состав APK или Android App Bundle. Такой профиль работает как локальная замена облачному, поэтому даже пользователи, для которых Google еще не собрала статистику через Play Store, получают часть выгоды от AOT-компиляции критических путей сразу после установки, а не только после нескольких дней постепенной адаптации фонового компилятора.
Что это означает для разработчиков и производительности приложений
Для разработчика переход на ART означает, что оптимизация кода начинается не только на этапе написания программы, но и на уровне того, как часто и в каком порядке вызываются методы, ведь именно эта статистика попадает в профиль и определяет, что будет скомпилировано заранее. Практические следствия перехода к AOT-модели можно свести к нескольким пунктам:
- Время холодного старта приложения снижается, поскольку критические методы уже находятся в скомпилированном виде на диске к моменту первого запуска;
- Расход заряда батареи в среднем сокращается за счет меньшего числа повторных циклов интерпретации одного и того же кода;
- Время установки и обновления приложения растет по сравнению с чисто интерпретируемой моделью, так как dex2oat обрабатывает часть байт-кода заранее;
- Требования к свободному месту на диске увеличиваются из-за хранения дополнительных артефактов компиляции рядом с исходным APK.
Так как компиляция теперь во многом опирается на профиль реального использования, тестирование производительности приложения стоит проводить не только на холодной установке, но и после нескольких циклов обычной работы, когда фоновый компилятор уже успел дооптимизировать наиболее востребованные методы, иначе первые замеры скорости могут оказаться заметно хуже той производительности, которую приложение показывает пользователям после нескольких дней использования.
Разработчик может управлять режимом компиляции вручную через adb, что удобно для тестирования разных сценариев на этапе отладки. Команда adb shell cmd package compile -m speed -f com.example.app заставляет dex2oat скомпилировать весь код пакета целиком в режиме максимальной скорости, что полезно для замера верхней границы производительности без влияния профиля. Команда adb shell cmd package compile -m speed-profile -f com.example.app компилирует только те методы, которые уже попали в локальный профиль устройства, имитируя штатное поведение системы после нескольких дней обычного использования. Значение -m задает так называемый фильтр компиляции, и помимо speed и speed-profile система поддерживает также verify, при котором dex2oat лишь проверяет корректность байт-кода без генерации нативных инструкций, оставляя выполнение методов интерпретатору и JIT-компилятору. Флаг -r bg-dexopt в свою очередь запускает тот же сценарий фоновой компиляции, который система обычно выполняет самостоятельно ночью при простое и зарядке устройства, что позволяет разработчику проверить итоговое состояние кэша компиляции без реального ожидания.
Почему совместимость между Dalvik и ART не всегда была полной
Обе среды выполнения работают с одним и тем же форматом байт-кода DEX, поэтому подавляющее большинство приложений, написанных под Dalvik, запускались на ART без переделки. Тем не менее переход не был совершенно бесшовным: ART ввела более строгую верификацию кода на этапе установки, чем Dalvik, из-за чего некоторые приемы, построенные на недокументированном поведении старой виртуальной машины, переставали работать корректно. Отдельные инструменты постобработки байт-кода порой генерировали файлы, которые Dalvik исполнял без проблем, а dex2oat отказывался компилировать из-за нарушений формата, и разработчикам таких библиотек приходилось обновлять свои инструменты под более строгие требования новой среды выполнения. ART изначально принесла с собой переработанный сборщик мусора с более компактным перемещением объектов в памяти, что снижало фрагментацию кучи и число пауз на сборку мусора по сравнению с Dalvik, но требовало от системных компонентов аккуратной работы с указателями на объекты в памяти.
Переход от Dalvik к ART показывает общий принцип, характерный для эволюции виртуальных машин: чем раньше в жизненном цикле программы выполняется трудоемкая трансляция байт-кода, тем меньше эта работа мешает пользователю в момент, когда ему нужен быстрый отклик от приложения, а плата за это переносится на менее заметные для человека этапы вроде установки или фонового простоя устройства.