Java 25 уже тут. Огляд новинок і поради з міграції (Жовтень 2025)

💡 Усі статті, обговорення, новини про Java — в одному місці. Приєднуйтесь до Java спільноти!

Всім привіт. Я Сергій Моренець, розробник, тренер, викладач, спікер і технічний письменник, хочу поділитися з вами своїм досвідом міграції проєктів з JDK 24 на JDK 25, яка вийшла два тижні тому. Це довгостроковий реліз, тому для кожного Java-розробника важливо знати його особливості. Я вже писав про нові фічі в JDK 24, тож в цій статті пропущу ті фічі, які не змінилися за ці пів року. Крім того, я наведу результати benchmarks для деяких нових фіч.

Сподіваюся, що ця стаття буде корисна для всіх, хто хоче більше дізнатися про нові фічі в Java 25, розібратися в тому, як провести міграцію застосунків на JDK 25 і які тут можуть бути складності.

Що нового в Java 25

16 вересня вийшов довгоочікуваний довгостроковий реліз Java — JDK 25, який буде основним production-ready версією Java на наступні кілька років. Таким чином, Java-розробникам час поступово вивчати і тестувати роботу всіх стабільних фіч, щоб бути готовими до їх використання, коли їхні проєкти перейдуть на цю версію.

Що ж увійшло до JDK 25? Насамперед це фічі, які довгий час доопрацьовувалися, тестувалися і ось нарешті оголошені стабільними:

  • Scoped Values
  • Декларації імпорту модулів
  • Flexible Constructor Bodies
  • Compact Object Headers
  • Generational Shenandoah
  • Compact Source Files and Instance Main Methods

Про ці фічі я вже детально писав у попередніх статтях, тому тут зупинюся тільки на останній, яка в JDK 25 зазнала мінімальних змін:

  1. Клас IO перенесли з java.io до java.lang, щоб його не потрібно було явно імпортувати.
  2. Методи цього класу тепер не імпортуються неявно, тому тепер назву класу потрібно вказувати.
  3. Сам клас використовує функціональність System.out, а чи не Console.

Тому в JDK 25 типовий код із використанням класу IO виглядає так:

void main() {
   IO.println("Hello");
}

Незважаючи на те, що JDK 25 є довгостроковим релізом, багато фіч досі перебувають у ній у статусі експериментальних/preview:

  • Structured Concurrency
  • Primitive Types in Patterns, instanceof, and switch
  • Vector API

Це досить цікавий факт, враховуючи, що вони кочують з версії до версії без змін, що говорить про їхнє позитивне сприйняття розробниками. Ну і в JDK 25 увійшли дві нові фічі (обидві в режимі preview):

  • JFR CPU-Time Profiling
  • Stable Values

У JFR (JDK Flight Recorder) досить довга історія, яка бере свій початок тоді, коли розробкою Java займалася компанія Sun. Зараз це компонент, який вбудований в JVM, і якщо його включити при запуску, то він збирає інформацію про JVM і запущений застосунок і зберігає її в спеціальному dump-файлі. Нова фіча додає дані про CPU profiling, причому вона працює лише на Linux-версіях JDK і має експериментальний характер.

Stable Values ​​- це цікавіша фіча, яка розширює JDK API і можливості Java загалом.

Stable Values

Вже давно відомо, що краще використовувати immutable-об’єкти, так як ви не можете змінити їх стан. А значить вони більш передбачувані в роботі, для них потрібно писати менше тестів, простіше використовувати функціональне програмування і так далі.

Для того, щоб декларувати таке поле, зазвичай використовують ключове слово final:

