Java 25 уже тут. Огляд новинок і поради з міграції (Жовтень 2025)
Всім привіт. Я Сергій Моренець, розробник, тренер, викладач, спікер і технічний письменник, хочу поділитися з вами своїм досвідом міграції проєктів з 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 зазнала мінімальних змін:
- Клас IO перенесли з java.io до java.lang, щоб його не потрібно було явно імпортувати.
- Методи цього класу тепер не імпортуються неявно, тому тепер назву класу потрібно вказувати.
- Сам клас використовує функціональність 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, тому за значенням, що повертається, можна судити про те, чи був вміст ініціалізований або ні.
Такий підхід має два мінуси:
- Поділяється оголошення та ініціалізація поля.
- Можна забути про 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, щоб вона стала високорівневою. Поки що вона має два обмеження:
- Поля типу StableValue повинні бути непублічними та бажано final (як і всі поля-монітори).
- Синхронізація на полях типу StableValue може призвести до deadlock.
Наскільки швидко працює цей API порівняно з тим, що ми захочемо написати власну реалізацію? Перевіримо це на benchmarks.
Benchmarks
Для того, щоб оцінити швидкодію алгоритму, що використовується в StableValue, візьмемо для порівняння ще три потокобезпечні реалізації:
- З Apache Commons
- З Google Guava
- Засновану на 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), коли виконуються дві умови:
- Є module-info.java.
- Сам застосунок оголошено модульним.
Наш застосунок модулі не використовує, швидше за все, в цьому і причина помилки.
Висновки
JDK 25 — це новий довгостроковий реліз Java, який буде основним для використання як мінімум протягом декількох наступних років, тому Java-індустрія повинна планувати перехід на нього і повноцінне використання його фіч. Можна відзначити, що через 2 тижні після релізу всі основні бібліотеки випустили оновлення, і будь-яких перешкод для міграції немає. Також немає проблем ні при запуску застосунків на JDK 25, ні використання фіч, оголошених стабільними.
Серед нових фіч можна відзначити Stable Values/Lazy Constants, які вирішують завдання on-demand ініціалізації immutable даних.
12 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів