Это текстовая версия доклада с HighLoad++ 2024, с которым выступал один из наших бывших девелопер-адвокатов @RustamKuramshin Также есть отдельная запись доклада, сделанная силами участников программного комитета HighLoad++.


В этой статье вы узнаете о повышении перфоманса Java-приложений. Разберём:

  1. Как работает HotSpot.

  2. Процессы исполнения байт-кода и в чём же основные проблемы.

  3. Технологии для повышения перформанса: CDS, CRaC и AOT. 

  4. Два мира сборки приложений: статический и динамический.

Поговорим о двух болях, которые есть в Java-приложениях — это время запуска и время достижения пиковой производительности. 

Колено раз
Колено раз
Колено два
Колено два

Начнём с понимания того, как вообще работает JVM, а точнее её конкретная реализация — HotSpot JVM.

Как работает JVM

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

  1. Есть исходники, в которых вы написали свое Spring-приложение, например, с аннотациями. Java-компилятор его преобразует в class-файлы. В class-файлах находится байт-код, который может читать JVM. На этой стадии у нас возникает class loader — механизм загрузки классов, который есть в JVM. 

Для дальнейшего понимания, в том числе понимания того, что такое class data sharing (CDS), нам нужно немного разобраться какие вообще есть этапы чтения классов.

Этапы чтения классов

  1. Загрузка байт-кода Class loader`ом.

  2. Верификация байт-кода с проверкой его валидности и возможности работы с ним JVM.

  3. Определение источника происхождения class-файла и проверка обратной бинарной совместимости. Если вы используете какие-то библиотеки, которые ходят в class-файлы и что-то меняют, то на этой стадии можно получить исключение о том, что класс не может быть прочитан.

  4. Инициализация переменных и полей класса. 

  5. Разрешение символьных ссылок.


Важно понимать, что процесс чтения класса с диска class loader`ом требует определённого времени. Мы не можем взять class-файл, мгновенно прочитать и начать выполнять. Нам нужно немного подготовиться.


  1. Использование байт-кода интерпретатором. После того, как class-файл был прочитан, он преобразовался в специальный формат, в котором он уже передаётся интерпретатору внутри JVM. Это не тот формат, который был на диске, а формат уже удобный для интерпретации байт-кода внутри JVM.

Дальше на сцену выходит JVM-интерпретатор. JVM-интерпретатор исполняет байт-код инструкция за инструкцией. Этот процесс происходит практически всегда.

На заре существования JVM байт-код только интерпретировался. Затем стало понятно, что интерпретация не может работать быстро. Например, у нас могут выполняться одни и те же методы, в которых один и тот же код. Это неэффективно с точки зрения интерпретации, потому что было бы проще заранее скомпилировать исполнение каких-либо методов. Так появилась JIT-компиляция (Just In Time).

JIT-компилятор может компилировать либо отдельные методы, либо разворачивать циклы и компилировать эти фрагменты кода. Это своего рода единицы компиляции для JIT-компилятора.

Дальше фрагменты скомпилированного байт-кода кэшируется в code cache. Теперь в следующий раз при вызове метода не происходит его интерпретация, а вызывается скомпилированный код. Так на пальцах объясняется механизм интерпретации JIT-компиляции в JVM.

Как именно происходит взаимодействие интерпретатора с JIT-компиляцией?

Многоуровневая компиляция

Внутри JVM есть 2 компилятора: C1 и С2. Особенность C1-компилятора — его скорость, поэтому его ещё называют клиентским. Он нужен для того, чтобы наше приложение быстрее начало компилировать байт-код. Итог работы C1-компилятора — не очень оптимизированный нативный код для процессора на текущей архитектуре. Однако C1-компилятор быстро такой код создаёт.

C2-компилятор ещё называют серверным компилятором. Он нужен для создания высоко оптимизированного кода. Чтобы создать такой код нужно больше времени, но такой код будет быстрее работать на текущем процессоре.

Существует такой механизм, который называется tiered compilation (многоуровневая компиляция). Давайте разберёмся как это работает:

Уровень 0 — интерпретируемый код. 

На этом этапе шаг за шагом интерпретируется код. Далее собираются сведения, мы профилируем нашу программу и собираем сведения о часто исполняемом коде. Например, это вызов отдельных методов или исполнение циклов.

Уровень 1 — код, скомпилированный C1-компилятором (без профилирования).

