openjdk.ruOpenJDK на русском

JEP 544: Ahead-of-Time Code Compilation

Компиляция кода Ahead-of-Time (заблаговременная компиляция и подготовка)

ОтветственныйJohn Rose
ТипFeature
ОбластьImplementation
СтатусProposed to Target
Выпуск28
Компонентhotspot / compiler
Обсуждениеleyden dash dev at openjdk dot org
Связан сJEP 483: Ahead-of-Time Class Loading & Linking
JEP 515: Ahead-of-Time Method Profiling
РецензентыAlex Buckley, Dan Heidinga, Mark Reinhold, Vladimir Kozlov
ОдобренVladimir Kozlov
Создан2024/06/30 04:47
Обновлён2026/09/21 14:35
Задача8335368

Аннотация

Сократить время запуска и прогрева: оптимизированный машинный код приложения должен быть доступен сразу при запуске HotSpot Java Virtual Machine. Для этого код приложения компилируется в машинный код в тренировочном запуске, а машинный код сохраняется в AOT Cache (кэш, подготовленный заранее) для использования в последующих рабочих запусках. Если в рабочем режиме нагрузка меняется, машинный код динамически генерируется заново, чтобы производительность оставалась пиковой. Так сочетаются лучшие стороны компиляции Ahead-of-Time (AOT) и just-in-time (JIT).

Цели

  • Позволить приложениям быстрее выходить на пиковую производительность.

  • Позволить приложениям сохранять пиковую производительность даже при изменении нагрузки.

  • Не требовать никаких изменений в коде приложений, библиотек или фреймворков.

  • Не требовать никаких изменений в конфигурации HotSpot, кроме запроса на использование AOT Cache.

  • Сохранить поддержку сборщиков мусора Serial, Parallel, G1 и ZGC.

  • Не вводить новые сценарии работы с AOT, а расширить существующий сценарий создания AOT Cache.

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

  • Поддержать процессорные архитектуры AArch64 и x64.

Что не является целью

  • Режим, работающий только с AOT, не является целью. Приложения будут использовать и код, скомпилированный в режиме AOT, и код, скомпилированный JIT, в одном и том же запуске и автоматически переключаться между ними по мере необходимости.

  • Поддержка кросс-компиляции не является целью. Код, скомпилированный в тренировочном запуске, должен выполняться в последующих рабочих запусках на той же архитектуре процессора с тем же набором возможностей.

  • Поддержка всех процессорных архитектур, которые сейчас поддерживает HotSpot, не является целью. Мы ожидаем, что в ходе обычной работы по портированию со временем будет добавлена поддержка всех основных архитектур.

Мотивация

Когда Java-приложение выполняется в HotSpot JVM, оно проходит три фазы: запуск, затем прогрев, затем выход на пиковую производительность.

Во время запуска HotSpot вызывает метод main приложения и по мере необходимости загружает, связывает и инициализирует классы. Сначала и код приложения, и код библиотек JDK выполняются интерпретатором байт-кода, а он работает медленно. Внутри интерпретатора HotSpot профилирует поведение приложения, подсчитывая такие события, как вызовы методов и итерации циклов. По данным профиля он выбирает часто вызываемые методы, или горячие точки, и компилирует их в машинный код базовым компилятором C1. Этот машинный код оптимизирован лишь умеренно.

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

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

Приложение остаётся в этом состоянии пиковой производительности, пока его горячие точки не меняются. Однако горячие точки приложения могут измениться в ответ на изменение нагрузки. Тогда HotSpot может динамически деоптимизировать код, отбрасывая ранее сгенерированный машинный код по мере необходимости, и повторно оптимизировать его, генерируя новый машинный код для методов, ставших горячими. Например, если приложение сначала получает запросы двух типов, HotSpot динамически оптимизирует код для этих двух типов запросов. Если приложение начинает получать запросы третьего типа, HotSpot может динамически деоптимизировать, а затем повторно оптимизировать код для всех трёх типов запросов. По сути, приложение может пройти ещё одну фазу прогрева и сохранить производительность при изменении нагрузки.

А как же статическая компиляция?

Статическую компиляцию иногда предлагали как альтернативу динамической компиляции Java-кода. Статический компилятор преобразует приложение целиком в машинный код в режиме Ahead-of-Time, до времени выполнения.

