Введение

Доброго времени суток забредшему на заголовок читателю. Сегодня будем говорить о том, как попробовать фичу, пришедшую в мир простых смертных Java-разработчиков вместе с 10‑й версией языка, и попробуем нагнать упущенный «пароход» и быть модными и «в теме», но обо всём по порядку.

Прежде чем начать, оговорюсь о том, что, когда в статье употребляется термин JVM, имеется в виду именно HotSpot JVM, и речь не идёт про другие реализации.

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

Оглавление

Немного теории и историческая сводка

Начну просто. На заре существован��я JVM исходный код компилировался в байткод, находящийся внутри .class-файлов, и исполнялся, будучи преобразованным интерпретатором в машинный код построчно. В этом, кстати, и кроется хвалёная джавовая WORA. Различные интерпретаторы для различных видов машин способны корректно выполнять один и тот же байт-код, по-разному его интерпретируя.

Последовательность выглядит примерно вот так:

  1. Загрузка байт-кода ClassLoader'ом

  2. Верификация байт-кода

  3. Определения происхождения class-файла и проверка обратной бинарной совместимости

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

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

  6. Интерпретатор начинает использовать байт-код

Особенно далеко на этом не уедешь в реалиях, когда все стремятся выжать из железок все соки производительности и всевозможными способами выдерживать нагрузки и повышать эффективность. А подобные стремления были замечены у программистов с самого начала существования современных языков программирования. Причин у этого было несколько, но сути явления это не меняет. Были придуманы всевозможные подходы для ускорения и оптимизации работы кода в 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-оптимизация происходит только во время работы приложения и уже после старта. Говоря простыми аналогиями, это позволяет разогнать уже “едущий” автомобиль, но не уменьшает количество времени, используемого для того, чтобы его завести и вообще начать “ехать”.

Так вот. Многоуровневая компиляция делится на несколько этапов по оптимизациям:

  1. Интерпретируемый код

  2. Код, который после некоторого количества итераций выполнения скомпилирован C1, но без учета профиля

  3. Код скомпилированный С1 с учетом минимального профиля выполнения

  4. Код скомпилированный С1 с учетом полноценного профилирования

  5. Код скомпилированный С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, с которыми приходилось сталкиваться, вставая на путь использования технологий шаринга классов до определенного времени.

  1. Скомпилировать приложение

  2. Запустить и “потыкать” приложение для генерации списка классов к архивации (Если использовать аргумент -XX:DumpLoadedClassList=classes.ls, то JVM запишет все классы, которые были загружены при выполнении.)

  3. Создать CDS-архив

  4. И только потом использовать этот самый 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 с его возможностью растекаться по нодам и приложениями в контейнерах. Ага. Кто виноват? Как это работает и что делать?

Существует два варианта:

  1. Включить .jsa архив в образ. В данном случае контейнеры поднимаются и каждый использует свой .jsa. Это избавляет от необходимости заботиться о том, как контейнеры шарятся между нодами и где они поднимаются. Хотя, часто контейнеры и поднимаются на одной ноде, что приводит нас к мысли о том, что можно использовать shared volume для того, чтобы сделать .jsa-архив доступным между разными подами, это накладывает на нас множество дополнительных забот о том, как этот архив шарится, все ли умеют до него достучаться, все ли поды используют одну и ту же сборку приложения, одинаковая ли на них версия JVM и так далее и тому подобное. Гораздо проще иметь локальный .jsa архив внутри образа и не делать простое сложным.

  2. Второй вариант подразумевает наличие общего тома, совпадение путей и версии 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 вручную.

Подготовка и установка необходиомого