Интерпретатор обращается к компилятору C1 и просит его скомпилировать код без каких-либо данных о профилировании. C1-компилятор возвращает уже скомпилированный, но неоптимизированный код.

Уровень 2 — код, скомпилированный C1-компилятором (базовое профилирование).

Интерпретатор обращается к компилятору C1 с данными о базовом профилировании, например, что есть методы, которые часто вызываются.  C1-компилятор возвращает скомпилированный код с учётом этих сведений.

Уровень 3 — код, скомпилированный C1-компилятором (полное профилирование, накладные расходы ~35%).

Мы обращаемся к компилятору C1 с данными о полном профилировании приложения. Это уже потребует немного больше времени.

Уровень 4 —  код, скомпилированный C2-компилятором (использует данные профилирования из предыдущих этапов).

Мы обращаемся к компилятору C2, чтобы он скомпилировал высокоэффективный нативный код. Это потребует ещё более значительного времени, если говорить о миллисекундах, микросекундах.

Цикл исполнения в HotSpot JVM можно представить в виде следующей круговой диаграммы:

  1. Сначала идёт интерпретация.

  2. Затем мы профилируем наше приложения и ищем так называемые “горячие точки” (hotspots).

  3. Мы обращаемся к C1-компилятору и делаем быструю компиляцию, которая даёт нам не очень эффективный нативный код.

  4. Мы продолжаем искать “горячие точки”, т.е. снова делаем профилирование.

  5. Обращаемся к C2-компилятору и делаем медленную компиляция, которая даёт нам более оптимизированный нативный код. Однако это потребует больше времени.

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

Это можно представить следующим графиком:

Мы видим, что у нас есть определённый этап разгона JVM, так называемое время достижения устойчивого состояния (steady state). На графике видно, что время достижения устойчивого состояния включает фазы деоптимизации и паузы сборщика мусора.

В микросервисной среде мы постоянно попадаем в эту ситуацию:

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

Разработчики JVM задумались об этом, и со временем разработали специальные оптимизации, которые позволяют сгладить эти проблемные моменты. И сейчас мы в них разберёмся.

Технологии для улучшения запуска HotSpot JVM

Class Data Sharing (CDS)

Class Data Sharing (CDS) — это функция оптимизации работы JVM, которая позволяет нам каждый раз не подготавливать наши class-файлы и не проходить все эти шаги, которые мы разбирали в механизме загрузки классов. Понятно, что если мы каждый раз будем запускать наше приложение, то каждый раз мы будем тратить на это время. Но зачем?

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

Что ещё интересно, что CDS использует memory mapping. Обычно, когда приложение работает с файлами, оно делает системные вызовы в ОС, которые прогоняют данные с диска через всю цепочку в ОС для того, чтобы наша программа в виртуальной памяти могла получить к этому доступ. Memory mapping же сразу позволяет сопоставить наши файлы в память для того, чтобы процесс не делал лишние системные вызовы для чтения файлов, а сразу получал данные из виртуальной памяти.

Соответственно, здесь это даёт возможность для оптимизации, если у нас есть несколько инстансов JVM, запущенных в одной ОС. Но здесь нужно сказать, что в свете технологий контейнеров и Cloud Native это слабо применимая оптимизация, потому что JVM будут разделены контейнерной средой.

Структура архивного файла CDS

jsa-архив состоит из нескольких областей.

  1. Пространство MiscData: хранит различные типы метаданных классов.

  2. Области для чтения (ReadOnly) и записи (WriteOnly): содержат фактические данные классов. Это разделение нужно, если у нас несколько процессов JVM читают jsa-архив.

В целом jsa-архив структурирован для оптимизации доступа нескольких JVM к данным классов.

Управление CDS-архивом

Первые CDS-архивы содержали основные классы JVM (например, java.lang). Это так называемый статический CDS-архив. Он управляется специальными опциями. Если ваша JDK не поставляется с CDS-архивом, вы можете сами его создать. Для этого есть аргумент -Xshare:<value>.

  • auto — включает CDS, если доступен jsa-архив. Эта опция говорит, что нужно использовать jsa-архив. Если он не обнаружен, то JVM просто продолжит загрузку классов из class-файлов, как это работало обычно.

  • on — требует использования CDS. Если CDS-архив не удалось обнаружить, то JVM аварийно завершит работу. Это нужно исключительно для отладки и тестирования, не нужно пытаться использовать на продакшене.

  • off — отключает CDS. Она отключает чтение CDS-архива.

  • dump — создаёт CDS-архив из основных классов JVM