У статической компиляции есть некоторые преимущества перед динамической. Статически скомпилированное приложение запускается и выходит на пиковую производительность сразу, без фазы прогрева. Во время выполнения не нужны ни интерпретатор байт-кода, ни профилирование, ни компиляция. Пиковая производительность может даже быть сопоставимой с HotSpot, если оптимизацией в статическом компиляторе управляют точные профили, собранные в предыдущих запусках.

Однако у динамической компиляции есть три ключевых преимущества перед статической.

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

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

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

Перенос работы по компиляции в тренировочные запуски

На протяжении всех фаз запуска и прогрева HotSpot постоянно жонглирует несколькими мячами: он выполняет код приложения и библиотек JDK; загружает, связывает и инициализирует классы по мере необходимости; профилирует выполнение приложения; и компилирует горячие методы в машинный код с разной степенью оптимизации, руководствуясь данными профиля.

Основной тезис проекта Leyden: чтобы сократить время запуска и прогрева, нужно выполнять часть этой работы раньше, в режиме Ahead-of-Time, а не just in time. Мы переносим работу на более ранний момент, выполняя её в тренировочном запуске и сохраняя результаты в AOT Cache для мгновенного использования в последующих рабочих запусках.

Мы перенесли на более ранний момент загрузку и связывание классов в JEP 483, вошедшем в JDK 24. AOT Cache хранит загруженные и связанные формы классов из тренировочного запуска и тем самым сокращает время запуска.

Мы перенесли на более ранний момент профилирование в JEP 515, вошедшем в JDK 25. AOT Cache хранит профили выполнения методов, вызванных в тренировочном запуске, поэтому компилятор C2 может начать работу сразу в начале рабочих запусков, и время прогрева сокращается.

Эти улучшения заложили основу для нашей конечной цели — перенести на более ранний момент работу по компиляции и оптимизации. AOT Cache будет хранить оптимизированный машинный код, скомпилированный в тренировочном запуске, и HotSpot сможет загружать этот код мгновенно, а не компилировать его заново в начале каждого рабочего запуска. Это сократит и время запуска, и время прогрева.

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

Описание

Мы расширяем существующий AOT Cache, чтобы он хранил оптимизированный машинный код, сгенерированный в тренировочном запуске. Такой кэшированный код называется AOT-кодом. Во время рабочего запуска запрос на оптимизированный код метода может быть выполнен мгновенно, если в кэше найден подходящий AOT-код. Если AOT-код недоступен, несовместим, непригоден по другим причинам или позже деоптимизирован, выполнение возвращается к существующим механизмам интерпретатора и JIT. AOT-код и JIT-код могут сосуществовать и полностью совместимы между собой, поскольку их создают одни и те же компиляторы, C1 и C2.

Чтобы создать AOT Cache, выполните тренировочный запуск приложения с опцией AOTCacheOutput и сгенерируйте AOT-код:

$ java -XX:AOTCacheOutput=app.aot -cp app.jar com.example.App ...

Этот сценарий не изменился по сравнению с предыдущими выпусками. Однако AOT Cache в файле app.aot теперь содержит не только предварительно связанные классы и данные профилирования, но и AOT-код для отобранных горячих методов. Затем в рабочем режиме приложение можно запускать с этим кэшем:

$ java -XX:AOTCache=app.aot -cp app.jar com.example.App ...

Для генерации и использования AOT-кода не нужны никакие дополнительные опции или настройки. HotSpot по умолчанию создаёт AOT-код и сохраняет его в кэше. Он по-прежнему сохраняет в кэше и данные профиля: они используются для определения порядка загрузки AOT-кода и для управления последующей генерацией JIT-кода.

Производительность

Чтобы оценить выигрыш AOT-кода при запуске, мы запустили пять тестовых приложений, построенных на популярных Java-фреймворках. Мы запускали их на двухъядерной системе Linux/x64, чтобы имитировать среду микросервисов, где JIT-компилятор, скорее всего, конкурирует с приложением за процессорное время и тем самым увеличивает время запуска:

Performance chart showing the startup benefit of AOT code for five benchmarks

Без AOT-кода AOT Cache сокращает время запуска этих приложений примерно на 50–70 %; с AOT-кодом кэш сокращает время их запуска примерно на 65–80 %.

Чтобы оценить выигрыш AOT-кода при прогреве, мы запустили тестовое приложение javac, которое двадцать раз подряд компилирует одни и те же 50 исходных файлов и измеряет время каждой итерации:

Performance chart showing the warmup benefit of AOT code for javac

