Всем привет, меня зовут Сергей Прощаев и в этой статье расскажу о том, как провести миграцию рабочего сервиса со Spring Boot 3.x на 4.0 и с Java 21 на Java 25 так, чтобы это была управляемая инженерная процедура, а не двухмесячный пожар.

Узнаёте ситуацию? Вы на Spring Boot 3.4, безопасник прислал тикет про свежий CVE, а апгрейд вы откладывали третий квартал подряд — потому что «и так работает». И вот теперь обновляться надо быстро, а трогать прод страшно: непонятно, что именно отвалится и где рванёт.

Я Tech Lead и руководитель направления Java | Kotlin разработки в FinTech & E-commerce и преподаю на курсах разработки и архитектуры. За последние годы я провёл через мажорные обновления не один десяток сервисов — от маленьких внутренних API до нагруженных платёжных компонентов, где минута простоя стоит конкретных денег. И почти всегда самым сложным было не «как поменять версию в pom.xml», а «как сделать это, не выкатив в прод тихую регрессию, которую никто не заметит до отчётного периода».

Покажу не идеальную картинку из release notes, а реальный рабочий маршрут: с чего начать, в каком порядке двигаться, что требует проверки на проекте, как это проверить и где обновляться пока не стоит.

Рис. 1. Миграция как контролируемая замена несущих опор под рабочим трафиком
Рис. 1. Миграция как контролируемая замена несущих опор под рабочим трафиком

Почему «потом» — самая дорогая стратегия

Начну с неприятного. Если вы сейчас на Spring Boot 3.4, то ваша ветка перестала получать публичные багфиксы и security-патчи 31 декабря 2025 года. Spring Boot 3.5 держится дольше, но и его open-source поддержка заканчивается в июне 2026 — то есть прямо сейчас. После этого вы остаётесь на ветке, в которую больше не приезжают исправления уязвимостей. В FinTech это не «технический долг», это вопрос, который рано или поздно зададут на аудите.

Помню, как однажды мы отложили мажорное обновление «на следующий квартал», потому что «и так работает». Через полгода накопилось три CVE в транзитивных зависимостях, пара из них требовала версий, которые тянулись только новым Boot. В итоге мы делали не плановую миграцию по чек-листу, а аврал под давлением безопасников. Вывод, который я с тех пор повторяю команде: обновления не добавляют бизнес-ценности ровно до того момента, когда их отсутствие начинает её отнимать.

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

Что у нас на руках перед стартом

Сначала зафиксируем вводные, потому что от них зависит сложность пути.

Spring Boot 4.0 вышел в ноябре 2025 года и стоит на принципиально новом фундаменте: Spring Framework 7, Jakarta EE 11, Hibernate ORM 7.1, Spring Security 7, Spring Data 2025.1, Jackson 3 и JUnit 6. Это важная деталь: вы обновляете не один фреймворк, а полтора десятка библиотек разом. Радиус поражения шире, чем кажется по номеру версии.

По Java: формальный минимум для Boot 4.0 — Java 17, но Spring Team даёт «first-class support» для Java 25. Java 25 LTS вышла в сентябре 2025 и стала новым LTS-релизом, так что для новой миграции её разумно рассматривать как целевую версию в первую очередь, а не застревать на 21. Java 26 уже есть (март 2026), но это не-LTS — на проде ей не место.

Здесь же сразу про принцип, который определит весь порядок дальше: не меняйте JDK и Spring baseline одним прыжком. Это два разных вектора — рантайм и фреймворк, — и если что-то отвалится при одновременной смене, вы не будете знать, что именно виновато. Поэтому в маршруте ниже мы сначала переедем на Boot 4 на текущей Java, убедимся, что регрессий нет, и только потом отдельным шагом поднимем JDK до 25. Шагов получается больше, зато диагностика регрессий становится предсказуемой.

Мой вариант, который я обычно использую как отправную точку перед миграцией:

  • Java 21, билд на Gradle 8.14+ (лучше Gradle 9) или свежий Maven.

  • Spring Boot 3.4.x или 3.5.x.

  • Зелёный CI с тестами, которым вы доверяете. Без этого дальше идти нельзя — миграцию вы будете проверять именно тестами.

  • Список зависимостей, которые вы фиксируете явно и которыми Boot не управляет (например, Spring Cloud) — их совместимые версии надо найти заранее.

И отдельно подчеркну, что именно должно быть под тестами до старта, потому что регрессии вы будете ловить ими: контрактные тесты API, интеграционные тесты с БД и миграциями схемы, проверки сериализации JSON, сценарии авторизации и, если есть, consumer-driven contracts. Если этого покрытия нет — сначала закройте его, а уже потом трогайте версии. Миграция без тестов — это не миграция, это лотерея.

Маршрут миграции одним взглядом

Прежде чем нырять в детали, посмотрим на маршрут целиком — он показан на Рис. 2. Главная идея — двигаться по одному вектору за раз: сначала довести фреймворк до 4.0 на привычной Java 21, и только убедившись, что всё зелёное, отдельно поднять рантайм до Java 25.

Рис. 2. План миграции: сначала фреймворк на текущей Java, потом отдельно рантайм
Рис. 2. План миграции: сначала фреймворк на текущей Java, потом отдельно рантайм

Главная мысль, которую стоит вынести из этой схемы: промежуточная остановка на последней 3.5.x — это предохранитель, а разнесённые во времени переходы на Boot 4 и на Java 25 — способ всегда знать, что именно сломалось. В 4.0 удалено около 36 deprecated-классов — это примерно 88% всех устаревших API из веток 2.x и 3.x. Spring Boot 3.5 существует ровно для того, чтобы заранее подсветить компилятором всё, что в 4.0 уже вырезано. Пройдёте через 3.5 с чистыми варнингами — и переход на 4.0 будет читаться по понятным ошибкам, а не как гадание на кофейной гуще.

Шаг 1. Поднять до последней 3.5.x и убить deprecation-варнинги

Это самый недооценённый шаг, и делаем мы его на текущем JDK — менять рантайм пока рано. Меняете версию Boot на актуальную 3.5.x, собираете проект и педантично разгребаете каждый warning о deprecated API. Критерий готовности простой: сборка зелёная, в логе компиляции ноль предупреждений о deprecated.

Шаг 2. Прогнать OpenRewrite

Механическую часть — переименования пакетов, замену устаревших API, обновление ключей конфигурации — отдайте инструменту. И сразу важная оговорка: в реальном проекте запускают не один рецепт, а набор — апгрейд Boot, миграцию Jakarta, обновление зависимостей, JUnit. Один UpgradeSpringBoot_4_0 снимет рутину, но не закроет всё.

<plugin>
  <groupId>org.openrewrite.maven</groupId>
  <artifactId>rewrite-maven-plugin</artifactId>
  <configuration>
    <activeRecipes>
      <recipe>org.openrewrite.java.spring.boot3.UpgradeSpringBoot_4_0</recipe>
    </activeRecipes>
  </configuration>
</plugin>

После прогона обязательно ревью диффа глазами: автоматика хорошо переносит шаблонное, но контекст бизнес-логики не понимает. Критерий готовности здесь — код-ревью изменений, а не «зелёный прогон рецепта».

Шаг 3. Перейти на Spring Boot 4.0.x на текущей Java и разобрать то, что ломается

Теперь — собственно переход на 4.0.x, и всё ещё на Java 21. Это ключевой момент выбранного порядка: самый широкий по радиусу шаг мы делаем на знакомом рантайме, чтобы любая регрессия указывала на фреймворк, а не на JDK.

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

Что меняется

Симптом

Что делать

Jackson 3

Пакеты переехали с com.fasterxml.jackson на tools.jackson, рекомендуемый маппер теперь JsonMapper; ломается кастомная сериализация и контракты дата/время

Проверить кастомные сериализаторы и контрактные тесты API

Spring Security 7

Изменённые дефолты CSRF могут молча сломать сценарии с браузерными/cookie-клиентами; чисто токенный M2M обычно не затронут

Проверить настройки CSRF, если есть браузерные или смешанные сценарии аутентификации

JSpecify null-safety

Старые @Nullable помечены deprecated; на Kotlin и проектах с null-чекером могут всплыть новые ошибки или варнинги

Срочно ничего делать не нужно — аннотации аддитивны; при желании перейти на JSpecify или адаптировать nullability-контракты

Undertow убран

Servlet 6.1 baseline, Undertow несовместим

Перейти на Tomcat (дефолт) или Jetty; для простых проектов — смена стартера, при кастомных handler’ах и тюнинге объём заметно больше

JUnit 6

Падают старые slice-тесты и lifecycle-методы

Обновить тестовые утилиты, заменить удалённые API

MockBean / SpyBean удалены

Не компилируются тесты

Перейти на замены, появившиеся в 3.5

Отдельно про JSpecify — это та самая «ошибка на миллиард долларов», только теперь явная на уровне API. Важная оговорка: собственные аннотации Spring (org.springframework.lang.Nullable) в Spring Framework 7 не удалены, а помечены deprecated, и для большинства проектов JSpecify-аннотации аддитивны — код компилируется и работает как раньше. Боль всплывает в основном на Kotlin, на проектах с подключённым null-чекером (NullAway) и на Java 25, где javac строже относится к type-use аннотациям.

Если решите переходить осознанно, выглядит это так:

// Было
import org.springframework.lang.Nullable;

// Стало
import org.jspecify.annotations.Nullable;

Я бы воспринимал это не как принудительную работу, а как возможность: можно пакет за пакетом через @NullMarked включать честную проверку null там, где раньше тихо прилетали NPE на проде.

Контрольная точка: проект компилируется и стартует на 4.0.x по-прежнему на Java 21, тесты не просто собираются, а проходят, контрактные тесты API остаются зелёными. Именно здесь — основная регрессионная проверка всей миграции.

Бонус: что ещё стоит включить на Boot 4

Раз уж вы на 4.0, заберите и пару вещей, которые упрощают жизнь, — для них Java 25 ещё не нужна.

  • Первая — стабильные HTTP-интерфейсы. То, что в 3.2 было экспериментом, в 4.0 стало полноценным API. Вы описываете клиента как обычный Java-интерфейс, а реализацию генерирует Spring.

@HttpExchange("/api/users")
public interface UserClient {
    @GetExchange List<User> findAll();
    @GetExchange("/{id}") User findById(@PathVariable Long id);
    @PostExchange User create(@RequestBody User user);
}

Для части сценариев это убирает необходимость в OpenFeign — хотя для сложных интеграций (балансировка, retry, fallback, observability) экосистема Spring Cloud по-прежнему актуальна.

  • Вторая — модуляризация. Boot 4 распилил собственную кодовую базу на узкие jar-ы, и стартер теперь тянет меньше транзитивных зависимостей. Меньше зависимостей — меньше поверхность для CVE и быстрее старт. Для нас, в финтехе, сокращение зависимостей под аудит безопасности — почти такой же аргумент, как производительность.

Шаг 4. Отдельным шагом поднять JDK до Java 25 LTS

Фреймворк уже на 4.0 и зелёный — теперь меняем второй вектор, рантайм. Здесь ломается не код, а инфраструктура: CI-пайплайн, базовый Docker-образ, локальные окружения, если где-то ещё живёт старый JDK. Меняйте toolchain явно и синхронно во всех местах.

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(25)
    }
}

Проверка: сервис собирается и стартует на 25, в логе старта нет неожиданных предупреждений о небезопасном нативном доступе (JEP 472, illegal-native-access) от ваших зависимостей, а регрессионный прогон остаётся зелёным.

Шаг 5. Включить виртуальные потоки на Java 25

А теперь обещанная боевая история — и заодно главный практический довод довести миграцию именно до Java 25.

В июле 2024 года команда JVM-экосистемы Netflix опубликовала разбор с честным заголовком «Java 21 Virtual Threads — Dude, Where’s My Lock?». Картина была такая: сервисы на Java 21, Spring Boot 3 и встроенном Tomcat после включения виртуальных потоков для обработки запросов начали периодически зависать. JVM живёт, инстанс жив, а трафик не обслуживается. Характерный симптом — тысячи сокетов, застрявших в состоянии CLOSE_WAIT. В дампе потоков нашлись тысячи «пустых» виртуальных потоков, созданных, но так и не запущенных.

Корень проблемыpinning. Когда виртуальный поток входит в блок synchronized, он закрепляется на несущем потоке ОС и не отпускает его. Если таких закреплённых потоков много, а в них ещё и блокировка, пул несущих потоков (ForkJoinPool) исчерпывается.

Поток, который держит lock, не может быть запланирован, а те, кто ждут этот lock, не могут продвинуться. Чаще pinning просто роняет пропускную способность, но в худшем случае — как раз у Netflix — это сваливается в полноценный дедлок (именно так его и описали в их разборе). Этот механизм показан на Рис. 3.

Рис. 3. Как pinning на synchronized приводит к дедлоку виртуальных потоков
Рис. 3. Как pinning на synchronized приводит к дедлоку виртуальных потоков

Главная мысль из этой схемы: проблема была не в виртуальных потоках как идее, а в том, что библиотеки, написанные годами раньше под платформенные потоки, использовали внутри synchronized для потокобезопасности. HikariCP, Caffeine, Apache HttpClient, MySQL Connector/J — всем пришлось обновляться. Netflix тогда временно откатил агрессивное внедрение виртуальных потоков.

