Большая распаковка Java 26. Что этот релиз значит для нас всех?

от автора

Всего в релиз вошло 10 JEP-ов. Несколько я объединил в один блок, одним намеренно приберёг, чтобы рассказать о нём чуть позже. Будет немного практики — прямо в статье посмотрим, как перевести существующий проект на Java 26 и с чем придётся столкнуться. Статья также доступна в формате видео.

Как устроен путь фичи в JDK

Прежде чем начать, небольшой экскурс для тех, кто не следит за структурой релизов внимательно: сначала фича появляется в инкубаторе, потом переходит в превью, потом может войти в финальный состав JDK. Инкубатор и превью включаются отдельными флагами — это сделано специально, чтобы собирать фидбэк, не давая гарантий стабильности API. В продакшн фичи из инкубатора и превью тащить не стоит: API может поменяться, и весь написанный под него код устареет. Думаю, так будет понятнее, что значит «N-е превью» или «N-й инкубатор» с практической точки зрения по ходу чтения статьи.


Два способа переехать на Java 26

Прежде чем начать рассказывать про новые фичи, хочу ответить на самый главный вопрос: а как собственно получить эти фичи в своём проекте? На самом деле, нововведения можно поделить на два типа: те, которые мы используем напрямую (например, изменения в синтаксисе), и те, которые используем косвенно (например, улучшение сборщика мусора). Так вот, чтобы получить вторые — в случае с прекрасной экосистемой Java можно вообще практически ничего не делать.

Вариант 1: просто меняем рантайм

Код собирается под Java 24, запускается на Java 26. Синтаксис и зависимости не трогаем. Такой подход даёт бесплатный прирост от улучшений GC и других рантайм-оптимизаций без единого изменения в коде.

Пересобираем, запускаем, подключаемся к контейнеру, выполняем java -version — видим Java 26. Переезд занял буквально 2 строчки в Dockerfile.

Пытливый читатель может задаться вопросом, а чего это у меня такой навороченный Dockerfile? К сожалению, кратко ответить на этот вопрос я не смогу. Рекомендую прочитать статью от ребят из Spring АйО: «Как должен выглядеть правильный Docker Image для Spring Boot приложения?«.

Вариант 2: полноценный переезд — меняем и рантайм, и language level

Для этого потребуется обновить зависимости и сам Gradle/Maven файл сборки. На практике переезд простенького проекта у меня прошёл практически бесшовно — приложение стартует, в логах видна Java 26, варнингов по Spring или зависимых библиотек нет.


Structured Concurrency — шестое превью

JEP 525. Первый инкубатор появился в Java 19, первое превью — в Java 21. Шесть превью и два инкубатора позади. Прошло практически 4 года с того момента, как мы увидели её в изначальном исполнении.

Суть проблемы: классические инструменты параллелизма — ExecutorService, Future, CompletableFuture — не знают ничего о связях между задачами. Если ты запустил три подзадачи для обработки одного запроса, они выполняются в разных потоках без общего «родителя» и без удобного способа координации.

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

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

Самый простейший случай, без параллелизма

Самый простейший случай, без параллелизма
  1. Вариант с ExecutorService + Future. .get() вызывается последовательно: сначала ждём профиль, потом настройки, потом историю. Если настройки упали через секунду, а профиль отрабатывает 10 секунд — об ошибке узнаём только через 10 секунд. При InterruptedException оставшиеся задачи не отменяются.

  2. Вариант с ручной отменой. Можно добавить cancel() в catch — тогда при падении одной задачи явно отменяем остальные. Но узнаём об ошибке по-прежнему в самый последний момент, когда .get() доходит до упавшей задачи. Бойлерплейт растёт, проблема с поздней диагностикой провала никуда не делась.

  3. Вариант с CompletableFuture.allOf. cancel(true) отменяет CompletableFuture-обёртку, но не прерывает поток, который выполняет задачу — он продолжит работу до конца. Исключение приходит завёрнутым в CompletionException → ExecutionException. Вручную отменять задачи всё равно нужно.

  4. Structured Concurrency. Наконец, вишенка на торте. Прошу любить и жаловать приятный, лаконичный и удобный API для решения нашей задачи:

Если любая задача упала — остальные отменяются автоматически. Поток-владелец всегда переживает все дочерние. Стектрейсы отражают реальную иерархию вызовов. Принцип тот же, что у try-with-resources: время жизни задач привязано к лексическому блоку.

На самом деле, мы уже могли написать нечто похожее и в версии Java 25. А вот что изменилось именно в Java 26:

  1. Скоуп теперь создаётся через статический StructuredTaskScope.open() вместо new.

  2. Метод join() возвращает List вместо Stream — результаты материализованы сразу, не нужно думать о ленивых вычислениях после закрытия скоупа.

  3. Добавился joinUntil(deadline) — если задачи не успели к дедлайну, скоуп их отменяет.

Изменений с прошлого превью немного, API явно стабилизируется. Финализация, скорее всего, произойдёт довольно скоро. Но фича по прежнему в превью, поэтому тащить в продакшен я бы вам не советовал.


Final-поля: конец долгой истории

JEP 502. Этот JEP важен для всех, кто пишет на Spring, Hibernate — не потому что мы сами меняем final-поля через рефлексию, а потому что именно так работают большинство популярных фреймворков под капотом.

Как так получилось?

Когда появилась сериализация/десериализация, встала задача: как восстановить объект из байтов, у которого есть final поля? Вместо того чтобы сделать узкий механизм именно для этого случая, в Java 5 расширили общий Field.set() так, чтобы он мог писать в final-поля вообще для всех. Это было проще в реализации, но открыло лазейку всем желающим.

Ей воспользовались все: Jackson, Hibernate, Spring, библиотеки мокирования и тестирования. Технически это работало. Семантически — нарушало контракт final. А у JIT не было и по прежнему нет возможности доверять final-полям и делать на их основе оптимизации, потому что кто угодно теоретически может такое поле изменить через рефлексию.

Двадцать лет спустя это начинают исправлять.

Что изменилось в Java 26?

JVM начинает выдавать предупреждения, когда код меняет final-поле через рефлексию после завершения работы конструктора:

WARNING: Final field com.example.MyClass.value mutated after construction

Пока только предупреждения. Используя специальный флаг эти предупреждения сейчас можно заглушить. Следующие шаги по плану: невозможность заглушить предупреждения, исключения, которые можно заглушить и, наконец, полный запрет подобного изменения значения final-поля.

Правильное решение — использовать sun.reflect.ReflectionFactory.newConstructorForSerialization(). Этот API позволяет создать объект и установить final-поля в момент его инициализации, что JVM считает допустимым. Именно так сейчас работают Jackson 2.x и Hibernate, поэтому на современных проектах варнингов вы уже скорее всего и не встретите.

// Старый способ — то, что будет запрещеноField field = MyClass.class.getDeclaredField("value");field.setAccessible(true);field.set(instance, 42); // WARNING в Java 26, исключение позже// Новый способ через ReflectionFactoryReflectionFactory rf = ReflectionFactory.getReflectionFactory();Constructor<?> objCtor = Object.class.getDeclaredConstructor();Constructor<?> serCtor = rf.newConstructorForSerialization(MyClass.class, objCtor);serCtor.setAccessible(true);MyClass instance = (MyClass) serCtor.newInstance(); // final-поля устанавливаются здесь

Что это означает на практике, для нас, простых работяг?

  1. Spring 6.x и Hibernate давно перешли на новый подход и внутренние механизмы JDK. На современных проектах предупреждений не будет.

  2. Spring 5.x и старый Jackson — при запуске на Java 26 в логах появятся варнинги. Приложение не упадёт, но это сигнал к обновлению.

Я проверял на своём проекте с телеграм-ботом на Spring Boot 3.5. Никаких ворнингов. А вот Connekt (HTTP-клиент в Amplicode/OpenIDE) пока не обновился, и во время запросов через него в логах видны варнинги — что-то связанное с cookie storage использует старый подход.


G1 и ZGC: бесплатный прирост производительности

  1. JEP 522 — улучшение пропускной способности G1. Не про latency, а именно про throughput — количество полезной работы в единицу времени.

  2. JEP 519 — ZGC получил поддержку concurrent object pinning. Раньше при обращении к JNI отдельные объекты «пинились» и не двигались во время GC, что вызывало stop-the-world паузы. Теперь это происходит конкурентно. G1 — пока дефолтный сборщик, но всё движется к тому, что ZGC со временем займёт его место.

По результатам прошлогоднего опроса среди Java-разработчиков, лишь единицы занимаются тонкой настройкой GC. Большинству это не нужно — и в этом смысл: просто поменяй рантайм в Dockerfile, как показано выше, и получи несколько процентов прироста без единой строчки кода.

Кстати, чтобы мои заявления про «большинство» были менее голословными, пройди свежий опрос от 2026 года. В нём вопросы не только про Java, но и про AI-агентов, Spring, деплоймент, и так далее. Про всё, с чем Java-разработчикам приходится сталкиваться хотя-бы время от времени.

Рекомендую пройти, чтобы понять насколько текущий стек твоей команды или твой лично вообще релевантен для РФ-рынка. Ну и плюс можно выиграть бесплатные билеты на конференции, тоже приятно 🙂


Примитивы в паттернах

JEP 530. Четвертое превью.

До Java 26 паттерны в switch и instanceof работали только с reference-типами. Integer — ок, int — нет. Приходилось писать воркэраунды с явным приведением типа. Теперь:

switch (value) {    case int i when i > 100 -> "big";    case int i when i > 0   -> "positive";    default                  -> "other";}

И в instanceof:

if (value instanceof int i && i > 0) {    // безопасное использование i}

Почему так не сделали с самого начала? Ну, это на самом деле не так уж и тривиально: reference-типы наследуются от Object, у них есть иерархия, по которой компилятор строит паттерны. У примитивов её нет. При этом между примитивами существуют неявные конверсии: int в doubleбезопасно, double в short — нет, теряются данные. JEP вводит понятие safe conversions: компилятор знает, какие переходы разрешены без потери данных, и запрещает небезопасные статически.

Пока превью — в продакшн не тащим.


HTTP/3 в стандартном HttpClient

JEP 517. Финализированная фича!

HTTP/3 работает поверх QUIC (UDP) вместо TCP. Убирает проблему head-of-line blocking, лучше работает на нестабильных соединениях, быстрее устанавливает соединение.

По умолчанию HttpClient всё ещё использует HTTP/2. HTTP/3 нужно включать явно:

HttpClient client = HttpClient.newBuilder()    .version(HttpClient.Version.HTTP_3)    .build();

Если сервер не поддерживает HTTP/3 — клиент автоматически откатывается до HTTP/2. Поэтому можно указывать HTTP_3 без страха: как только сервер дорастёт до поддержки, оно заработает само. Чтобы видеть в логах, какая версия протокола реально использовалась, нужен VM-флаг -Djdk.internal.httpclient.debug=true.

Как подключить HTTP/3 в Spring

В Spring Boot начиная с версии 6.1 RestClient и WebClient используют стандартный Java HttpClient под капотом. Это значит, можно переопределить factory-бин и получить HTTP/3 во всём приложении без низкоуровневого кода:

@Configurationpublic class Http3Config {    @Bean    JdkClientHttpRequestFactory jdkClientHttpRequestFactory() {        HttpClient client = HttpClient.newBuilder()                .version(HttpClient.Version.HTTP_3)                .build();        return new JdkClientHttpRequestFactory(client);    }    @Bean    RestClient restClient(JdkClientHttpRequestFactory jdkClientHttpRequestFactory) {        return RestClient.builder()                .requestFactory(jdkClientHttpRequestFactory)                .build();    }}

После этого везде, где заинжектирован RestClient, запросы пойдут по HTTP/3. Я проверял с Connekt: запрос к нашему приложению идёт по HTTP/1.1 (Connekt к нам так подключается), а запрос из приложения по RestClient к внешнему эндпоинту — по HTTP/3. В логах это видно явно при включённом debug-флаге -Djdk.httpclient.HttpClient.log=requests.


Vector API и что ещё не вошло в обзор

  1. Vector API (JEP 529) — одиннадцатый инкубатор. Не опечатка. Инкубатор → превью → финал — и вот одиннадцатый инкубатор подряд. Технология интересна для LLM-вычислений, но временной горизонт финализации кажется слишком далеким лично для меня, поэтому не стал в это лезть. Без комментариев.

  2. Lazy Constants (JEP 526) — интересная фича, но как то для неё не нашлось места в этой статье, разберу отдельно в телеграм-канале.

  3. Удаление апплетов (JEP 523) — ну, что поделать, ушла эпоха.

Уже сейчас OpenIDE позволяет разрабатывать проекты на Java, Spring, Python, Go, JavaScript и TypeScript! А поддержка Docker и 300+ плагинов доступны абсолютно бесплатно в маркетплейсе. Пробуйте российскую IDE в деле и подписывайтесь на нас в Telegram или Max, чтобы не пропустить свежие обновления и полезные материалы.

ссылка на оригинал статьи https://habr.com/ru/articles/1022864/