На каждой кривой первая итерация показывает сокращение времени запуска: AOT Cache без AOT-кода сокращает время запуска примерно на 30 %; добавление AOT-кода даёт ещё 45 %, в сумме около 75 %. Последующие итерации показывают фазу прогрева, в ходе которой HotSpot компилирует самые горячие методы: время итерации, как правило, уменьшается, а затем выходит на установившийся уровень по мере того, как растёт качество машинного кода и компиляторы завершают свою работу. Кривая для AOT Cache без AOT-кода снижается быстрее, чем кривая без AOT Cache, и в итоге выходит примерно на тот же установившийся уровень. Кривая для кэша с AOT-кодом уже к четвёртой итерации близка к установившемуся уровню. Площадь между верхней и нижней кривыми соответствует общему сокращению времени прогрева.

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

Различия между AOT-кодом и JIT-кодом

AOT-код и JIT-код могут различаться, поскольку обучающие и рабочие запуски могут различаться.

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

Другой источник различий состоит в том, что значение статического final-поля может меняться от запуска к запуску; например, поле может инициализироваться текущими датой и временем. При JIT-компиляции метода, который ссылается на такое поле, класс, содержащий это поле, уже будет инициализирован, поэтому значение поля будет известно, и C2 может считать его константой времени компиляции и встроить его непосредственно в машинный код. Однако при AOT-компиляции того же метода ни один класс ещё не будет инициализирован, поэтому значение поля будет неизвестно, и C2 не может считать его константой времени компиляции; он должен сгенерировать код, который явно загружает поле.

Несмотря на эти различия, AOT-код всё равно даёт значительный выигрыш в производительности, причём прозрачно для приложений. Как и всегда, во время выполнения компиляторы могут сгенерировать JIT-код взамен AOT-кода, который со временем перестал быть удачным.

Согласованность обучающих и рабочих запусков

Чтобы воспользоваться преимуществами AOT Cache, созданного в обучающем запуске, обучающий запуск и все последующие рабочие запуски должны быть по существу схожи, как описано в JEP 483.

Если AOT Cache содержит AOT-код, этот код используется при соблюдении двух дополнительных ограничений:

  • Во всех запусках используются процессоры одной и той же архитектуры с одним и тем же набором возможностей. Например, AOT-код, сгенерированный для процессора x64 с поддержкой векторных инструкций AVX-512, не будет работать на процессоре x64 без этой поддержки.
  • Во всех запусках используется один и тот же сборщик мусора, поскольку AOT-код содержит барьеры чтения/записи, специфичные для сборщика мусора.

Если эти ограничения не соблюдены, HotSpot выдаёт предупреждение и не загружает AOT-код, возвращаясь к обычным механизмам интерпретатора и JIT. При этом он по-прежнему использует остальные сведения из AOT Cache, а именно загруженные и связанные классы и данные профилирования. В этом случае приложение может запускаться и прогреваться медленнее, но выполняться оно всё равно будет корректно и со временем всё равно достигнет пиковой производительности.

Наблюдение за использованием AOT Cache в рабочих запусках

Вы можете узнать, загружается ли AOT-код в рабочем запуске, с помощью существующей опции HotSpot PrintCompilation, которая теперь сообщает и о загрузке AOT-кода, и о JIT-компиляции:

$ java -XX:+PrintCompilation \
  -XX:AOTCache=app.aot -cp app.jar com.example.App ...

Вы также можете проверить, пригоден ли AOT Cache, содержащий AOT-код, для использования в конкретной рабочей среде. Опция AOTMode=required заставляет HotSpot сообщить об ошибке и завершить работу, если AOT Cache нарушает какое-либо ограничение:

$ java -XX:AOTMode=required \
  -XX:AOTCache=app.aot -cp app.jar com.example.App ...

(Изначально эта опция называлась AOTMode=on, позже её переименовали для ясности в AOTMode=required.)

Наконец, загрузку AOT-кода можно отключить с помощью диагностической опции AOTCodeCaching:

$ java -XX:+UnlockDiagnosticVMOptions -XX:-AOTCodeCaching \
  -XX:AOTCache=app.aot -cp app.jar com.example.App ...

Эту опцию можно использовать, чтобы оценить влияние AOT-кода на производительность в рабочем запуске или чтобы избежать ошибок нарушения ограничений при использовании AOTMode=required, когда AOT-код непригоден, но остальные сведения в кэше пригодны.

Управление генерацией AOT-кода при обучении