Для этого нам потребуются:

  1. Приложение Docker Desktop или его аналог на вашем компьютере (В примере будем использовать Rancher. Он бесплатный опенсорсный и ни от кого не прячется.)

  2. Подопытный проект. Чтобы далеко не ходить скачаем с типичный sample java-проект со спрингом - Spring Petclinic (https://github.com/spring-projects/spring-petclinic)

  3. Amplicode (https://amplicode.ru/)

  4. И какая-нибудь среда разработки. Например, OpenIDE, потому что понадобится docker-plugin, а он там есть бесплатный и доступен к установке из маркетплейса (Сам процесс установки плагина будет опущен в данной статье). 

А ещё в OpenIDE поддержана Java 25, что оказалось полезным в ходе экспериментов. Сейчас OpenIDE — это единственная среда разработки на российском рынке, где эту самую Java 25 можно попробовать во всей красе.

Практическая часть

Включаем и выключаем CDS для стандартной библиотеки

Сперва-наперво попробуем запустить приложение с CDS(он включен по-умолчанию), а затем без него.

Для этого нам потребуется база. Она будет поднята при помощи docker-compose-файла, который поможет создать Amplicode.

Сгенерируем docker-compose.yaml, просто прокликав все по-умолчанию.

Генерация docker-compose.yaml в Amplicode
Генерация docker-compose.yaml в Amplicode

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

Подсказки Amplicode в docker-compose на основании имеющегося datasource
Подсказки Amplicode в docker-compose на основании имеющегося datasource

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

Получившееся описание сервиса
Получившееся описание сервиса

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

Docker-plugin в OpenIDE
Docker-plugin в OpenIDE

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

Spring Run configuration в OpenIDE
Spring Run configuration в OpenIDE

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

На всякий случай дам пояснение термина перцентиль:

90-й перцентиль - это такое число, что 90% элементов массива меньше или равны ему. В моём случае проводилось 100 экспериментов для каждого кейса. Значит, в 90% попыток результатом было время запуска, меньшее или равное этому числу. Перцентиль сохраняет единицы измерения исходных данных. Везде в статье, где вы увидите перцентиль, знайте, что речь идёт о секундах.

Сообщение в консоли о времени запуска
Сообщение в консоли о времени запуска

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

CDS-логи в консоли
CDS-логи в консоли

А теперь выключим CDS и повторим.

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

Run configuration с выключенным CDS
Run configuration с выключенным CDS

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

Логи сообщающие о том, что выключен 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-архив
Логи говорящие о том, какие классы не добавлены в jsa-архив

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

Полученный jsa-архив
Полученный jsa-архив

Теперь стоит попробовать запустить приложение, используя этот архив. Сделать это можно, выполнив набор команд в терминале: 

java -XX:SharedArchiveFile=application.jsa -Xlog:cds -jar spring-petclinic-3.3.0/spring-petclinic-3.3.0.jar

CDS-логи видны в консоли при старте приложения. Уже хорошо.

Логи во время запуска с использованием AppCDS
Логи во время запуска с использованием AppCDS

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

Логи о том, за сколько стартовало Spring-приложение с AppCDS
Логи о том, за сколько стартовало Spring-приложение с AppCDS

Стартовало за 2 секунды. По сравнению с начальными экспериментами, проведёнными над классическим CDS, прирост на первый взгляд более ощутимый. Понятно, что все приводимые числа — это незначительное время старта, но такой прирост в процентах — как минимум приятно.

Для честности я также провёл необходимое количество экспериментов, как и в предыдущие разы, посчитал 90-й перцентиль и определил, сколько процентов составляет разница между получившимися значениями. Разница составила 31,33%. Это значит, что в 90% случаев прирост производительности при использовании AppCDS составляет 31,33%. Уже лучше.

AppCDS в Docker-контейнере.

Что ж, я обещал лёгкий способ без терминалов, мытарств и изощрений. В реализации этого эксперимента мне поможет Amplicode и его умение генерировать Docker-файлы.

Для начала займёмся генерацией Docker-файла.

В Amplicode Explorer выберем Docker -> New-> Dockerfile

Создание Docker-файла в Amplicode Explorer
Создание Docker-файла в Amplicode Explorer

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

Мастер-окно генерации Docker-файла c необходимыми AppCDS-настройками
Мастер-окно генерации Docker-файла c необходимыми AppCDS-настройками

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

Получившийся Docker-файл
Получившийся Docker-файл

Здесь стоит отметить, что я руками дописал в 25-й строке -Xlog:cds, чтобы видеть вывод CDS-логов.

Далее необходимо выполнить build образа, чего docker-plugin OpenIDE сделать не предложил. Ладно, справляемся из терминала и прописываем docker build.

Теперь самое интересное. Я бы хотел, чтобы всё у меня запускалось при помощи docker-compose, и при этом сервис моей «Петклиники» поднимался только тогда, когда контейнер с базой данных уже запущен и «маякнул» мне, что он жив и здоров.

Для этого в уже существующем docker-compose.yaml стоит добавить сервис и выбрать Spring Boot:

Добавление Spring-Boot сервиса в docker-compose.yaml
Добавление Spring-Boot сервиса в docker-compose.yaml

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

Мастер-окно добавления Spring-сервиса в docker-compose.yaml
Мастер-окно добавления Spring-сервиса в docker-compose.yaml

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

Получившийся файл
Получившийся файл

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

Подсказки Amplicode в docker-compose.yaml
Подсказки Amplicode в docker-compose.yaml

Дальше я запускал приложение с 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.jar

Dockerfile без использования 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-файл

Добавленная зависимость Spring boot AOT
Добавленная зависимость Spring boot AOT

Вот так она добавляется в 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 можно на нашем сайте.