Существуют поставки JDK, которые уже содержат CDS. Например, у Axiom JDK есть JDK, поставляемая уже с CDS-архивом. Это очень удобно, т.к. не нужно в Dockerfile при подготовке вашего сервиса как-то дополнительно готовить JDK для создания базового CDS-архива.

Но технологии не стояли на месте, и стало понятно, что большие Java-приложения в продакшене используют не только базовые классы, которые есть в JVM, но также классы библиотек и зависимости. И здесь в игру вошла технология Application Class Data Sharing, или так называемый AppCDS.

AppCDS и динамические архивы

AppCDS позволяет упаковать в jsa-архив классы, библиотеки, зависимости, которые есть в вашем фреймворке, например, в Spring.

Наверное, вы когда-либо профилировали приложение, работающее на Java, например, с помощью VisualVM. Если вы когда-нибудь с помощью VisualVM подключитесь к вашему сервису на Spring Boot, вы увидите, что в runtime загружено более 20 тысяч Java-классов. Это достаточно большое число. На подготовку этих классов, с точки зрения работы JVM, каждый раз тратится время.

Для этого появилась технология AppCDS. AppCDS управляется с помощью -XX флагов JVM:

  • -XX:ArchiveClassesAtExit=<name of archive file> — сгенерирует AppCDS-архив при выходе из приложения.

  • -XX:SharedArchiveFile=<name of archive file> — использует  AppCDS-архив при последующих запусках.

  • -XX:+AutoCreateSharedArchive=<name of archive file> — создаст AppCDS-архив , если его не удалось прочитать.

Использование в Spring

  • -XX:ArchiveClassesAtExit=application.jsa — создаёт CDS-архив при завершении работы приложения.

  • -Dspring.context.exit=onRefresh — запускает и затем немедленно завершает работу Spring-приложения. После полной инициализации ApplicationContext и всех бинов, приложение должно корректно завершить работу. В этот момент будет создан jsa-архив.

  • При последующем запуске Spring-приложения вы можете указать параметр -XX:SharedArchiveFile=application.jsa, ссылаясь на созданный JSA-архив, и Spring больше не будет повторно загружать все классы.

  • -Xlog:class+load:file=cds.log — для отладки работы Class Data Sharing можно создать лог-файл загрузки классов, который покажет, откуда фактически загружались классы. Так можно проверить, что классы действительно загружались не из class-файлов, а из оптимизированного CDS-архива.

Это и есть технология Class Data Sharing.

Coordinated Restore at Checkpoint (CRaC)

Основная идея CRaC заключается в следующем: когда приложение попадает в продакшен и начинает обрабатывать клиентские запросы, JVM постепенно оптимизирует код через JIT-компиляцию (с использованием C1- и C2-компиляторов). Состояние, когда код полностью оптимизирован и JVM достигает пиковой производительности, называется ready state. Для того, чтобы не потерять это состояние, хотелось бы как-то сделать снимок работающего процесса JVM, чтобы при следующем запуске именно этого приложения (где не изменялись исходные классы) мы уже сразу попали в это прогретое состояние и не дожидались, когда пользователи с помощью клиентского приложения прогреют JVM. Так была создана технология CRaC. 

CRaC разработан для сокращения времени запуска Java-приложений за счёт реализации механизма создания контрольных точек и восстановления из них. CRaC позволяет нам делать мгновенные снимки работающего процесса JVM, сохранять эти снимки на диск с помощью так называемой технологии CRIU (Checkpoint/Restore in Userspace). Эта технология Linux, которая была создана для работы с виртуальными машинами.

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

Linux CRIU (Checkpoint/Restore in Userspace)

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

Кроме того, сейчас есть экспериментальная поддержка CRIU в Docker. Для этого требуется установить CRIU 2.0 или новее в систему с Docker.

Также нужно внести изменения в конфиг демона (daemon) и включить экспериментальные функции:

  • Создать точку восстановления: docker checkpoint create <container name> checkpoint

  • Запускать приложение в контейнере с восстановлением из чекпоинта: docker start --checkpoint checkpoint <container name>