До сих пор мы говорили о том, что AOT Cache создаётся в обучающем запуске за один шаг с помощью опции AOTCacheOutput, как показано выше. На самом деле создание AOT Cache состоит из двух отдельных шагов: HotSpot сначала запускается в режиме record, чтобы сохранить наблюдения за поведением вашего приложения в AOT-конфигурации, а затем ещё раз в режиме create, чтобы собрать из этой конфигурации AOT Cache, что включает компиляцию AOT-кода. При использовании опции AOTCacheOutput второй шаг выполняется прозрачно, но каждый шаг можно вызвать явно с помощью опций AOTMode и AOTConfiguration:

$ java -XX:AOTMode=record -XX:AOTConfiguration=app.aotconf \
       -cp app.jar com.example.App ...
$ java -XX:AOTMode=create -XX:AOTConfiguration=app.aotconf \
       -XX:AOTCache=app.aot

HotSpot предоставляет множество опций для управления поведением своих компиляторов C1 и C2. Эти опции одинаково управляют генерацией как JIT-кода во время выполнения, так и AOT-кода на шаге сборки. Например, следующая командная строка выводит всю активность JIT-компиляции во время собственно обучающего запуска и всю активность AOT-компиляции при создании кэша:

$ java -XX:+PrintCompilation \
  -XX:AOTCacheOutput=app.aot -cp app.jar com.example.App ...

Чтобы видеть активность AOT-компиляции, но не JIT-компиляции, используйте существующую переменную окружения JDK_AOT_VM_OPTIONS, чтобы передать опцию PrintCompilation только на шаг сборки:

$ JDK_AOT_VM_OPTIONS='-XX:+PrintCompilation' \
  java -XX:AOTCacheOutput=app.aot -cp app.jar com.example.App ...

Либо вы можете выполнить оба шага самостоятельно, указав опцию PrintCompilation только на втором шаге.

Наконец, создание AOT-кода в обучающем запуске можно отключить с помощью диагностической опции AOTCodeCaching:

$ java -XX:+UnlockDiagnosticVMOptions -XX:-AOTCodeCaching \
  -XX:AOTCacheOutput=app.aot -cp app.jar com.example.App ...

Эту опцию можно использовать, чтобы оценить, как AOT-код влияет на размер AOT Cache, — кэшированный код может значительно увеличить AOT Cache.

Подробнее обо всех опциях командной строки, связанных с AOT, см. страницу руководства по команде java.

Дальнейшая работа

  • Исследовать возможность свести к минимуму интерпретацию байт-кода и JIT-компиляцию в пользу почти полной опоры на AOT-код. Первые эксперименты показывают, что минимизация использования интерпретатора приводит к чрезмерно большим файлам AOT Cache, загрузка которых может занимать больше времени, чем простая работа интерпретатора. Аналогично, минимизация JIT-компиляции часто приводит к снижению пиковой производительности. Применимость этого подхода может оказаться ограниченной, если он, как и статическая компиляция, не оправдает ожиданий пользователей.

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

  • Рассмотреть опцию, которая позволяла бы пожертвовать частью производительности, или согласиться на более крупные файлы AOT Cache, или и то и другое, ради переносимости между процессорами одной архитектуры, но с разными наборами возможностей.

Тестирование

  • Мы создадим новые модульные тесты для этой возможности. Они будут проверять, что AOT-код, если он есть, ведёт себя корректно.

  • Мы запустим существующие тесты AOT Cache с включённой возможностью и убедимся, что они проходят.

  • Поначалу будут поддерживаться только процессоры AArch64 и x64. Модульные тесты будут соответствующим образом скорректированы с учётом отсутствия AOT-кода на других архитектурах.

Риски и допущения

  • Новых рисков, помимо уже отмеченных в JEP 483, нет.

  • Мы исходим из того, что основополагающий принцип HotSpot по-прежнему верен: Java-приложение должно компилироваться во время выполнения с учётом фактического поведения приложения, используя все доступные возможности процессора. Благодаря этому принципу Java-код автоматически работает лучше на новом оборудовании.

  • Базовое допущение AOT Cache также остаётся в силе: предполагается, что обучающий запуск — хороший источник наблюдений, которые, будучи переданы через AOT Cache в рабочий запуск, улучшат производительность рабочего запуска. Это допущение полностью применимо к AOT-коду, который приносит пользу схожим рабочим запускам и при этом не вредит отличающимся рабочим запускам, поскольку те могут с помощью JIT-компиляции сгенерировать другой код.