И вот тут вступает Java 25. JEP 491 (приехал в Java 24 и унаследован 25 LTS) переписал внутренности synchronized так, что на нём виртуальные потоки больше не закрепляются — это устранило главную причину pinning (про оставшиеся случаи скажу в разделе про ограничения). Тот же код, который страдал от pinning на synchronized на Java 21, на Java 25 в этой части работает уже без правок.

Авторы JEP прямо говорят: перестаньте менять synchronized на ReentrantLock ради обхода — обход больше не нужен. Плюс дампы виртуальных потоков теперь показывают владельца блокировки, так что диагностика подобных ситуаций перестала быть археологией по heap-дампу.

Могу себе представить, как команда, которая «просто включила spring.threads.virtual.enabled=true» на Java 21 и обожглась, теперь смотрит на Java 25 с осторожностью. Но именно этот фикс и есть один из самых весомых поводов довести миграцию до 25, а не остановиться на полпути.

Контрольная точка: под нагрузочным прогоном latency стабильна и нет роста сокетов в состоянии CLOSE_WAIT, а в дампе потоков (jcmd <pid> Thread.dump_to_file) не копятся «пустые» виртуальные потоки.

Шаг 6. Раскатывать через канарейку — как это делают зрелые команды

Несколько практик, которые я подсмотрел у сильных команд и проверил на себе.

Раскатывают поэтапно, через канарейку. Сначала стабилизируются на совместимых настройках, потом сходятся к нативным паттернам Boot 4 — а не выкатывают всё разом. Виртуальные потоки включают отдельным релизом, а не в одном коммите с миграцией, чтобы эффект был изолирован. Прогоняют OpenRewrite, но обязательно ревьюят дифф руками.

Явно пиннят версии в BOM — особенно это касается быстро меняющихся библиотек вроде Spring AI, где между минорами реально бывают breaking changes, и читают changelog на каждом апгрейде. И сразу включают нормальную наблюдаемость: Micrometer 2 и Actuator 4 в Boot 4 унифицируют метрики, логи и трейсы — на инцидентах после миграции это окупается в первый же день.

Личная позиция: я не верю в миграцию «за выходные на проде». Я верю в миграцию, где каждый шаг закрывает один риск и проверяется отдельно. Скучно? Да. Зато без ночных звонков.

Критерий готовности: канарейка держит заметную долю прод-трафика несколько часов без деградации ключевых метрик — latency, error rate, GC; только после этого — полный раскат.

Где этот маршрут не сработает

Честно о границах. Если вы сидите на Undertow — переезд будет болезненнее всех, придётся менять сервер, это не косметика. Если у вас тяжёлая кастомная логика на WebFlux или глубокие интеграции с внутренними пакетами Spring, которые в 4.0 переехали, — закладывайте время на ручной разбор, OpenRewrite тут не спасёт.

Если вы тянете много зависимостей, которыми Boot не управляет (тот же Spring Cloud), вы будете ждать их совместимых релизов — и это нормальная причина не торопиться. И главное про виртуальные потоки: я выше обещал вернуться к оставшимся случаям pinning — на нативном коде через JNI и Foreign Function API в Java 25 закрепление всё ещё возможно. Для большинства серверных сервисов нативный код не на пути запроса, но если он у вас есть — проверяйте отдельно.

Чек-лист перед раскатом

Короткий список, по которому я прохожусь сам перед тем, как трогать прод:

  1. Промежуточная остановка на 3.5.x пройдена, deprecation-варнингов ноль.

  2. OpenRewrite прогнан, дифф отревьюен руками.

  3. Переход на Boot 4.0.x сделан на старой Java, регрессия зелёная: Jackson 3, Security 7 (CSRF), JSpecify, тесты — проверены по таблице.

  4. JDK 25 поднят отдельным шагом: CI, базовый образ, локальные окружения.

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

  6. Раскат через канарейку, есть план отката.

Что дальше

Миграция на Spring Boot 4 и Java 25 — это не гонка за версиями. Это способ удержать платформу в актуальном, поддерживаемом и безопасном состоянии, а заодно забрать вещи, которые реально упрощают жизнь: рабочие виртуальные потоки, явный null-safety, лёгкие стартеры. Делается это предсказуемо — если идти по маршруту и менять по одному вектору за раз, а не прыгать через ступени.

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

  • 22 июня, 20:00. «Контейнеризация Java-приложений с Docker». Записаться

  • 29 июня, 20:00. «Как работает @Transactional в Spring: границы транзакций и типовые ошибки». Записаться

  • 23 июля, 20:00. «Основы многопоточности в Java». Записаться

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

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

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