Эта функция ещё находится на стадии экспериментальной поддержки и не входит в основную версию Docker.

CRaC основан на CRIU

Для работы с CRaC нужна JDK, которая поставляется уже с поддержкой CRaC. Когда мы работаем с контейнерными приложениями, у нас в контейнере уже должна находиться CRIU, JVM также должна быть настроена на работу с CRIU. CRIU требует повышенных привилегий, и её не так просто использовать внутри Docker-контейнера. Например, если вы захотите в процессе билда Dockerfile создать снапшот с помощью CRIU, то вам потребуются повышенные привилегии и дополнительные настройки контейнера. Если при попытке создать снапшот, у нашего приложения есть открытые файлы или сокеты, то вы получите исключение CheckpointException. Снапшот не будет создан, но приложение продолжит свою работу. Это особенность работы с CRaC, к которой мы ещё вернёмся.

Снапшоты CRaC можно создавать разными способами:

  • Из Java-кода с помощью API, которая предоставляет CRaC.

  • С помощью утилит, поставляемых с JVM, например, jcmd.

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

Общий процесс можно представить следующей схемой: 

  1. Java-приложение либо через CRaC API в коде, либо через jcmd инициирует создание снапшота. 

  2. CRaC вызывает CRIU. Для создания чекпоинта формируется набор image-файлов, сохраняемых в указанном каталоге. 

  3. Для запуска из чекпоинта достаточно указать путь к каталогу с image-файлами. Теперь можно запустить приложение.

Когда лучше создавать снапшот CRaC?

  • Первый вариант — подход fast-forward: когда мы хотим ускорить именно чтение классов. После того, как Spring инициализировался, мы создаём снапшот.

  • Второй вариант: когда наше приложение достаточно прогрето, например, с помощью нагрузочных тестов в пайплайне или каким-то образом на продакшене.

Важно: в момент создания снапшота (уже после сохранения image-файлов) останавливается процесс JVM. То есть мы создали снапшот, сделали снимок нашего процесса, и после этого мы должны запустить его заново из этого снимка. Нельзя сделать снапшот бесшовно, чтобы CRIU сохранил image-файлы и потом продолжил работу.

Для такого у нас есть разные подходы. Мы можем создавать снапшоты в CI-пайплайне либо на продакшене с помощью прогрева JVM нагрузочными тестами.

Как работает CRaC

  1. Запускаем наше Java-приложение, указывая опцию и путь для сохранения image-файлов: java -XX:CRaCCheckpointTo=PATH -jar app.jar.

  2. В какой-то момент создаём снапшот, т.е. делаем чекпоинт. Для этого используем jcmd и указываем ссылку на наш JAR-файл: jcmd app.jar JDK.checkpoint. После этого JVM останавливается.

  3. Запускаем наше приложение уже из снапшота: java -XX:CRaCRestoreFrom=PATH. Не нужно указывать ссылку на JAR-файл.

CRaC Java API

У CRaC есть Java API. Для чего оно нужно?

В момент создания снапшота JVM останавливается, потом мы запускаем наше приложение заново. Соответственно, нам нужно подготовиться к остановке приложения. Для этого необходимо закрыть все сетевые соединения и прекратить работу с файловыми дескрипторами, то есть перестать читать файлы с диска. После этого снапшот может быть создан.

Когда мы восстанавливаемся из снапшота, нам нужно восстановить все сетевые соединения и продолжить работу с какими-то файлами, с которыми мы работали ранее, если в этом есть необходимость. Для этого CRaC предоставляет интерфейс Resource, который содержит два callback: beforeCheckpoint() и afterRestore(). В этих callback вы можете реализовать эту логику.

Использование в Spring

Если мы откроем документацию, то увидим, что Spring Boot поддерживает работу с CRaC. Это так, но если вы работаете с сетевыми соединениями (например, работа с БД, с Kafka) либо у вас подключен профайлер для непрерывного профилирования или у вас открыты какие-либо сетевые соединения, то Spring не сможет сделать снапшот. Вам нужно будет в ручном режиме написать достаточно большое количество Java-кода, чтобы снапшот мог быть создан. В текущей версии Spring для этого нет каких-то базовых реализаций. Это нужно учитывать.

Так как работать с CRaC в Spring? 

