С ростом размера Maven проекта встает вопрос скорости сборки и выполнения тестов. Один из самых очевидных способов оптимизации - переезд на другую систему сборки (например, Gradle). Однако перед тем как переезжать (что неизбежно создаст новые проблемы - об этом в конце) давайте поговорим о том, что мы можем сделать для ускорения, оставаясь на Maven. Тем более что многие советы нам пригодятся в любом случае, даже если переезд в итоге и состоится.
Мы начнем с простого, поговорим про кеши и build-сканы и затем заглянем под капот Maven, чтобы понять, как найти и устранить узкие места, а также детально сравним с Gradle. Эффект от каждого совета по отдельности может быть и не такой заметный, но в совокупности ускорение может быть в разы!
В рамках этой статьи мы поговорим в основном про оптимизацию компиляции.
Проверим JDK
Если инженеры на вашем проекте - счастливые обладатели MacBook на процессорах Apple (аналогично Windows на ARM), стоит убедиться, не используется ли JDK на x64 в режиме эмуляции трансляции. Несмотря на очевидное удобство технологий вроде Rosetta, это создает достаточно заметную дополнительную нагрузку на CPU. В консоли это можно проверить командой mvn -v
:

Эту проверку можно автоматизировать и давать соответствующее сообщение (или вообще ломать сборку) на локальных окружениях, которые вы не можете контролировать - в случае если вы хотите решить этот вопрос для группы разработчиков вашего проекта. Готовый плагин jvm-arch-maven-plugin.
Если хотите реализовать проверку сами
Вы можете реализовать собственную проверку архитектуры процессора и сравнить ее с архитектурой JVM (не обязательно делать плагин, это можно сделать даже через bash-скрипт), но здесь есть неочевидный момент с работой System.getProperty("os.arch")
в режиме эмуляции и без. Подробнее тут: macOs, Windows
Следующий немаловажный фактор выбора JDK: в среднем каждый новый релиз JDK быстрее предыдущего. Речь в том числе и про работу javac
. Особенно это будет заметно при апдейте на несколько мажорных версий. Так что если вы все еще используете старый JDK - вот еще один повод обновиться. Вендор JDK также имеет значение - результаты могут сильно разниться (например, Oracle JDK далеко не самый быстрый). Есть рекомендация использовать тот же дистрибутив, что и в продакшн, так что здесь смотрите сами.
Уточнение про апгрейд JDK
Во избежание обвинений в необоснованности утверждения, что более новая версия JDK имеет более быстрый компилятор, хочу уточнить, что здесь стоит провести множество тестов, т.к. цифры могут оказаться разными для разных вендоров. Автор наблюдал существенно лучшие результаты на Amazon Corretto 17 по сравнению с Corretto 11, и на Corretto 21 еще немного лучше.
Kotlin
Если в вашем проекте используется Kotlin, есть смысл обновить его версию до 2.x
. JetBrains проделали большую работу, существенно переписав компилятор - с K2 компиляция станет заметно быстрее, даже если вы используете смесь java+kotlin в своих модулях.
Для большей безопасности лучше обновиться в три шага:
сначала до
kotlin 1.9.25
(последняя версия1.x
)затем на
kotlin 2.0.21
(последняя версия2.0.x
) в режиме старого компилятораи наконец в режиме компилятора K2
Режим версии компилятора (не то же самое что версия kotlin plugin) определяется через параметр language-version
:

Кеши сборок и build-сканы
Пожалуй, самая главная проблема производительности Maven - отсутствие встроенного механизма кеширования. Судя по всему, это и не предвидится в ближайших версиях, зато существует как отдельные решения.
Apache предлагает Maven Build Cache Extension, который поддерживает локальные и remote (в первую очередь для CI/CD) кеши. Ограничением этого решения является то, что он кеширует т.н. фазы жизненного цикла сборки (Maven Lifecycle Phases). Это в целом неплохо работает для проектов среднего размера, где нет особой кастомизации. Есть смысл посмотреть в эту сторону, если в вашем проекте нет собственных плагинов и решений вроде селективного выбора тестов.
Типичная конфигурация apache maven build cache extension состоит из двух файлов: .mvn/extensions.xml
и maven-build-cache-config.xml
. Вместо .mvn/extensions.xml
расширение можно определить также в корневом pom.xml
, как это сделано в проекте jetty (сам файл конфигурации тут):