public class Runner {
     private final Cache cache = new Cache();

Але такий підхід для eager ініціалізації даних призводить до того, що сповільнюється завантаження застосунку, і чим він більший, то більше часу займає така процедура. Краще було б ініціалізувати об’єкти за першим запитом (on-demand). Але тоді доведеться відмовитися від слова final, а це означає, що поле cache може бути випадково змінено.

З такою проблемою зіткнулися інженери Java, коли вони писали клас String та метод hashCode(). З одного боку, клас Sting — immutable, а значить, і хеш, що генерується — immutable, тому hash code можна обчислювати прямо в конструкторі. Але який сенс це робити, якщо ми заздалегідь не знаємо, буде цей хеш використовуватися чи ні. Тому був застосований спеціальний підхід, коли поле hash не було оголошено як final:

@Stable
private int hash;

Але додана цікава анотація @Stable. Ця анотація говорить про те, що поле hash може змінюватись лише один раз. І якщо подивитися на метод hashCode, то це дійсно так і відбувається:

public int hashCode() {
     int h = hash;
     if (h == 0 && !hashIsZero) {
          h = isLatin1() ? StringLatin1.hashCode(value)
               : StringUTF16.hashCode(value);
          if (h == 0) {
               hashIsZero = true;
          } else {
               hash = h;
          }
     }
     return h;
}

Але ця анотація не просто створена для документування функціональності, вона використовується JVM, яка може відстежувати зміну даного поля і потім розглядати отримане значення як константу, так як вона буде впевнена в тому, що більше змін (записів) у нього не буде.

І все б добре, але анотація @Stable є внутрішньою в JDK і не створена для публічного використання. Крім того, спробуйте, глянувши на цей метод, визначити, чи є він потоково безпечним чи ні? Так, він thread-safe, але тільки тому, що у поля hash тип int, а JVM гарантує, що операції читання/записи для таких полів є атомарними. Але в загальному випадку при спробі створити свої on-demand (або lazy) поля вам доведеться зіткнутися із завданнями синхронізації. Тому назріла необхідність у наявності API, яке дозволяло б простіше та безпечніше вирішувати завдання реалізації deferred immutability.

Першим фундаментальним типом у новому API є StableValue — це sealed інтерфейс, який має лише одну реалізацію — StableValueImpl:

@PreviewFeature(feature = PreviewFeature.Feature.STABLE_VALUES)
public sealed interface StableValue<T>
permits StableValueImpl {

Сам StableValueImpl є контейнером для вашого значення, використовує generics (як і Optional), і що цікаво, теж застосовує анотацію @Stable:

@Stable
private Object contents;

Для роботи з новими API є два підходи. Перший пов’язаний з тим, що ми змінюємо тип final полів у наших компонентах на StableValue та ініціалізуємо їх, використовуючи метод of():

private final StableValue<Cache> cache = StableValue.of();

Далі потрібно додати метод, який ініціалізуватиме значення поля при першому зверненні:

private Cache getCache() {
    return cache.orElseSet(Cache::new);
}

І будь-яке звернення до кешу буде саме через getCache():

public void init() {
      getCache().clear();
}

При цьому під капотом метод orElseSet використовує синхронізацію, тому є потокобезпечним. Що буде, якщо спробувати двічі ініціалізувати поле?

boolean success = cache.trySet(null);

Помилки не буде, але метод trySet поверне false, тому за значенням, що повертається, можна судити про те, чи був вміст ініціалізований або ні.

Такий підхід має два мінуси:

  1. Поділяється оголошення та ініціалізація поля.
  2. Можна забути про getCache() або не знати про нього та звернутися безпосередньо через cache:
var value = cache.orElse(null);

Тому надійніше використовувати другий варіант із Supplier:

private final Supplier<Cache> cache = StableValue.supplier(Cache::new);

Тоді відпадає потреба в окремому методі getCache():

cache.get().clear();

Що цікаво, у класі String метод hashCode() залишився без змін, хоча там так і проситься застосувати StableValue. Якби ми зробили це за розробників JDK, то нам би довелося переписати пов’язаний код як:

private final Supplier<Integer> hash = StableValue.supplier(this::generateHashCode);

private int generateHashCode() {
     return isLatin1() ? StringLatin1.hashCode(value)
          : StringUTF16.hashCode(value);
}

public int hashCode() {
       return hash.get();
}

Цю фічу збираються оголосити стабільною (хоча вона й так стабільна, судячи з назви) вже в JDK 26. Але згідно з публічною інформацією, її перейменують на Lazy Constants, спростять API, щоб вона стала високорівневою. Поки що вона має два обмеження:

  1. Поля типу StableValue повинні бути непублічними та бажано final (як і всі поля-монітори).
  2. Синхронізація на полях типу StableValue може призвести до deadlock.

Наскільки швидко працює цей API порівняно з тим, що ми захочемо написати власну реалізацію? Перевіримо це на benchmarks.

Benchmarks

Для того, щоб оцінити швидкодію алгоритму, що використовується в StableValue, візьмемо для порівняння ще три потокобезпечні реалізації:

  1. З Apache Commons
  2. З Google Guava
  3. Засновану на AtomicReference

Для емуляції «важковагової» операції напишемо метод createValue:

public abstract class BaseInitializer {
     protected String createValue() {
         try {
            Thread.sleep(10);
        } catch (InterruptedException e) {
        }
        return «ok»;
    }
}

Усі реалізації будуть засновані на інтерфейсі Initializer:

public interface Initializer<T> {
        T get() throws Exception;
}

Реалізація від Apache Commons Lang:

public class ApacheCommonsInitializer extends BaseInitializer implements Initializer<String> {
    private final ConcurrentInitializer<String> lazyInitializer = new LazyInitializer<>() {
        @Override
        protected String initialize() throws ConcurrentException {
            return createValue();
        }
    };

    @Override
    public String get() throws Exception {
        return lazyInitializer.get();
    }
}

Реалізація від Google Guava:

public class GoogleGuavaInitializer extends BaseInitializer implements Initializer<String> {
        private Supplier<String> supplier = Suppliers.memoize(this::createValue);

        @Override
        public String get() throws Exception {
            return supplier.get();
        }
}

Реалізація, заснована на AtomicReference:

public class AtomicInitializer extends BaseInitializer implements Initializer<String> {
        private AtomicReference<String> instance = new AtomicReference<>(null);

        @Override
        public String get() throws Exception {
            String value = instance.get();
           if (value == null) {
                value = createValue();
                if (instance.compareAndSet(null, value))
                    return value;
                else
                    return instance.get();
                } else {
            return value;
        }
    }
}

І нарешті, реалізація на основі StableValue:

public class StableValueInitializer extends BaseInitializer implements Initializer<String> {
        private final Supplier<String> stableValue = StableValue.supplier(this::createValue);

        @Override
        public String get() {
            return stableValue.get();
        }
}

Для тестування було обрано наступну конфігурацію:

  • JMH 1.37
  • JDK 25
  • Intel Core i9, 8 cores
  • 32 GB
  • • 10 ітерацій обчислення, 10 ітерацій прогріву (warm-up)

Сам benchmark:

@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@Warmup(iterations = 10)
@Measurement(iterations = 10)
public class Java25Benchmark {
        private final Map<String, Initializer<String>> initializers;

        public Java25Benchmark() {
            initializers = Map.of("apache", new ApacheCommonsInitializer(), "atomic", new AtomicInitializer(), "guava",
                new GoogleGuavaInitializer(), "java25", new StableValueInitializer());
        }

        @Param({ "apache", "guava", "atomic", "java25" })
        private String type;

        @Benchmark
        public String getValue() throws Exception {
            return initializers.get(type).get();
        }

Результати тестів:

Benchmark (type) Mode Cnt Score Error Units
Java25Benchmark.getValue apache avgt 5 10.068 ± 0.048 ns/op
Java25Benchmark.getValue guava avgt 5 11.487 ± 0.059 ns/op
Java25Benchmark.getValue atomic avgt 5 9.648 ± 0.054 ns/op
Java25Benchmark.getValue java25 avgt 5 10.592 ± 0.065 ns/op

Як видно з результатів, StableValue показав не найкращий результат, але можливо, реалізація цього API покращиться в JDK 26.

Переходимо на JDK 25

Спробуємо перейти на JDK 25 та оцінити його можливості. Перша спроба міграції проводилася за 8 днів після релізу. Її мета — перевірити, наскільки Java-індустрія готова до виходу нової версії Java.

Почнемо з того, що переведемо всі Dockerfile на JDK 25. На щастя, Gradle вже підтримує нову версію, а 26 вересня з’явилися і офіційні Dockerfile з її підтримкою, але станом на 28 вересня самих images ще немає на Docker Hub.

Тому спробуємо зібрати поки що один із Maven проєктів, використовуючи JDK 25. І одразу отримуємо помилку:

common/infra/event/BaseEvent.java: An unhandled exception was thrown by the Error Prone static analysis plugin.
[ERROR]  Please report this at github.com/...​le/error-prone/issues/new and include the following:
[ERROR]
[ERROR]  error-prone version: 2.37.0
[ERROR]  BugPattern: (see stack trace)
[ERROR]  Stack Trace:
[ERROR]  java.lang.AbstractMethodError: Receiver class lombok.javac.Javac$JavadocOps_8$1 does not define or inherit an implementation of the resolved method ’abstract com.sun.tools.javac.parser.Tokens$Comment stripIndent()’ of interface com.sun.tools.javac.parser.Tok
ens$Comment.
[ERROR]   at jdk.compiler/com.sun.tools.javac.parser.DocCommentParser.<init>(DocCommentParser.java:168)

Помилка компіляції в плагіні Google ErrorProne, але витоки її у неправильній роботі Lombok. Ця помилка добре відома, спробуємо виправити її, перейшовши на останню версію Lombok:

<lombok.version>1.18.42</lombok.version>

І самого плагіна:

<path>
        <groupId>com.google.errorprone</groupId>
        <artifactId>error_prone_core</artifactId>
        <version>2.42.0</version>
</path>

Продовжуємо збирання та отримуємо помилку, пов’язану з роботою Mockito та ByteBuddy:

Caused by: java.lang.IllegalArgumentException: Java 25 (69) is not supported by the current version of Byte Buddy which officially supports Java 24 (68) — update Byte Buddy or set net.bytebuddy.experimental as a VM property
at net.bytebuddy.utility.OpenedClassReader.of(OpenedClassReader.java:120)

Оновлюємо Mockito до останньої версії:

<mockito.version>5.20.0</mockito.version>

Тепер проєкт збирається успішно.

І лише 30 вересня Gradle Docker images були розміщені в Docker Hub, після чого ми можемо поміняти:

FROM gradle:8-jdk-21-and-24-alpine as gradle

На:

FROM gradle:jdk-25-alpine as gradle

І для Spring Boot проєктів змінити версію JRE з:

FROM eclipse-temurin:24-jre-alpine

На:

FROM eclipse-temurin:25-jre-alpine

Запускаємо збірку та отримуємо помилку:

* What went wrong:
Could not determine the dependencies of task ’:common:compileJava’.
> Failed to query the value of extension ’errorprone’ property ’enabled’.
> Failed to calculate the value of task ’:common:compileJava’ property ’javaCompiler’.
> Cannot find a Java installation on your machine (Linux 5.15.153.1-microsoft-standard-WSL2 amd64) matching: {languageVersion=21, vendor=any vendor, implementation=vendor-specific, nativeImageCapable=false}. Toolchain download repositories have not been configured.

Проблема в тому, що зараз у скрипті збірки вказана JDK 21, якої, звичайно, немає у Gradle Docker image:

java {
    toolchain {
        languageVersion.set(JavaLanguageVersion.of(21))
    }
}

Замінимо її на поточну (25):

toolchain {
    languageVersion.set(JavaLanguageVersion.of(25))
}

Тепер проєкт збирається та запускається успішно.

Використовуємо JDK 25

Поки що ми тільки запускали проєкт на JDK 25, але не використали жодних її фіч. Вкажемо сумісність із JDK 25:

java {
    sourceCompatibility = org.gradle.api.JavaVersion.VERSION_25
    targetCompatibility = org.gradle.api.JavaVersion.VERSION_25
}

Перебираємо застосунок, але при збиранні отримуємо помилку:

* What went wrong:
Execution failed for task ’:common:resolveMainClassName’.
> Unsupported class file major version 69

При запуску тестів помилка стає зрозумілішою (несумісність бібліотеки ASM з JDK 25):

Caused by: org.springframework.core.type.classreading.ClassFormatException: ASM ClassReader failed to parse class file — probably due to a new Java class file version that is not supported yet. Consider compiling with a lower ’-target’ or upgrade your framework version. Affected class: class path resource [GatewayHandlerMappingTest.class]
 at org.springframework.core.type.classreading.SimpleMetadataReader.getClassReader(SimpleMetadataReader.java:59)

Таким чином, нам потрібно перейти з Spring Boot 3.4.x (вона не підтримує JDK 25) на 3.5.x:

ext {
    springBootVersion = ’3.5.6′
}

З тих фіч JDK 25, які увійшли в нову версію, найпростіше використовувати дві:

  • Декларації імпорту модулів
  • Flexible Constructor Bodies

Почнемо із першої за списком. Наприклад, у нас є клас, де в тому числі такі імпорти:

import java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

Тепер ми можемо все це замінити одним рядком:

import module java.base;

Але якщо у нас є імпорти сторонніх бібліотек?

import com.google.common.collect.Lists;
import com.google.common.collect.Streams;

Тут потрібно перевірити, чи є у її jar-файлі файл module-info.class. І навіть якщо ні, то у файлі MANIFEST.MF може бути згенероване ім’я модуля:

Automatic-Module-Name: com.google.common

Спробуємо використати його:

import module com.google.common;

І отримуємо помилку під час компіляції:

imported module not found: com.google.common

Однак є бібліотеки, які вже явно підтримують модулі, наприклад Lombok, де вже є module-info.java:

module lombok {
    requires java.base;
    requires java.compiler;
    requires java.instrument;
    requires jdk.unsupported;
    exports lombok;
}

Спробуємо замість:

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

Написати:

import module lombok;

І знову отримуємо схожу помилку:

imported module not found: lombok

Детальне дослідження показує, що системи збирання включають залежність через modulepath (а не через classpath), коли виконуються дві умови:

  1. Є module-info.java.
  2. Сам застосунок оголошено модульним.

Наш застосунок модулі не використовує, швидше за все, в цьому і причина помилки.

Висновки

JDK 25 — це новий довгостроковий реліз Java, який буде основним для використання як мінімум протягом декількох наступних років, тому Java-індустрія повинна планувати перехід на нього і повноцінне використання його фіч. Можна відзначити, що через 2 тижні після релізу всі основні бібліотеки випустили оновлення, і будь-яких перешкод для міграції немає. Також немає проблем ні при запуску застосунків на JDK 25, ні використання фіч, оголошених стабільними.

Серед нових фіч можна відзначити Stable Values/Lazy Constants, які вирішують завдання on-demand ініціалізації immutable даних.

👍ПодобаєтьсяСподобалось9
До обраногоВ обраному1
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Дякую за огляд. Як завжди, коротко, лаконічно, по суті.
Хоча суті як такої і не є. Якась гонка за номерами версій з ускладненням синтаксису і впровадженням не дуже зрозумілих фіч.
Особисто перестав кодити на джаві вже як 4 роки, і бачу, що не дуже хочу туди вертатися, особливо, якщо «потрібно» буде використовувати ото все.

void main() {
   IO.println("Hello");
}

нарешті!

боже, куди котиться світ ©

Нарешті справжній Java Script! Ще ж і з shebang’ом можна )
#!/usr/bin/env -S java --source 25

Я с джавой уже лет 15 и меня всегда умиляла эта беготня за версиями)) Тут дай бог проект с 17 версией получить, а они 25-ю разбирают. Кому эта версия нужна?)

Да особливо якщо старенький спрінг та на старенькому гредлі ))

Отож. У нас вже пару рокiв деви намагаються переïхати з 11 на 17, а тут вже 25.

Мені ) Мігрую у нас все на останні LTS, цього тижня вже все мігранув на 25

JDK 25 — це новий довгостроковий реліз Java, який буде основним для використання як мінімум протягом декількох наступних років

Ну вот смотрите. Джава появилась 30 лет назад, и в 1998 её профорсила Микрософт, то есть 27 лет назад. За это время вышло 25 версий, по году на каждую версию, если не учитывать, что пару лет как-то проконтовались. С чего бы вдруг им сейчас ломать традицию? При этом у вас при переходе на новую версию нужны правки в коде, насколько я понял. В то время как в Go тоже в аккурат вышла 25-я версия, но он стартанул в 2009, новые версии выходят стабильно каждые полгода, и за всё время никаких breaking changes там не было. Код ранних версий спокойно компилится на последней без каких-либо правок ни в одном из исходников.

схоже не зрозуміли що сказав автор

не зрозуміли

А шо ти хочеш від нього, він же на угрофінському пише.
Слабоуміє, сер.

А что бы вы посоветовали делать чтоб стать таким же умным как вы?

Підписатись на коментарі