Шаг 1. Берём необходимые инструменты:

  • JDK с поддержкой CRaC. Сейчас это доступно только на платформе Linux.

  • Библиотека org.crac:crac для работы с CRaC (её последняя версия 1.5). Она должна присутствовать в classpath.

Шаг 2. Каким-то образом создаем снапшот либо из кода, либо с помощью jcmd. Для этого указываем параметры командной строки Java: -XX:CRaCCheckpointTo=PATH.

Шаг 3. Останавливаем Spring с помощью -Dspring.context.exit=onRefresh, когда уже были инициализированы все бины, все классы, и Spring был подготовлен.

Это подход Fast-Forward. Если у вас нет возможности захватить прогретое состояние JVM на продакшене либо в CI-пайплайне, вы можете подготовить JVM после того, как Spring уже был проинициализирован, и сохранить этот снапшот.

Шаг 4. Восстанавливаем состояние приложения из чекпоинта. Для этого при следующем запуске приложения вы указываете каталог, в котором у вас лежат файлы вашего снапшота с помощью опции -XX:CRaCRestoreFrom=PATH. Так, Spring-приложение запустится из этого снапшота, а вам не нужно указывать ссылку на JAR-файлы.

Результаты использования CRaC

Ниже приведены результаты времени запуска (мс) до и после использования CRaC:

На диаграмме видно, что если мы разогреем наше приложение и запустим его из снапшота, то мы вернемся к прогретому состоянию уже за существенно меньшее количество миллисекунд.

Есть простые примеры, на которых это можно проверить. Вы можете написать простой код без Spring с использованием паттерна кэширования. Например, вы можете

проверять числа на простоту. Есть такой известный пример для демонстрации работы CRaC, где мы проверяем, простое это число или нет. Числа генерируются случайным образом в каком-то небольшом диапазоне. Нам выгодно кэшировать уже проверенные числа, помещать их в локальный кэш, и в следующий раз их не проверять.

Интересно, что после создания снапшота и восстановления из него, JVM прочитает image-файлы, и ваш кэш будет восстановлен. За счёт этого срабатывает оптимизация. Так же, при следующем запуске JVM мы получаем прогретое состояние, которое было достигнуто с помощью JIT-компиляции.

В целом CRaC — это отличная технология. Однако нужно сказать, что если мы занимаемся продакшен-разработкой на Spring, то сейчас нам будет сложно с этим работать. Если мы работаем с БД или у нас есть какие-то исходящие/входящие сетевые соединения, то нужно будет вручную написать достаточно много кода.

AOT (Ahead-of-Time) и GraalVM

В этой части рассмотрим Ahead-of-Time компиляцию и GraalVM.

GraalVM — революция в мире производительности Java

GraalVM — это мультиязыковая виртуальная машина. Она поддерживает как JVM-языки (Java, Scala, Kotlin), так и не JVM-языки (Python, Ruby и WebAssembly).

GraalVM нам позволяет использовать Ahead-of-Time (AOT) компиляцию. Что такое AOT? Как мы до этого разобрались, обычно в процессе работы JVM интерпретатор взаимодействует с C1- и C2-компиляторами и просит их скомпилировать определённые фрагменты кода. Но что, если мы сразу весь наш байт-код превратим в нативный код для процессора текущей архитектуры? Это и есть AOT, или Ahead-of-Time компиляция. На этом и не только основана работа GraalVM.

Открытый и закрытый миры

Далее нам нужно разобраться в нескольких терминах, связанных с компиляцией и сборкой приложений. Это Open World и Closed World (открытый и закрытый миры). Это важно для понимания работы GraalVM.

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

Например, вы наверняка знаете библиотеку Lombok, которая позволяет сократить количество boilerplate-кода, которое мы пишем на Java. Как раз в момент компиляции мы расставляем в коде аннотации, например, аннотацию @Data, которая генерирует equals(), hashCode(), toString(), геттеры и сеттер'ы. Мы их сами не пишем. Когда мы компилируем приложение с помощью процессора аннотаций, у нас этот код создается автоматически. Это такой динамический мир, к которому мы привыкли, и он вписывается в эту концепцию Open World. В этот момент у нас может происходить загрузка новых классов, может даже перекомпилироваться и деоптимизироваться наш код. Это динамическая среда.

В противовес этому существует подход Closed World, который предполагает, что в момент сборки приложения у нас уже все должно быть известно, и мы не можем изменять наше приложение. Например, если у нас уже есть классы, они не могут меняться. У нас возникают сложности с рефлексией: мы не можем использовать Reflection в этот момент и т. д.