Как оказалось, Maven достаточно гибок в плане кастомизации поведения. Так что вы можете написать даже свое собственное расширение или плагины для кеширования сборок, распределенного и селективного выполнения тестов. В качестве примера - мои наработки для кеширования maven-surefire-plugin
и maven-failsafe-plugin
: https://github.com/seregamorph/maven-surefire-cached
Develocity (ранее известный как Gradle Enterprise) от Gradle - альтернативное решение с закрытым кодом. Добавляет в Maven механизм кешей через Develocity Maven Extension, который отличается от решения Apache. Тут кешируется каждый plugin goal, т.е. в целом это более гранулярно. Этот механизм кеширования больше похож на тот, что реализован в Gradle. Remote кеш идет только в комплекте с дорогостоящей лицензией на сам Develocity, зато есть бесплатный вариант с локальным кешем и build-сканами на их облачный сервер. К слову, build-сканы тоже можно выключить, если это нарушает требования безопасности вашей компании. При этом локальный кеш сборок останется.
Remove-кеши в Gradle - бесплатно
Приятным открытием для автора стало то, что remote-кеши для Gradle не требуют лицензии Develocity. Вы можете развернуть свой standalone сервер, который распространяется в виде Docker-образа. Вы можете реализовать даже свое решение для хранения кешей на базе файлового хранилища, протокол там достаточно простой. Это может очень ускорить ваши сборки в CI/CD окружениях.
Настраивается тоже двумя файлами:

Пример простой конфигурации .mvn/develocity.xml
, при которой Develocity не будет запрашивать разрешение на создание build-скана каждый раз:
<develocity>
<buildScan>
<publishing>
<onlyIf>true</onlyIf>
</publishing>
<termsOfUse>
<url>https://gradle.com/help/legal-terms-of-use</url>
<accept>true</accept>
</termsOfUse>
</buildScan>
</develocity>
Добавим extension в проект Netty и сравним сборку. По сравнению со скоростью по умолчанию (2m11s
) ускорение почти в два раза. По завершению сборки в консоли будет ссылка на build-скан вроде такой https://gradle.com/s/lh7h42es3riko, там можно посмотреть статистику выполнения, в т.ч. работу кешей:

Build-сканы
Develocity предоставляет достаточно детализированную информацию по сборке проекта: потребляемые ресурсы, эффективность работы кешей и таймлайны. Это существенно упрощает диагностику проблем и пост узких мест.
Пример build-сканов проектов spring: https://ge.spring.io/scans
Параллельная сборка
По умолчанию Maven собирает проект одним потоком, но поддерживает и многопоточный режим через параметр -T
с фиксированным числом тредов, либо через коэффициент числа ядер C
:
# 6 тредов
mvn clean package -T6
# столько же тредов, сколько ядер процессора
mvn clean package -T1C
Тут следует иметь в виду, что не любой проект корректно поддерживает многопоточную сборку. Например, если запускаются тесты, которые используют фиксированные номера портов, это приведет к конфликтам. В таком случае тесты можно отрефакторить и это даст существенное улучшение по скорости. Еще из недостатков - лог станет смешанным для всех параллельных задач, что может затруднить диагностику проблем.
Эти параметры можно прописать по умолчанию через .mvn/maven.config
, чтобы не указывать каждый раз в командной строке:

Давайте запустим сборку еще один раз с выключенными кешами. Это позволит нам лучше увидеть узкие места параллелизации:
mvn clean install -DskipTests=true -Dgradle.cache.local.enabled=false -T6

На временнóй диаграмме выше видно распределение работы разных потоков - где-то активны сразу 5 тредов, где-то только один. Это и есть наше узкое горлышко и первые кандидаты для оптимизации.
Maven Daemon
Перед тем как мы поговорим о том, как нам улучшить параллелизм, еще один способ визуализации узких мест параллельной сборки на тот случай если строгие безопасники не разрешают выгружать build-сканы в чужое облако. Речь про maven daemon (mvnd). Это относительно новый проект, недавно получивший релиз 1-й версии. Он в том числе имеет реализацию кешей и включает многопоточную сборку как опцию по умолчанию. В целом опыт использования я бы не назвал успешным (это, конечно, не значит что так будет и у вас, так что есть смысл попробовать), т.к. до сих пор есть несколько критичных багов. Зато он имеет весьма похожий на Gradle анимированный UI вроде этого демо:

Подобно котику, который из всего подарка больше всего радуется коробке, мы будем весьма специфично использовать mvnd
- смотреть покадрово скриншоты выполнения сборки (собирать нужно с выключенными кешами!) - на них отлично видно где хороший параллелизм, а где - нет:

На втором кадре - модуль-кандидат для оптимизации (нет параллелизма).
Как работает параллелизм в Maven?
Ключевое здесь - это, конечно, многомодульность. Одномодульный проект практически не имеет пространства для оптимизации.

Для модулей на диаграмме параллелизм возможен между теми из них, что не зависят друг от друга. Например, feature1-impl
может собраться параллельно с feature2-impl
. Давайте заглянем под капот Maven - как он организует параллельную сборку.
Посмотрим на отдельно взятый модуль, который объявляет зависимости на другие модули и библиотеки из репозитория (upstream dependencies), модули что зависят от текущего - downstream dependencies:

Управляет исполнением этого дерева MultiThreadedBuilder и у него очень простой контракт:
Перед тем как начать собирать текущий модуль планировщик ожидает полную сборку (все фазы) всех модулей, от которых он зависит (upstream dependencies), независимо от dependency scope. Это работает и в другую сторону - любой модуль что зависит от текущего, будет ждать завершения всех фаз сборки и только после этого сам встанет на сборку. Все фазы в рамках одного модуля выполняются в цикле по очереди одним потоком.
Иными словами, даже если наш условный модуль core
зависит от test-utils
в <scope>test</scope>
, maven не начнет собирать core
пока не соберет и не прогонит тесты test-utils
. Это существенно уменьшает параллелизм и многоядерный процессор просто остается незагруженным.

Сравнение с Gradle
В этом смысле в Gradle всё намного лучше: дерево зависимостей в нем строится не между модулями (Project), а между задачами (Task). Поэтому Gradle будет как правило делать это быстрее (потребляя больше памяти). Кроме того, Gradle собирает артефакты (jar / war) не дожидаясь компиляции и исполнения тестов.

Убираем ненужные зависимости
Чем меньше зависимостей у модуля, тем раньше Maven сможет его собрать (и тем раньше он станет доступным для сборки другим модулям, что от него зависят). Для этого можно использовать стандартный плагин dependencies
:
mvn dependency:analyze
...
[WARNING] Unused declared dependencies found:
[WARNING] org.springframework.boot:spring-boot-starter-web:jar:2.4.1:compile
[WARNING] org.springframework.boot:spring-boot-starter-data-jpa:jar:2.4.1:compile
[WARNING] org.hibernate.validator:hibernate-validator:jar:6.1.6.Final:compile
[WARNING] com.h2database:h2:jar:1.4.200:runtime
[WARNING] com.fasterxml.jackson.core:jackson-databind:jar:2.11.3:compile
[WARNING] com.fasterxml.jackson.datatype:jackson-datatype-jsr310:jar:2.11.3:compile
[WARNING] org.springframework.boot:spring-boot-starter-test:jar:2.4.1:test

Важно быть осторожным при удалении и не полагаться слишком сильно на советы плагина. Вполне возможно удаленная зависимость должна остаться в итоговой сборке. Но чем ниже по дереву зависимостей она добавлена (скорее всего в вашем проекте есть модуль вроде app
, который зависит от всех остальных - убедитесь что она там) - тем лучше параллелизация.
Разделение больших модулей (независимые части)
Есть смысл посмотреть на структуру самых больших модулей в проекте - скорее всего у них и зависимостей больше и есть шанс найти независимые группы классов. Разделив их на разные модули, мы скорее всего сможем сократить и зависимости каждого из них.

Можно добиться существенного ускорения сборки, разбив несколько самых больших подобных модулей. В тексте выше описано, как найти первых кандидатов для оптимизации (узкие места без параллелизма).
Разделение больших модулей (зависимые части)
Если вы активно используете интерфейсы и Dependency Injection (либо SPI через ServiceLoader
), крупные модули можно разделить на зависящие друг от друга части - -api
(с интерфейсами) и -impl
(реализации). Этот подход в целом весьма способствует лучшему дизайну интерфейса модулей, скрывая детали реализации. Кроме того, опять же количество необходимых зависимостей существенно сокращается:

Большинство модулей, которые зависели от большого разделенного модуля, теперь будут зависеть только от его -api
части. Исключение - модули приложений (app
) и модули с интеграционными тестами - им по-прежнему нужны -impl
модули.
Выносим кодогенерацию в библиотеки
Есть два основных вида кодогенерации:
из статических дескрипторов вроде protobuf или avro-файлов, SQL (вроде jOOQ), OpenAPI из YAML
генерируемый из самого кода - Apache Feign, OpenAPI и кода и пр.
В первую очередь мы говорим про первый тип, т.к. он он не привязан к текущему коду и может быть практически безболезненно выпилен. По сути такие кодогенерации зависят только от самого инструмента и его "схемы".
В целом кодогенерация прям в проекте - это, конечно, удобно, т.к. всё лежит в одном месте - и схемы, и исходники, и генеренный код. Но это порождает ряд проблем: во-первых, нельзя просто открыть проект в IDEA и скомпилировать его через IDE - требуется вызов maven команд. После этого любой запуск приложения или тестов из IDE начнет сборку заново, т.к. среда разработки не сообразит, как правильно сделать инкрементный билд. Кроме того, единожды сгенеренный он рано или поздно устареет - например, при обновлении схем можно наткнуться на непонятные баги, которые решаются перегенерацией. Как бы то ни было, сгенерированный код нужно еще и откомпилировать, а это тоже отнимает ресурс. Если схемы или SQL меняются не особо часто, есть смысл вынести всё в библиотеку и притащить как зависимость.
Если кодогенерацию очень уж хочется оставить
В случае переезда на Gradle это может работать более эффективно за счет кешей. А еще в IDEA операции сборки по умолчанию делегируются в Gradle, т.е. при сборке автоматически будет принято решение, нужна ли перегенерация или нет.
Оставаясь на Maven можно переконфигурировать кодогенерацию из target/generated-sources/java
в src/main/java
, т.е. включить этот код в контроль версий и обновлять каждый раз вместе с изменением схемы. Поначалу такой подход кажется странным, но по факту оказывается весьма неплохим компромиссом, который работает на относительно больших проектах.
Еще раз хочу обратить внимание, почему вынос кодогенерации важен:
У вас появится возможность переключиться с Maven-сборки (локально) полностью на инкрементную сборку в IDEA - а она безусловно быстрее почти всегда
IDEA: параллельная компиляция
Раз уж мы упомянули IDEA, обратим внимание на еще один момент: параллельная компиляция в настройках проекта:

В зависимости от версии IDEA по умолчанию она выключена или находится в режиме Automatic (в новых версиях). Не забудьте увеличить и размер памяти.
Перестаньте публиковать ненужные артефакты
Если вы делаете библиотеку, полезно выкладывать и sources.jar
. Но если вы собираете приложение, то не стоит каждый модуль deploy'ить отдельно, да еще и с -javadoc
, -sources
, -test-sources
и -test-jar
(можно публиковать итоговый артефакт, в идеале многослойным docker-образом для экономии места). Подобные операции не только занимают много места в репозиториях, но это еще и занимает большую часть времени самой сборки.
Даже если вы их не публикуете, проверьте есть ли такие конфиги в вашем проекте (еще раз - если только мы не говорим именно про библиотеку) - возможно их стоит убрать:
maven-source-plugin
(jar-no-fork
)maven-javadoc-plugin
(jar
)
Персистентные build-агенты CI/CD
Для агентов сборки CI/CD можно использовать т.н. персистентные агенты, т.е. инстансы, которые переиспользуются между несколькими сборками. Это может существенно сократить время сборки за счет экономии на скачивании зависимостей из репозитория, плюс в локальной файловой системе могут храниться кеши предыдущих сборок.
Здесь важно соблюсти баланс и не делать их слишком долгоживущими - это может и увеличить стоимость эскплуатации и усложнить диагностику проблем, которые бы решились простым перезапуском пустого агента.
Используйте Maven-профили
Если есть задачи, которые можно пропустить при сборке, их лучше сделать опциональными и спрятать в профилях. Если какой-либо функционал используется малым процентом разработчиков (например, специфическая кодогенерация или валидация), опять же - не стоит включать ее по умолчанию. Тут нужно выбрать между простотой и оптимизацией - какое поведение ожидается без указания дополнительных параметров.
Профили, соответственно, оставьте для шагов в CI/CD, либо предложите вспомогательные скрипты, чтобы не запоминать как что включить/выключить.
Почему бы просто не переехать на Gradle?
При всех своих преимуществах Gradle имеет и ряд недостатков. Для начала, он потребляет существенно больше памяти и дискового пространства. На больших проектах директории с кешами достаточно быстро набегают в размере. Кроме того, удобство императивного определения build-скриптов - это обоюдоострый меч, который в определенный момент может стать большой проблемой. Gradle скрипты нужно писать опираясь на множество правил, которые порой являются совсем не очевидными, кроме того на мажорных обновлениях это все может сломаться. Если приняли решение мигрировать - вперед, но будьте готовы, что это вовсе не серебряная пуля. Кроме того помимо миграции проектов придется переделать все пайплайны и переучить инженеров вашей организации. Даже с инструментами вроде Develocity диагностика проблем со сборкой вроде т.н. "poisoned" кешей - большая морока. Gradle-демоны тоже могут накопить проблем и зависать и даже отказываться закрываться по команде gradle --stop
. Удивительно, но в некоторых сценариях Maven может быть существенно быстрее из-за того, что фаза конфигурации Gradle может занимать продолжительное время (Maven за то же время просто успевает все перекомпилить).
В целом зрелые проекты скорее переедут на Gradle в итоге, чем нет (как правило, ради оптимизации тестов). Но есть множество проектов в активной разработке, которые чувствуют себя прекрасно и на Maven. Рано его списывать со счетов, тем более что он продолжает развиваться и стабильно держит свой процент среди Java-проектов.
novoselov
Проводили сравнение по скорости Gradle Cache (Develocity) и Maven Cache?
И насчет того что Maven Cache "в целом неплохо работает для проектов среднего размера", есть пример проекта большого размера где бы он плохо работал?
seregamorph Автор
Нет, но предположу что для билда, где все таски берутся из кеша результат будет примерно одинаковый.
Здесь я еще раз заострю внимание на том, что дело по сути не в размере, а в том что проект большего размера с большей вероятностью будет иметь кастомизацию. Отвечая на вопрос - да, есть пример такого проекта. Там из-за большого количества интеграционные тесты были разделены на группы и выполнялись в отдельных джобах. При этом компилировалось всё один раз на первом шаге (
mvn clean package -DskipTests=true
), и передавалось на следующий этап в параллельные пайплайны (mvn failsafe:integration-test failsafe:verify -Dit.test=$TEST_FILTER_1
гдеTEST_FILTER_1
- N разных фильтров тестов), так экономили на компиляции. С apache build cache возникло несколько проблем:он работает только с циклами lifecycle, а не goal. Т.е. указывать goal нельзя
failsafe:integration-test
, можно например так:mvn verify
, но тогда Maven будет прогонять все шаги включая компиляцию (в теории ее наверно можно отключить через skip compile plugin; но это не единственная проблема)оказалось достаточно проблематично подобрать такую конфигурацию extension, чтобы всё правильно кешировалось. Т.е. легко допустить ошибку, при которой кешируется пустой результат запуска тестов и потом ошибочно берется из кеша даже при других параметрах
Думаю, в целом можно настроить extension, поэтому я и рекомендовал его посмотреть. В нашем случае пока разбирались в коде apache build cache extension (который весьма компактный проект), написали свой собственный, т.к. в любом случае нужны были доработки вроде дополнительной мета-информации, сбор метрик и пр.
novoselov
Можете поделиться своими наработками?
Может стоит их отправить в Apache?
Maven Cache еще долго бы без посторонней помощи разрабатывали, хотя после Gradle Cache идея очевидная.
seregamorph Автор
Плагины с поддержкой кеширования: https://github.com/seregamorph/maven-surefire-cached
Прототип переписанного планировщика с улучшенным параллелизмом: https://github.com/seregamorph/maven-surefire-cached/pull/3 - это изменение по сути подразумевает слом обратной совместимости из-за очередности фаз сборки
По поводу поделиться с Apache - мне не жалко (код выше под лицензией Apache 2.0). Только нет уверенности, что это попадает в их планы. Я смотрел несколько видео и читал статьи от мейнтейнеров проекта и насколько понял, они очень сильно заморочены на обратную совместимость. С одной стороны это хорошо, с другой - это существенно ограничивает потенциальное развитие проекта. В конце концов, Maven - это инструмент, а не язык программирования, поэтому ломающие изменения здесь могли бы быть ок (а иначе зачем обновляться?). Для сравнения Gradle регулярно ломает совместимость своих API, но делают они это весьма аккуратно, заблаговременно объявляя API устаревшими, давая понятную диагностику и пр. И их инструмент активно развивается.
При возможности думаю поделиться своими идеями с мейнтейнерами Maven (либо письмом, либо где-нибудь на конференции).