
Введение
Доброго времени суток забредшему на заголовок читателю. Сегодня будем говорить о том, как попробовать фичу, пришедшую в мир простых смертных Java-разработчиков вместе с 10‑й версией языка, и попробуем нагнать упущенный «пароход» и быть модными и «в теме», но обо всём по порядку.
Прежде чем начать, оговорюсь о том, что, когда в статье употребляется термин JVM, имеется в виду именно HotSpot JVM, и речь не идёт про другие реализации.
В современном мире мы боремся за то, чтобы наши приложения стартовали быстро, и ищем способы всячески ускорить их запуск. Об этом и будет предлагаемая читателю статья. В начале будет краткий теоретический экскурс, а во второй части — практика. Я приведу ссылки на конкретные разделы, чтобы читатели, которые не готовы осилить большое количество материала или не хотят читать всё, могли перейти сразу к интересующей их части.
Оглавление
Немного теории и историческая сводка
Начну просто. На заре существован��я JVM исходный код компилировался в байткод, находящийся внутри .class-файлов, и исполнялся, будучи преобразованным интерпретатором в машинный код построчно. В этом, кстати, и кроется хвалёная джавовая WORA. Различные интерпретаторы для различных видов машин способны корректно выполнять один и тот же байт-код, по-разному его интерпретируя.
Последовательность выглядит примерно вот так:
Загрузка байт-кода ClassLoader'ом
Верификация байт-кода
Определения происхождения class-файла и проверка обратной бинарной совместимости
Инициализация переменных
Разрешение символьных ссылок
Интерпретатор начинает использовать байт-код
Особенно далеко на этом не уедешь в реалиях, когда все стремятся выжать из железок все соки производительности и всевозможными способами выдерживать нагрузки и повышать эффективность. А подобные стремления были замечены у программистов с самого начала существования современных языков программирования. Причин у этого было несколько, но сути явления это не меняет. Были придуманы всевозможные подходы для ускорения и оптимизации работы кода в JVM.
JIT-компиляция
Первой важной вехой в истории гонки за достижением максимальной производительности и разгона JVM стала многоуровневая компиляция кода. Довольно большое время в жизненном цикле JVM тратится на преобразование байткода в машинный код. Что, если иметь этот код в памяти уже преобразованным? Это могло бы сэкономить время. Здесь на сцену выходит многоуровневая компиляция.
Дело в том, что применить сразу все необходимые оптимизации к коду невозможно. Нужен профиль того, как этот код исполняется: когда и как часто вызывается, как ветвится и т. д. Ведь оптимизация как явление тоже стоит определённых ресурсов, а любой инструмент нужно применять по делу. Чаще всего производительность конкретного приложения в большей степени зависит от наиболее часто выполняемых участков кода. Они, кстати, именуются “hot spots” (в переводе — «горячие точки»), и именно они дали название нашей с вами любимой JVM.
Чтобы их вычислить и применять оптимизацию и компиляцию строго по делу, в JVM осуществляется сбор метаинформации, то есть профиля исполняемого кода, с последующей оптимизацией. Это явление именуется PGO — Profile Guided Optimization, то есть оптимизация на основании профиля.
Однако разгоняться нам нужно с самого начала, а профиль появляется только через некоторое время. Поэтому оптимизация делится на несколько этапов. Для обозначения этого явления используется термин Tiered Optimization — поэтапная оптимизация. Для этого у JVM есть несколько JIT-компиляторов: C1 и C2. Их принципиальное отличие заключается в том, что C2 предоставляет большее количество оптимизаций, а C1 начинает работать раньше. C1 обычно начинает работу с кодом, ещё не имеющим профиля, и выполняет первичные оптимизации, тогда как C2 способен учитывать в своей работе прошлые итерации оптимизаций и профилирования.
Давайте, прежде чем я приведу последовательность того, как происходит поэтапная оптимизация при помощи JIT-компиляторов, разберу, откуда вообще пошла аббревиатура “JIT”. JIT — это производная от словосочетания “Just in time”, что означает «во время выполнения». Дело в том, что JIT-оптимизация происходит только во время работы приложения и уже после старта. Говоря простыми аналогиями, это позволяет разогнать уже “едущий” автомобиль, но не уменьшает количество времени, используемого для того, чтобы его завести и вообще начать “ехать”.
Так вот. Многоуровневая компиляция делится на несколько этапов по оптимизациям:
Интерпретируемый код
Код, который после некоторого количества итераций выполнения скомпилирован C1, но без учета профиля
Код скомпилированный С1 с учетом минимального профиля выполнения
Код скомпилированный С1 с учетом полноценного профилирования
Код скомпилированный С2 с учетом прошлых этапов профилирования и имеющий максимальное количество оптимизаций.
Важно также отметить, что это цикличный процесс, так как периодически возникает ситуация, когда код приходится перекомпилировать: например, начинают использоваться и требуют оптимизации дополнительные ветви исполнения. Этот процесс называется “деоптимизация”. После него происходит повторная интерпретация, и стадии JIT-компиляции повторяются до тех пор, пока в памяти в конечном счёте не окажется наиболее полная версия оптимизированного кода.
Естественно, в данный момент возникает вопрос: «А где все эти чудеса компиляции хранятся?»
Для этого существует отдельная область памяти — Code Cache. Именно в этой области хранится код, скомпилированный и оптимизированный компилятором. Начиная с 9-й версии Java, Code Cache делится на три области: JVM Internal, Non-Profiled Code и Profiled Code. Погружаться в детали работы JVM и устройство памяти можно достаточно долго; однако это не тема сегодняшней статьи.
Из важного для прочтения и осознания стоит, пожалуй, добавить, что размер Code Cache ограничен, и это необходимо учитывать при настройке JVM. Для коррекции размера каждого сегмента существует отдельное свойство. На этом, пожалуй, для первого знакомства достаточно.
Компиляция и оптимизация — это хорошо. Но все эти действия происходят уже в рантайме. Всё, что происходит в рантайме, требует времени, а его у нас и так расходуется достаточно много по ходу работы. Взять хотя бы паузы, возникающие в приложении во время работы Garbage Collector. Работа и оптимизация всего, что связано с Garbage Collector, — это отдельная глубокая тема. Существует несколько GC, заточенных под разные цели и по-разному реализующих время пауз. Стоит просто помнить, что такие паузы бывают и они стоят определённого количества временных ресурсов.
В сухом остатке этих размышлений остаётся вопрос: неужели нельзя подготовиться к рантайму заранее и сделать сам старт быстрее и производительнее? Быстро ехать — это замечательно, но как сделать так, чтобы и заводилось побыстрее, и разгонялось шустро?
Кроме того, стоит учитывать, что в современном мире мы работаем с приложениями, которые нередко представлены несколькими экземплярами, работающими параллельно. На сегодняшний день это более чем актуальная задача. На дворе 25-й год, и мульти-инстанс сегодня — норма. Однако проблема не нова, и её решение стало доступно нам ещё в 10-й версии Java. Об этом и поговорим далее.
CDS и AppCDS
Итак, к нам пришла мысль о том, что было бы неплохо иметь нечто, что можно быстро загрузить в память на старте нашего приложения, а также пошарить это между инстансами приложений. Эта же мысль пришла в голову и разработчикам Java, когда они придумали CDS (Class Data Sharing). К идее необходимости подобного механизма они пришли ещё во времена Java 1.5. Долгое время о чём-то шушукались, что-то мастерили и в Java 10 сделали возможность работы с CDS доступной для обычного разработчика.
CDS (Class Data Sharing) — это возможность сократить время запуска JVM и объём потребляемой памяти на компиляцию за счёт создания и последующего использования специального архива (.jsa). Этот архив содержит набор скомпилированных классов, преобразованных в специальный формат, и при запуске JVM отображается непосредственно в память JVM с помощью memory mapping.
Наверное, для более простого понимания стоит прибегнуть к переведённой на русский язык цитате Николая Парлога:
«Чтобы выполнить байт-код класса, JVM необходимо выполнить несколько подготовительных шагов. Получив имя класса, она ищет его на диске, загружает, проверяет байт-код и помещает его во внутренние структуры данных. Конечно, это занимает некоторое время, что особенно заметно при запуске JVM, когда требуется загрузить как минимум пару сотен, а скорее всего — тысячи классов.
Дело в том, что пока JAR-файлы приложения не меняются, данные этих классов всегда одинаковы. JVM выполняет одни и те же шаги и приходит к одному и тому же результату при каждом запуске приложения.
Вот вам и разделение данных классов приложения! Идея заключается в том, чтобы создать эти данные один раз, сохранить их в архиве, а затем использовать повторно при последующих запусках и даже совместно использовать между одновременно работающими экземплярами JVM.
При запуске с архивом JVM отображает файл архива в свою собственную память, и большинство необходимых классов оказывается в её распоряжении; ей не нужно возиться со сложным механизмом загрузки классов. Область памяти может даже совместно использоваться одновременно работающими экземплярами JVM, что освобождает память, которая в противном случае тратилась бы на репликацию одной и той же информации в каждом экземпляре.
AppCDS значительно сокращает время, затрачиваемое JVM на загрузку классов, что особенно заметно во время запуска. Это также предотвращает длительное время отклика в случаях, когда пользователь впервые обращается к функции, требующей большого количества ещё не загруженных классов».
Что ж, идея классная. Вот только изначальный CDS включал в себя только классы самой JDK. Стоит отметить, что начиная с Java 12, CDS для стандартной библиотеки языка включен по умолчанию.
Цитируя и переводя всё того же Николая Парлога:
«Самый простой способ начать использовать совместное использование данных классов - ограничиться классами JDK, поэтому сначала мы поступим именно так. Затем мы увидим, что простой JAR-файл “Hello, World” можно запустить почти вдвое быстрее.
Если вы используете Java 12 или более позднюю версию, вам не нужно ничего делать, чтобы воспользоваться преимуществами такого архива. JDK поставляется с этим архивом и использует его автоматически».
Это отличные новости: можно ничего не делать. Как бы не так. Ведь мы используем Spring, а значит, уметь мапить только классы JDK нам недостаточно. Ведь основную массу классов в проект приносит Spring и библиотеки, приходящие с зависимостями. Кроме того, мы живём в мире, где всем правят контейнеры. А это делает использование .jsa-архивов уже гораздо более сложным. Но давайте обо всём по порядку.
Итак, нам бы хотелось, чтобы можно было быстро загрузить в память классы нашего приложения, а не только JDK. Здесь на сцену выходит AppCDS.
AppCDS (Application Class-Data Sharing) - это технология в Java, которая позволяет заранее подготовить и сохранить в общий архив (CDS-архив) метаданные классов не только JDK, но и самого приложения, чтобы ускорить запуск JVM и снизить потребление памяти.
Сама концепция AppCDS описана в JEP 310: https://openjdk.org/jeps/310
Об изысканиях и замерах по времени старта при использовании CDS и APPCDS можно почитать статью написанную Владимиром Плизгой: https://habr.com/ru/articles/472638/
Отдельно следует ознакомиться с его докладом на JPoint 2019:
Детальным пересказом заниматься не стоит, но было бы полезным кратко обежать взглядом разные полезности и проблемы AppCDS, с которыми приходилось сталкиваться, вставая на путь использования технологий шаринга классов до определенного времени.
Скомпилировать приложение
Запустить и “потыкать” приложение для генерации списка классов к архивации (Если использовать аргумент
-XX:DumpLoadedClassList=classes.ls, то JVM запишет все классы, которые были загружены при выполнении.)Создать CDS-архив
И только потом использовать этот самый AppCDS во время запуска.
Сложновато не правда-ли? Однако, есть и хорошие новости на дворе 25-ый год и это значит, что все эти проблемы уже были встречены грудью аудитории языка Java, задокументированы и решены. Начиная с Java 13 у нас появился Dynamic AppCDS, описанный в JEP 350: https://openjdk.org/jeps/350
Это значит, что действий теперь нужно делать меньше. Все еще надо два запуска для формирования нужного архива, но хорошая новость в том, что архив будет создан автоматически и для этого нам нужно уметь писать всего два аргумента:
-XX:ArchiveClassesAtExit=archive.jsa - нужен во время тестового запуска, и говорит о том, что по завершении жизни, если это корректное, а не аварийное завершение приложения надо сформировать .jsa архив.
-XX:SharedArchiveFile=archive.jsa - нужен во время второго - “продуктивного” запуска, чтобы созданный архив использовать.
Однако, у такого подхода есть некоторые трудности в современном мире, где правят контейнеры, кубернетисы и иже сними.
“Application Class-Data Sharing (AppCDS) in Java is a feature that enhances the startup performance and reduces the memory footprint of Java applications. It works by creating a shared archive of frequently used classes, including application-specific classes, which can then be memory-mapped and shared across multiple JVM instances running on the same host.”
Что в переводе означает:
“Совместное использование классов приложений и данных (AppCDS) в Java - это функция, которая повышает производительность запуска и сокращает объем памяти, занимаемый приложениями Java. Она работает за счет создания общего архива часто используемых классов, включая классы, специфичные для приложения, которые затем можно отобразить в память и использовать совместно между несколькими экземплярами JVM, работающими на одном хосте.”
А у нас Kubernetes с его возможностью растекаться по нодам и приложениями в контейнерах. Ага. Кто виноват? Как это работает и что делать?
Существует два варианта:
Включить .jsa архив в образ. В данном случае контейнеры поднимаются и каждый использует свой .jsa. Это избавляет от необходимости заботиться о том, как контейнеры шарятся между нодами и где они поднимаются. Хотя, часто контейнеры и поднимаются на одной ноде, что приводит нас к мысли о том, что можно использовать shared volume для того, чтобы сделать .jsa-архив доступным между разными подами, это накладывает на нас множество дополнительных забот о том, как этот архив шарится, все ли умеют до него достучаться, все ли поды используют одну и ту же сборку приложения, одинаковая ли на них версия JVM и так далее и тому подобное. Гораздо проще иметь локальный .jsa архив внутри образа и не делать простое сложным.
Второй вариант подразумевает наличие общего тома, совпадение путей и версии JVM и доставляет немало хлопот, особенно когда у вас несколько нод. Так как на каждой ноде этот том должен быть своим и должна существовать своя копия .jsa-архива. Если же нас интересует доступ к одному .jsa-архиву при поднятии подов на разных нодах, мы сталкиваемся с большим количеством настроек и необходимостью разбираться с массой «видеоэффектов». Стоит ли игра свеч? Не думаю. Поэтому этот вариант опустим.
Итак, к чему были все эти долгие вступления?
Давайте легко и просто попробуем AppCDS в Docker на локальной машине. Заодно посмотрим и на какие-нибудь цифры ускорения.
На самом деле на «ТыТрубе» уже есть англоязычное видео о том, как это делается, но там Dockerfile пишут руками, а сегодня этого можно не делать, потому что для подобных вещей есть Amplicode:
Да и проверить всё необходимое хотелось бы самостоятельно.
Во всём, что касается Docker, Docker Compose и всего, что связано с контейнерами, переменными и т. д., — пожалуй, за исключением флагов для Spring AOT, так как не все ещё до него дожили и в локальных реалиях Spring Boot 3.3+ ещё не до всех доехал (что грустно, так как на момент написания статьи уже существует Java 25, а Spring 7 вышел в General Availability), — нам поможет Amplicode.
Хотя, пока писалась эта статья, мы в Amplicode взяли необходимое на заметку и добавим возможность использовать Spring AOT и Spring AOT Cache в описании Docker-файлов и в настройках Gradle, но чуть позже — в следующем релизе новой функциональности. Сегодня же мы допишем всё необходимое для Spring AOT вручную.
Подготовка и установка необходиомого
Для этого нам потребуются:
Приложение Docker Desktop или его аналог на вашем компьютере (В примере будем использовать Rancher. Он бесплатный опенсорсный и ни от кого не прячется.)
Подопытный проект. Чтобы далеко не ходить скачаем с типичный sample java-проект со спрингом - Spring Petclinic (https://github.com/spring-projects/spring-petclinic)
Amplicode (https://amplicode.ru/)
И какая-нибудь среда разработки. Например, OpenIDE, потому что понадобится docker-plugin, а он там есть бесплатный и доступен к установке из маркетплейса (Сам процесс установки плагина будет опущен в данной статье).
А ещё в OpenIDE поддержана Java 25, что оказалось полезным в ходе экспериментов. Сейчас OpenIDE — это единственная среда разработки на российском рынке, где эту самую Java 25 можно попробовать во всей красе.
Практическая часть
Включаем и выключаем CDS для стандартной библиотеки
Сперва-наперво попробуем запустить приложение с CDS(он включен по-умолчанию), а затем без него.
Для этого нам потребуется база. Она будет поднята при помощи docker-compose-файла, который поможет создать Amplicode.
Сгенерируем docker-compose.yaml, просто прокликав все по-умолчанию.

Далее воспользуемся подсказкой среды, нажмем на “лампочку” и создадим описание для PostgreSQL на основании существующего описания в application.properties.

В качестве результата получим валидное описание сервиса.

Запустим полученный сервис при помощи установленного докер плагина.

После этого откроем Run-конфигурацию нашего приложения в OpenIDE и добавим VM-Option для вывода логов CDS-Xlog:cds

Стартуем Spring-приложение, используя Java 17 и уже включённый по умолчанию CDS, и посмотрим в консоли время запуска. Получилось 3,7 секунды. Я провёл много(100 штук) замеров, посчитал 90-й перцентиль, и он составил 3,9612.
На всякий случай дам пояснение термина перцентиль:
90-й перцентиль - это такое число, что 90% элементов массива меньше или равны ему. В моём случае проводилось 100 экспериментов для каждого кейса. Значит, в 90% попыток результатом было время запуска, меньшее или равное этому числу. Перцентиль сохраняет единицы измерения исходных данных. Везде в статье, где вы увидите перцентиль, знайте, что речь идёт о секундах.

А еще в консоли есть логи CDS.

А теперь выключим CDS и повторим.
Для этого выполним gradle clean, пропишем VM-options -Xshare:off -Xlog:cds,пересоберемся и перезапустимся.

В логах после запуска увидим, что CDS выключен.

На время старта это сильно не повлияло. Вышло 3,371 секунды. Парадоксально: кажется, стало быстрее? Но тем и хороши множественные эксперименты, в отличие от одиночных результатов, — мы можем увидеть закономерность.

Я снова провел необходимое число экспериментов. Время колебалось в диапазоне от 3,3 до 4,7 секунд. Я посчитал 90 перцентиль. Он был равен 4,667.
Сравним разницу в процентах между числами. Она составляет 15,1%. Это меньше, чем я ожидал. Однако, будем честны - ванильный классический CDS JDK, скорее всего не так сильно влияет на время старта спрингового приложения. Ведь основное время занимает старт спринга.
AppCDS на локальной машине
Тут начинается самое интересное. Будем пробовать AppCDS в Docker и вне Docker-контейнера и сравним результаты экспериментов с теми, которые мы получили, просто запустив Spring-приложение со включённым CDS, так как его результаты лучше, чем те, где CDS выключен.
Начнем, пожалуй, без контейнера. Тут я был немного обескуражен тем, что среда разработки не подходит для выполнения сценария генерации .jsa архива. Как я не старался прописывать различные VM опции - не работало.(Может быть я упустил какие-либо детали из вида и все работает, но моя практика такова.)
Пришлось воспользоваться терминалом среды разработки. В качестве системы сборки у меня Gradle.
Пересоберем проект: gradle clean build
Далее я выполнил следующий набор команд:
java -Djarmode=tools -jar build/libs/spring-petclinic-3.3.0.jar extract
java -XX:ArchiveClassesAtExit=./application.jsa -Dspring.context.exit=onRefresh -jar spring-petclinic-3.3.0/spring-petclinic-3.3.0.jar
Эти команды создают так называемый exploded jar, показывают куда поместить мой .jsa файл и выполняют тренировочный запуск спринг приложения с его остановкой, как только оно запустится. Похожие наборы команд будут встречаться много раз далее по ходу статьи и я бы хотел, чтобы мой читатель понимал что происходит, хотя бы в общих чертах.
В логах множество сообщений о том, какие классы не были добавлены в архив. Хороший знак - значит, всё отработало. Об этом писали те, кто уже проходил этот маршрут вручную до меня. В этом нет ничего страшного. Более подробно эта тема раскрыта в статье Владимира Плизга, приведённой выше.

В корне проекта меня ждал .jsa-архив.

Теперь стоит попробовать запустить приложение, используя этот архив. Сделать это можно, выполнив набор команд в терминале:
java -XX:SharedArchiveFile=application.jsa -Xlog:cds -jar spring-petclinic-3.3.0/spring-petclinic-3.3.0.jar
CDS-логи видны в консоли при старте приложения. Уже хорошо.

Далее видно информацию о старте приложения.

Стартовало за 2 секунды. По сравнению с начальными экспериментами, проведёнными над классическим CDS, прирост на первый взгляд более ощутимый. Понятно, что все приводимые числа — это незначительное время старта, но такой прирост в процентах — как минимум приятно.
Для честности я также провёл необходимое количество экспериментов, как и в предыдущие разы, посчитал 90-й перцентиль и определил, сколько процентов составляет разница между получившимися значениями. Разница составила 31,33%. Это значит, что в 90% случаев прирост производительности при использовании AppCDS составляет 31,33%. Уже лучше.
AppCDS в Docker-контейнере.
Что ж, я обещал лёгкий способ без терминалов, мытарств и изощрений. В реализации этого эксперимента мне поможет Amplicode и его умение генерировать Docker-файлы.
Для начала займёмся генерацией Docker-файла.
В Amplicode Explorer выберем Docker -> New-> Dockerfile

Во всплывающем мастер-окне проставим галочки где необходимо.

И по итогу получим файл следующего вида:

Здесь стоит отметить, что я руками дописал в 25-й строке -Xlog:cds, чтобы видеть вывод CDS-логов.
Далее необходимо выполнить build образа, чего docker-plugin OpenIDE сделать не предложил. Ладно, справляемся из терминала и прописываем docker build.
Теперь самое интересное. Я бы хотел, чтобы всё у меня запускалось при помощи docker-compose, и при этом сервис моей «Петклиники» поднимался только тогда, когда контейнер с базой данных уже запущен и «маякнул» мне, что он жив и здоров.
Для этого в уже существующем docker-compose.yaml стоит добавить сервис и выбрать Spring Boot:

Далее, в открывшемся мастер-окне следует указать зависимость от postgres:

В итоге получим файл следующего вида:

В получившемся файле для работы потребовалось добавить environment и прописать туда корректный POSTGRES_URL, так как в application.properties указан localhost, и с Docker это не работает, о чём было сообщено в консоли при запуске. Однако при замене Amplicode подсказал необходимое, и не пришлось делать почти ничего руками, кроме того, чтобы имя базы данных дописать.

Дальше я запускал приложение с AppCDS и без него. Dockerfile-ы с использованием cds и без него, а также файл docker-compose.yml прикладываю.
Важно: для Docker-образа с AppCDS использовался
liberica-openjre-alpine:17-cds, а для работы без AppCDS -liberica-openjre-alpine:17.Также стоит отметить, что этап сборки образа не прописан в Dockerfile. Приложение я собирал локально при помощи Gradle.
Dockerfile с использованием AppCDS:
FROM bellsoft/liberica-openjre-alpine:17-cds AS layers
WORKDIR /application
COPY build/libs/spring-petclinic-3.3.0.jar app.jar
RUN java -Djarmode=tools -jar app.jar extract --layers --destination extracted
FROM bellsoft/liberica-openjre-alpine:17-cds
VOLUME /tmp
RUN adduser -S spring-user
USER spring-user
WORKDIR /application
COPY --from=layers /application/extracted/dependencies/ ./
COPY --from=layers /application/extracted/spring-boot-loader/ ./
COPY --from=layers /application/extracted/snapshot-dependencies/ ./
COPY --from=layers /application/extracted/application/ ./
RUN java -XX:ArchiveClassesAtExit=app.jsa -Dspring.context.exit=onRefresh -jar app.jar || true
ENV JAVA_CDS_OPTS=" -Xlog:cds -Xlog:class+load:file=/tmp/classload.log -XX:SharedArchiveFile=app.jsa"
ENV JAVA_ERROR_FILE_OPTS="-XX:ErrorFile=/tmp/java_error.log"
ENV JAVA_HEAP_DUMP_OPTS="-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp"
ENV JAVA_ON_OUT_OF_MEMORY_OPTS="-XX:+ExitOnOutOfMemoryError"
ENV JAVA_NATIVE_MEMORY_TRACKING_OPTS="-XX:NativeMemoryTracking=summary -XX:+UnlockDiagnosticVMOptions -XX:+PrintNMTStatistics"
ENTRYPOINT exec java \
$JAVA_HEAP_DUMP_OPTS \
$JAVA_ON_OUT_OF_MEMORY_OPTS \
$JAVA_ERROR_FILE_OPTS \
$JAVA_NATIVE_MEMORY_TRACKING_OPTS \
$JAVA_CDS_OPTS \
-jar app.jarDockerfile без использования AppCDS:
FROM bellsoft/liberica-openjre-alpine:17 AS layers
WORKDIR /application
COPY build/libs/spring-petclinic-3.3.0.jar app.jar
RUN java -Djarmode=tools -jar app.jar extract --layers --destination extracted
FROM bellsoft/liberica-openjre-alpine:17
VOLUME /tmp
RUN adduser -S spring-user
USER spring-user
WORKDIR /application
COPY --from=layers /application/extracted/dependencies/ ./
COPY --from=layers /application/extracted/spring-boot-loader/ ./
COPY --from=layers /application/extracted/snapshot-dependencies/ ./
COPY --from=layers /application/extracted/application/ ./
ENV JAVA_ERROR_FILE_OPTS="-XX:ErrorFile=/tmp/java_error.log"
ENV JAVA_HEAP_DUMP_OPTS="-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp"
ENV JAVA_ON_OUT_OF_MEMORY_OPTS="-XX:+ExitOnOutOfMemoryError"
ENV JAVA_NATIVE_MEMORY_TRACKING_OPTS="-XX:NativeMemoryTracking=summary -XX:+UnlockDiagnosticVMOptions -XX:+PrintNMTStatistics"
ENTRYPOINT exec java \
$JAVA_HEAP_DUMP_OPTS \
$JAVA_ON_OUT_OF_MEMORY_OPTS \
$JAVA_ERROR_FILE_OPTS \
$JAVA_NATIVE_MEMORY_TRACKING_OPTS \
$JAVA_CDS_OPTS \
-jar app.jar
Docker-compose.yml:
services:
org.springframework.samples.spring-petclinic:
environment:
POSTGRES_URL: jdbc:postgresql://postgres:5432/petclinic
image: org.springframework.samples.spring-petclinic:latest
build: .
restart: "no"
ports:
- "8081:8080"
healthcheck:
test: wget --no-verbose --tries=1 --spider http://localhost:8080/actuator/health || exit 1
interval: 30s
timeout: 5s
start_period: 30s
retries: 5
depends_on:
- postgres
postgres:
image: postgres:18.0
restart: "no"
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
environment:
POSTGRES_USER: petclinic
POSTGRES_PASSWORD: petclinic
POSTGRES_DB: petclinic
healthcheck:
test: pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB
interval: 10s
timeout: 5s
start_period: 10s
retries: 5
volumes:
postgres_data:Цифры 90-ых перцентилей получившихся в ходе экспериментов прилагаю:
Приложение c AppCDS в контейнере локально: 3,2977
Приложение без AppCDS в контейнере локально: 3,9745
Прирост производительности при использовании AppCDS составил: 17.03%
В конетйнере на локальной машине было чуть медленнее, чем без контейнера. Чтобы увеличить обьективность оценки, я развернул все необходимые контейнеры в облаке, провёл должное количество "контейнерных" экспериментов с AppCDS и без него, посчитал 90-е перцентили и разницу в процентах между ними. Она составила 21,7%.
90-ый перцентиль для контейнера с AppCDS в облаке был равен: 3,8722
Он же для контейнера без AppCDS в облаке составил: 4,9457
То есть приложение в контейнере с AppCDS стартует на 21,7% быстрее, чем без AppCDS.
Отвечу на вопрос: «А почему у тебя в контейнерах медленнее, чем без контейнеров даже в облаке?»
Здесь мной допущена некоторая ошибка, и время "в облаке" приведено для запуска обоих сервисов. То есть это суммарное время запуска контейнера с базой данных и поднятия Spring-приложения.
Локальный запуск без AppCDS с уже работающей заранее базой | 3,9612 |
Локальный запуск без CDS с уже работающей заранее базой | 4,667 |
Локальный запуск с AppCDS с уже работающей заранее базой | 2,7201 |
Запуск с AppCDS в контейнере на локальной машине | 3,2977 |
Запуск без AppCDS в контейнере на локальной машине | 3,9745 |
Контейнеры в облаке с AppCDS (последовательно 2 сервиса: PostgreSQL и Spring-приожение) | 3,8722 |
Контейнеры в облаке без AppCDS (последовательно 2 сервиса: PostgreSQL и Spring-приожение) | 4,9457 |
Окончательные выводы предоставляю возможность сделать читателю. Дабы не быть осужденным.
Spring AOT и AppCDS
На этом, я думал было закончить, однако мысль о том, чтобы попробовать все необходимое, используя Spring AOT не давала мне покоя. Однако, я решил ограничиться экспериментами на локальной машине. Сравнивать я решил AppCDS-вариант с тем, где используется еще и AOT.
Для начала немного теории.
Spring AOT (Ahead-Of-Time processing) - это механизм Spring Framework, который выполняет часть работы приложения заранее на этапе сборки, а не во время запуска. Проще говоря: Spring AOT - это предварительная компиляция и оптимизация Spring-приложения на этапе сборки.
Впервые поддержка AOT была заявлена в Spring 6 и связанном с ним Spring Boot 3 (однако поддержка Spring AOT Cache, который также будет опробован в ходе статьи требует наличия Java 24+ и Spring Boot 3.3+). В моем случае приложение имеет Spring Boot 3.3.2 и соответствующие версии зависимостей.
Spring заранее генерирует оптимизированный код и метаданные, чтобы приложение:
запускалось быстрее,
потребляло меньше памяти,
использовало меньше reflection,
было совместимо с GraalVM Native Image.
Для использования Spring AOT необходимо добавить плагин в gradle.build-файл

Вот так она добавляется в Gradle:
id 'org.springframework.boot.aot' version '3.3.2'
Перед пересозданием уже знакомого нам exploded-jar пересоберемся с флагом для AOT: gradle clean processAot build
Выполним создание exploded-jar и тестовый прогон из консоли при помощи команд используемых в ходе сценария, где мы рассматривали использование AppCDS на локальной машине, добавив флаг: -Dspring.aot.enabled=true
Получившийся в реультате набор команд:
java -Djarmode=tools -jar build/libs/spring-petclinic-3.3.0.jar extract
java -Dspring.aot.enabled=true -XX:ArchiveClassesAtExit=./application.jsa -Dspring.context.exit=onRefresh -jar spring-petclinic-3.3.0/spring-petclinic-3.3.0.jar
В консоли увидим уже AppCDS логи, а в корне проекта появится уже знакомый .jsa-архив.
Запустим при помощи команды: java -Dspring.aot.enabled=true -XX:SharedArchiveFile=application.jsa -Xlog:cds -jar spring-petclinic-3.3.0/spring-petclinic-3.3.0.jar

Стартовало за 2,071. Начало многообещающее. После экспериментов посчитан перцентиль. Он будет равен 2,301.
Напомню, что для кейса c локальным запуском AppCDS без Spring AOT он был равен 2,7201, а без AppCDS имел значение 3,8997.
Посчитаем процентовку итого имеем прирост равный 15.41%.
Прирост скорости старта при использовании AppCDS без Spring AOT составлял 30,2% в сравнении с обычным запуском без AppCDS.
Сравнивая перцентили для запуска приложения без AppCDS и с AppCDS + Spring AOT, получим прирост по скорости запуска в 41%.
Ощутимо!
«На этом исследования завершены», — хотел сказать я, но вдруг вспомнил, что на дворе 25-й год, а эксперименты я проводил на 17-й версии Java, хотя уже доступна 25-я. А ещё есть Spring AOT Cache.
Java 25, AppCDS, Spring AOT и AOT Cache
Для начала я скачал Java 25 и переключился на неё. OpenIDE предложила мне дистрибутив AxiomJDK 25, я не стал отказываться. Им и воспользуюсь.
Следует отметить, что все предыдущие эксперименты были выполнены с использованием Spring Boot версии 3.3.2. В качестве системы сборки применялся Gradle версии 8.7. Для работы с Java 25 требуется обновление проекта до Spring Boot версии 4.0.0, а также использование Gradle версии не ниже 9.1.
С целью исключения отвлечения на процесс миграции на новую версию Spring в рамках данных экспериментов была использована актуальная версия проекта Spring Petclinic из ветки
main, в которой все необходимые обновления уже выполнены.
На текущем этапе было принято решение проверить, к каким изменениям приведёт перенос обычного Spring-приложения на Java 25 без использования дополнительных оптимизаций. В частности, представляло интерес, приведёт ли такое обновление к повышению производительности.
После уже ставшего традиционным числа экспериментов и подсчёта перцентиля перед глазами оказалась цифра 3,3402. Для версии Java с номером 17 она была равна 3,9612. Это значит, что, просто обновив версию, мы получили прирост в 15,68 %.
Теперь время попробовать 25 Java с AppCDS. Не буду кормить своего читателя скриптами и командами запуска. Они остались теми же. К цифрам.
Для приложения, использующего Java 25 и AppCDS, значение 90-го перцентиля составило 1,5772.
Далее был рассмотрен вариант с использованием AppCDS в сочетании со Spring AOT. В этом случае значение перцентиля составило 1,1997, что является весьма высоким результатом. По сравнению с исходным запуском приложения на Java 17 без дополнительных оптимизаций прирост скорости запуска составил 69,71 %.
И теперь — к ключевой части экспериментов: Spring AOT Cache + Spring AOT + Java 25. Именно ради этого сочетания и проводилось всё исследование. Однако прежде чем перейти к результатам, необходимо кратко остановиться на механизме Spring AOT Cache.
Цитируя и переводя документацию фреймворка:
AOT-cache — это функция JVM, которая помогает сократить время запуска и потребление памяти Java-приложениями. Если вы используете Java < 24, вам следует ознакомиться с разделами о AppCDS. AppCDS является предшественником AOT-cache, но работает аналогично. Spring Boot поддерживает как AppCDS, так и AOT-cache, и рекомендуется использовать AOT-cache, если он доступен в используемой вами версии JVM (Java 24 или более поздняя).
Итак, перейдём к практической части эксперимента.
Сборка проекта выполняется с использованием Gradle следующей командой:
gradle clean processAot build
Далее создаётся exploded-архив приложения с помощью уже знакомой команды:
java -Djarmode=tools -jar build/libs/spring-petclinic-4.0.0-SNAPSHOT.jar extract
После этого выполняется тестовый запуск приложения с включённым Spring AOT, в ходе которого формируется файл AOT-кэша (.aot):
java -Dspring.aot.enabled=true -XX:AOTCacheOutput=./app.aot -Dspring.context.exit=onRefresh -jar spring-petclinic-4.0.0-SNAPSHOT/spring-petclinic-4.0.0-SNAPSHOT.jar
На заключительном этапе производится запуск подготовленного приложения с использованием ранее созданного AOT-кэша:
java -XX:AOTCache=app.aot -Dspring.aot.enabled=true -Xlog:cds -jar spring-petclinic-4.0.0-SNAPSHOT/spring-petclinic-4.0.0-SNAPSHOT.jar
Далее эксперименты были как и прежде автоматизированы, было выполнено 100 запусков приложения. По их результатам был рассчитан 90-й перцентиль, значение которого составило 0,842. Прирост производительности по отношению к обычному запуску Spring-приложения на Java 17 составил 78.74%.
Далее приводится таблица с перцентилями, дополненная результатами описанных выше экспериментов и ранее уже представленная в данной статье.
Локальный запуск на Java 17 без AppCDS | 3,9612 |
Локальный запуск на Java 17 без CDS | 4,667 |
Локальный запуск на Java 17 с AppCDS | 2,7201 |
Запуск на Java 17 с AppCDS в контейнере на локальной машине | 3,2977 |
Запуск на Java 17 без AppCDS в контейнере на локальной машине (Java 17) | 3,9745 |
Контейнеры в облаке с AppCDS (поднимаются последовательно 2 сервиса: PostgreSQL и Spring-приожение) (Java 17) | 3,8722 |
Контейнеры в облаке без AppCDS (поднимаются последовательно 2 сервиса: PostgreSQL и Spring-приожение) (Java 17) | 4,9457 |
Локальный запуск на Java 25 без AppCDS | 3,3402 |
Локальный запуск на Java 25 с AppCDS | 1,5772 |
Локальный запуск на Java 25 с AppCDS + Spring AOT | 1,1997 |
Локальный запуск на Java 25 с AOT Cache + Spring AOT | 0,842 |
Заключение
Подводя итоги, следует отметить, что в рамках данных исследований могли быть получены и более впечатляющие численные показатели, однако итоговые результаты в значительной степени зависят от характеристик конкретной машины. Кроме того, для оценки производительности реальных приложений целесообразно выполнять тестовые прогоны с более развернутыми сценариями запуска, включающими вызовы API либо выполнение полноценных бизнес-сценариев.
Оптимизация времени старта Java-приложений может быть реализована различными способами, и охватить их все в рамках одного исследования затруднительно. Тем не менее для современных Spring-приложений рассмотренные подходы представляются наиболее простыми и предпочтительными, поскольку они сопровождаются минимальным уровнем сложности и рисков по сравнению с альтернативными вариантами.
В ходе эксперимента мной также был достаточно подробно опробован функционал OpenIDE и Amplicode. На мой взгляд, данные инструменты существенно упрощают повседневную работу разработчика и делают процесс разработки более комфортным, снижая порог входа и сглаживая сопутствующие сложности. На этом у меня все. Благодарю за внимание.

Актуальные новости про Spring и его экосистему проще всего получать, подписавшись на наш телеграм канал. Получить актуальную версию Amplicode можно на нашем сайте.