Closed World означает, что:

  • Программа и все её зависимости уже известны на этапе компиляции.

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

  • Весь исполняемый код компилируется и оптимизируется заранее без необходимости использования JIT-компиляции или runtime-оптимизации.

Возможности GraalVM

GraalVM предоставляет несколько стратегий работы как для Open World, так и для Closed World.

GraalVM позволяет запускать приложения на Spring Boot (поскольку поддерживает Java). При этом GraalVM не является сертифицированной Java SE реализацией, хотя Java-код на ней работает. Более того, некоторые Java-программы могут выполняться даже быстрее на GraalVM.

Можно использовать GraalVM для работы в концепции Closed World, где мы заранее компилируем наше приложение. Однако здесь возникает ряд ограничений.

Кроме этого, GraalVM предоставляет интересные инструменты для разработки своих интерпретаторов (проект Truffle).

Использование в Spring

Есть два способа:

Spring AOT (Spring Ahead-of-Time)

В этом случае мы не работаем с полностью автономным исполняемым файлом. Мы убираем рефлексию из нашего приложения. Все динамические свойства работы Spring удаляются. Если распаковать JAR-архив, подготовленный с помощью Spring AOT, мы увидим специальные классы с сгенерированным кодом, которые заменяют механизмы динамической рефлексии, используемые при запуске и работе Spring. Это важно понимать, так как это накладывает определённые ограничения.

Используем подготовленный JAR-архив со Spring AOT:

  • Для Gradle: java -Dspring.aot.enabled=true -jar myapplication.jar

  • Для Maven: mvn -Pnative package.

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

Автономные исполняемые двоичные файлы

Здесь есть два подхода:

  • Использование Buildpacks

Для подготовки Docker-образа существует технология Buildpacks.

Maven:

<plugin>

    <groupId>org.graalvm.buildtools</groupId>

    <artifactId>native-maven-plugin</artifactId>

</plugin>

> mvn -Pnative spring-boot:build-image

Gradle:

plugins {

    id("org.graalvm.buildtools.native") version "0.10.2"

}

> gradle bootBuildImage

При создании Docker-образа можно указать, чтобы внутри содержалось нативное приложение, работающее без JVM.

  • Прямая компиляция через GraalVM Native Build Tools

GraalVM должна быть установлена в системе. Компиляции через:

Maven: mvn -Pnative native:compile

Gradle: gradle nativeCompile

В результате в каталоге билда build/native/nativeCompile появится исполняемый файл, для запуска которого не требуется JVM. Это будет работать в вашей системе как обычное нативное приложение.

Сравнительный анализ (AOT vs JIT)

Мы рассмотрели основные оптимизации, связанные с ускорением запуска и оптимизацией прогрева JVM. Теперь проанализируем их плюсы и минусы.

AOT

JIT

Встраивание методов

Ограниченное

Агрессивное во время выполнения

Генерация байт-кода

Невозможна во время выполнения

Возможна во время выполнения

Работа с рефлексией

Возможна, но усложнена

Полноценная поддержка

Спекулятивные оптимизации

Не поддерживаются

Полноценная поддержка

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

Ниже (в среднем)

Выше (в среднем)

Скорость запуска

Полная скорость с самого начала

Требуется время для прогрева

Накладные расходы

Нет расходов на компиляцию в runtime

Есть расходы на компиляцию в runtime

Использование памяти

Меньший объем

Больший объем

Сложности с рефлексией в AOT-компиляции особенно актуальны для Spring. Мы привыкли к таким вещам, как работа с профилями в Spring. Например, у нас может быть профиль для разных сред запуска нашего Spring-приложения: 

  • профиль для разработки (с in-memory базой данных),

  • профиль для продакшена.

При использовании Ahead-of-Time (AOT) компиляции невозможно динамическое переключение профилей, все настройки должны быть определены заранее.

Средняя производительность AOT несколько ниже, чем при JIT-компиляции. Причина в том, что JIT-компилятор (C1/C2) делает деоптимизацию, анализирует работу приложения в runtime и адаптируется под конкретные сценарии использования.

С другой стороны, при AOT-компиляция отсутствуют накладные расходы на JIT-компиляцию во время выполнения.

Сборка Spring-приложений с помощью Native Build Tools требует достаточно много оперативной памяти, и это может повлиять на CI-пайплайны. Чтобы собрать даже очень простое приложение на Spring Boot в нативный исполняемый файл, вам потребуется минимум 8 ГБ оперативной памяти. Если вы не выделите контейнеру столько оперативной памяти, то JVM прервет сборку с OutOfMemoryError, потому что подготовка нативного файла достаточно длительная. GraalVM и её инструментам приходится анализировать все ваши библиотеки, весь массив байт-кода и исходного кода на Java, превращая это все в нативный исполняемый код. Затем всё это еще нужно оптимизировать. Это очень ресурсоемкий процесс, требующий много времени на стадии сборки и компиляции.

А когда мы работаем с JIT, мы остаемся в привычном мире, где возможно встраивание методов (method inlining), генерация байт-кода во время выполнения, спекулятивные оптимизации, когда мы чаще заходим в какие-то ветки в нашем коде, то они

именно и будут скомпилированы. Это динамизм, который нам предоставляет JIT-компилятор.

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

Часто приводят такой график сравнения, который позволяет понять, в какую сторону тянет

статический/динамический мир:

При Ahead-of-Time компиляции:

  • Ускоряется запуск приложения.

  • Уменьшается memory footprint (потребление оперативной памяти).

При JIT-компиляции:

  • Потребляется больше памяти.

  • Достигается большая пропускная способность.

Мы получаем лучшую производительность, но расплачиваемся временем на JIT-компиляцию.

Здесь нужно сказать, что в Java появился Project Leyden, который пытается объединить преимущества обоих подходов — статического и динамического.

Влияние технологий оптимизации и выводы

Мы рассмотрели три оптимизации JVM. Важно понять, когда их использовать.

  • Class Data Sharing (CDS): устраняет необходимость каждый раз тратить время на подготовку байт-кода к исполнению. За счёт memory mapping уменьшает потребляемую память среди процессов JVM.

  • CRaC (Coordinated Restore at Checkpoint): сосредоточен на создании контрольных точек для быстрого восстановления состояния Java-приложения. Позволяет захватить прогретое состояние JVM и запускаться именно из него.

  • GraalVM: AOT-компиляция в связке с Native Images сокращают время запуска Java-приложений.

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

Делайте бенчмарки. Спойлер: если считать основной метрикой вашего backend-приложения пропускную способность в RPS (requests per second), т.е. сколько запросов в секунду обрабатывает ваше приложение на бэкенде, то, если вы одновременно в разных инстансах вашего приложения включите эти оптимизации и дадите на вход нагрузку, то в реальных условиях (с учётом сетевых задержек и задержек ввода/вывода) общий performance не будет заметно отличаться. Это происходит, например, из-за задержек при чтении из БД и обращений к смежным сервисам.

Не стоит ожидать вау-эффекта в типичном Spring-проекте. Даже если анализировать задержки обработки запросов суммарно по всем эндпоинтам, результаты останутся в одном диапазоне.

С другой стороны, если рассматривать скорость запуска приложения, то сборки с помощью GraalVM и использование CRaC существенно сокращают время запуска. Разница измеряется секундами. Например, на Spring Pet Clinic:

  • Обычный запуск: 7-8 секунд.

  • С CRaC или AOT: < 1 секунды.

Это особенно важно для serverless-подхода в облаке, когда приложение запускается по требованию (на входящую нагрузку), что позволяет экономить ресурсы и бюджет. В таких сценариях CRaC и AOT действительно критичны.

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

Комментарии (2)


  1. Lewigh
    25.05.2025 10:57

    Самое смешное и одновременно грустное в этом то что столько времени тратится на оптимизацию JVM столько человеческого труда и ума ... чтобы потом запустить сервер на кривом Spring и по сути слить эти достижения в унитаз.


  1. RustamKuramshin
    25.05.2025 10:57

    Меня больше всего огорчил уровень адопшена CRaC в Spring Boot

    https://docs.spring.io/spring-boot/reference/packaging/checkpoint-restore.html#page-title

    Это действительно работает.

    Однако в типовом проекте Spring Boot + Spring Data JPA + PostgreSQL нужно написать коллбэки для CRaC самому. Это странно как минимум потому что в таком простом проекте нет "инородных" зависимостей для спринга.