Что нового в Java 25. Часть 2

Что нового в Java 25. Часть 2

Это вторая часть обзора JEP в Java 25 LTS. Если вы пропустили первую часть, см. Что нового в Java 25. Часть 1.

В этой части мы рассмотрим ещё шесть фичей, среди которых Примитивные типы в patterns, instanceof и switch, Vector API, Flexible Constructor Bodies и другие.

Содержание

Категории JEP-ов

Как использовать фичи в предварительной и экспериментальной версиях

Чтобы попробовать экспериментальные фичи или фичи в предварительной версии, нужно их явно включить. Вы можете это сделать как через командную строку, так и настроить в интегрированной среде разработки (IDE). Некоторые фичи можно включать на уровне javac, а не только при запуске java.

В командной строке включите фичу в предварительной версии одним из следующих способов:

  • Скомпилируйте программу с помощью javac --release 24 --enable-preview Main.java и запустите ее с помощью java --enable-preview Main.
  • При использовании source code launcher запустите программу с java --enable-preview Main.java.
  • При использовании jshell запустите его с помощью jshell --enable-preview.

JEP 507: Примитивные типы в patterns, instanceof и switch (третья предварительная версия)

Примитивные типы в patterns, instanceof и switch были представлены в JDK 23 (JEP 455) в превью, ушли на превью в JDK 24 (JEP 488) и остаются на превью в JDK 25 без изменений.

Ранее выражение-селектор в switch должно было соответствовать одному из следующих типов:

  • byte, short, int, char;
  • Их обёрткам Byte, Short, Integer, Character;
  • перечислением enum;
  • String (с версии JDK 7).

Теперь к этому списку добавились boolean, float, double и long (с соответствующими им обёртками). Константы в case должны быть того же типа, что и выражение-селектор.

Пример c паттерном примитивного типа:

switch (x.getStatus()) {
    case 0 -> "okay";
    case 1 -> "warning";
    case 2 -> "error";
    case int i -> "unknown status: " + i;
}

Раньше для обработки long приходилось писать if-конструкции. Теперь switch может обрабатывать и long:

long v = ...;
switch (v) {
    case 1L              -> ...;
    case 2L              -> ...;
    case 10_000_000_000L -> ...;
    case 20_000_000_000L -> ...;
    case long x          -> ... x ...;
}

Ранее в instanceof можно было использовать только ссылочные (reference) типы и записи (record patterns в Java 21+). Это ограничение делало проверку примитивных значений сложной и небезопасной.

Например, в Java 17 безопасное приведение int в byte требовало явного диапазонного контроля:

if (i >= Byte.MIN_VALUE && i <= Byte.MAX_VALUE) {  
    byte b = (byte) i;  
    // работа с b  
}

В то же время автоматическое преобразование int в float может быть потерей информации без предупреждений:

int getPopulation() { ... }
float pop = getPopulation();  // потенциально теряется точность

Теперь instanceof может проверять, можно ли безопасно привести один примитивный тип к другому без потери информации и привести их. Предыдущие два примера можно переписать так:

if (getPopulation() instanceof float pop) {
    // безопасное использование pop
}

if (i instanceof byte b) {
    // b уже безопасно приведённый к byte
}

Это объединяет удобство присваивания и безопасность приведения типов, а также устраняет необходимость ручных проверок диапазона.

Если instanceof применяется без шаблона (T t), то он теперь может проверять совместимость приведения типов без приведения:

if (i instanceof byte) {  // проверяем, помещается ли i в byte
    byte b = (byte) i;    // приведение остаётся явным
}

Эти улучшения делают instanceof и switch более удобными для работы с примитивными типами данных:

  • Упрощается проверка и приведение примитивных типов без потерь информации.
  • instanceof теперь поддерживает примитивные шаблоны и проверку совместимости типов.
  • switch становится более универсальным, поддерживая новые типы (boolean, long, float, double).

Теперь также можно использовать примитивные паттерны внутри record-паттернов, избегая небезопасных приведений типов.

if (json instanceof JsonObject(var map)
    && map.get("age") instanceof JsonNumber(int age)) {
    System.out.println("Возраст: " + age);
}

Этот JEP призван избавить нас от ручных проверок диапазонов и лишнего шаблонного кода.

JEP 508: Vector API (десятый инкубатор)

Vector API появилось в JDK 16 (JEP 388) в модуле jdk.incubator.vector и всё ещё остаётся в инкубационном периоде. В десятом инкубаторе будет несколько изменений:

  • VectorShuffle теперь поддерживает доступ к MemorySegment и из него.
  • Реализация теперь использует Foreign Function & Memory API (JEP 454) для вызова нативных математических библиотек вместо использования кастомного кода C++ внутри HotSpot JVM, что улучшает сопровождаемость.
  • Операции сложения, вычитания, деления, умножения, вычисления квадратного корня и fused multiply/add над значениями Float16 теперь автовекторизуются на поддерживающих их x64-процессорах.

Vector API останется в инкубаторе тех пор, пока необходимые возможности Project Valhalla не будут доступны в превью. Как только это произойдёт, Vector API перейдёт из инкубатора в статус превью.

JEP 513: Flexible Constructor Bodies

Statements before super() появились в предварительной версии в JDK 22 (JEP 447) в режиме превью, затем были переименованы в Flexible Constructor Bodies в JDK 23 (JEP 482) и остались в превью в JDK 24 (JEP 492). В JDK 25 предлагается эту фичу утвердить без особых изменений.

Теперь в теле конструктора можно писать выражения до явного вызова super(...) или this(...). Такие выражения не могут обращаться к ещё не созданному объекту, но могут:

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

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

Раньше Java требовала первой строкой конструктора вызывать super(...) или this(...). Так, проверка аргументов выполнялась после вызова super(...), что могло приводить к лишней работе:

class Person {

    ...
    int age;

    Person(..., int age) {
        if (age < 0)
            throw new IllegalArgumentException(...);
        ...
        this.age = age;
    }

}

class Employee extends Person {

    Employee(String name, int age) {
        super(name, age);        // потенциально ненужная работа
        if (age < 18 || age > 67)
            throw new IllegalArgumentException(...);
    }

}

Такой код сначала запускает конструктор Person, а потом может “откатить” всё исключением.

Обходное решение — выносить проверки в статический метод:

Employee(String name, int age) {
    super(name, verifyAge(age)); // обход
}

Теперь же мы можем написать так:

Employee(String name, int age) {
    if (age < 18 || age > 67) {
        throw new IllegalArgumentException("Недопустимый возраст");
    }
    super(name, age); // безопасный вызов после проверки
}

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

class Person {
    int age;
    Person(int age) {
        this.age = age;
        show(); // вызывает метод подкласса
    }
    void show() { System.out.println("Age: " + age); }
}

class Employee extends Person {
    String officeID;
    Employee(int age, String officeID) {
        super(age);
        this.officeID = officeID;
    }
    @Override void show() {
        System.out.println("Age: " + age);
        System.out.println("Office: " + officeID); // null
    }
}

Вызов new Employee(42, "Foo") напечатает следующее:

Age: 42
Office: null

А всё потому что поле officeID ещё не инициализировано к моменту вызова show().

Теперь тело конструктора делится на две фазы:

  1. Пролог — код до вызова конструктора (super(...) или this(...)).
  • Можно валидировать параметры.
  • Можно инициализировать поля.
  • Нельзя использовать this, вызывать методы экземпляра или читать значения полей. Допустимо присваивать значения полям, даже если они уже инициализированы при объявлении.
  1. Эпилог — код после вызова конструктора. Можно работать с this, вызывать методы, использовать поля.
class Employee extends Person {
    String officeID;

    Employee(String name, int age, String officeID) {
        if (age < 18 || age > 67) { // пролог
            throw new IllegalArgumentException("Недопустимый возраст");
        }
        this.officeID = officeID;   // можно инициализировать поле
        super(name, age);           // вызов суперкласса
        System.out.println("OK");   // эпилог
    }
}

Теперь new Employee("Alice", 42, "Foo") выведет OK и объект будет корректно инициализирован.

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

Это также подготовка к Value Classes (JEP 401) Project Valhalla.

JEP 505: Structured Concurrency (пятая предварительная версия)

Structured Concurrency впервые появился в JDK 19 (JEP 428) в инкубаторе. С JDK 21 (JEP 453) эта фича находится в предварительной версии. В JDK 25 Structured Concurrency всё ещё остаётся в предварительной версии, но претерпевает некоторые изменения в API:

  • StructuredTaskScope теперь открывается через статические фабричные методы, а не через публичные конструкторы.
  • Базовый фабричный метод без параметров (open()) покрывает наиболее частый сценарий: он создаёт StructuredTaskScope, который ждёт либо успешного завершения всех подзадач, либо ошибки хотя бы одной из них.
  • Для более гибких политик завершения можно использовать перегруженные фабричные методы с передачей кастомного Joiner. Это позволяет реализовать разные стратегии объединения результатов и обработки ошибок.

Выполнение подзадач происходит так:

  1. Подзадачи ответвляются в отдельные виртуальные потоки методом fork().
  2. Подзадачи воссоединяются методом join() в блоке кода главной задачи.

В примере ниже показана задача, которая параллельно запускает две подзадачи и дожидается результата их выполнения:

Response handle() throws ExecutionException, InterruptedException {
    try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
        Supplier<String>  user  = scope.fork(() -> findUser());
        Supplier<Integer> order = scope.fork(() -> fetchOrder());

        scope.join()            // Join both subtasks
             .throwIfFailed();  // ... and propagate errors

        // Here, both subtasks have succeeded, so compose their results
        return new Response(user.get(), order.get());
    }
}

У StructuredTaskScope есть несколько принципиальных отличий, которые делают код безопаснее:

  • Если одна из подзадач, findUser() или fetchOrder(), завершится с ошибкой, то другая подзадача автоматически прекращает выполнение, если ещё не завершилась. Это поведение контролируется политикой завершения, реализованной в ShutdownOnFailure, но есть и другие политики.
  • Если поток, выполняющий handle(), прерывается до или во время вызова join(), то обе подзадачи, findUser() и fetchOrder(), автоматически отменяются при выходе из блока.
  • Представленный код имеет чёткую структуру: создаются подзадачи, выполняется ожидание их завершения или отмены, после чего принимается решение — либо обработать результаты успешно завершённых задач, либо завершиться с ошибкой (при этом подзадачи уже завершены, и дополнительных действий по их очистке не требуется).
  • В дампе потоков чёткая иерархия задач: потоки, выполняющие findUser() и fetchOrder(), видны как дочерние для родительского потока.

Чтобы попробовать Structured Concurrency, явно включите её.

Structured Concurrency делает многопоточное программирование в Java более управляемым и безопасным. В отличие от традиционных пулов потоков, StructuredTaskScope гарантирует, что все дочерние задачи корректно завершатся, а ошибки не останутся незамеченными. Этот механизм особенно полезен для управления асинхронными операциями в современных многопоточных приложениях.

JEP 514: Ahead-of-Time Command-Line Ergonomics

AOT-кэши были представлены в JEP 483. Они ускоряют запуск приложений и будут становиться всё более полезными по мере того, как Project Leyden привнесёт новые AOT-оптимизации в HotSpot JVM.

В JDK 24 для создания AOT-кэша требовалось выполнить два шага:

  1. Запустить JVM в режиме record, чтобы во время тренировочного прогона приложения собрать конфигурацию AOT:
$ java -XX:AOTMode=record -XX:AOTConfiguration=app.aotconf \
       -cp app.jar com.example.App ...
  1. Запустить JVM в режиме create, чтобы на основе собранной конфигурации создать AOT-кэш:
$ java -XX:AOTMode=create -XX:AOTConfiguration=app.aotconf \
       -XX:AOTCache=app.aot

В дальнейшем приложение запускается уже с использованием готового кэша:

$ java -XX:AOTCache=app.aot -cp app.jar com.example.App ...

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

Однако в таком подходе есть два минуса:

  • приходится вызывать java дважды,
  • остаётся временный файл конфигурации (.aotconf), который не нужен в проде.

Было бы логично объединить эти шаги, по крайней мере для самых распространённых сценариев, сделав процесс одношаговым. Это удобно и для приложений вроде JRuby, которые сами управляют тренировочными прогоном под специфичные нагрузки.

В JDK 25 в java-лаунчер добавлен новый флаг -XX:AOTCacheOutput, который задаёт имя выходного файла для AOT-кэша.

При его использовании (без других AOT-опций) JVM сама делает два шага:

  1. Тренировочный прогон (AOTMode=record).
  2. Создание кэша (AOTMode=create).

Пример (один шаг вместо двух):

$ java -XX:AOTCacheOutput=app.aot -cp app.jar com.example.App ...

В этом случае JVM автоматически создаёт временный конфигурационный файл .aotconf и удаляет его по завершении.

Запуск в проде не меняется:

$ java -XX:AOTCache=app.aot -cp app.jar com.example.App ...

Для передачи специфичных JVM-опций только на этапе создания кэша, а не во время тренировочного прогона, введена новая переменная окружения JDK_AOT_VM_OPTIONS. Она работает по тому же принципу, что и JAVA_TOOL_OPTIONS.

Это позволяет пользоваться одношаговым сценарием даже тогда, когда параметры запуска для record и create различаются.

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

Например:

  • Тренировочный прогон сделать на маленькой облачной инстансе (ближе к реальной среде).
  • Создание кэша выполнить на мощной машине, чтобы сэкономить время (будущие оптимизации Leyden могут занимать минуты на слабой машине, но всего секунды на сильной).

JEP 515: Ahead-of-Time Method Profiling

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

Java-платформа динамична:

  • Любой класс можно унаследовать, и метод может перестать вызываться после переопределения.
  • Новые классы могут подгружаться в ответ на внешние события, расширяя поведение приложения непредсказуемым образом.

Поэтому статический анализ всегда ограничен.

При запуске JVM выявляет, какие методы действительно нагружают систему. Чтобы достичь пиковой производительности, JIT-компилятор должен найти «горячие» методы (hot methods), т.е. те, что чаще всего выполняются и потребляют больше всего CPU, и скомпилировать их байткод в нативный код. Именно эта логика дала название HotSpot JVM.

С JDK 1.2 HotSpot автоматически собирает профили, статистику по методам: количество выполнений байткода, встречающиеся типы объектов и т .д. Эти данные позволяют JVM предсказать поведение метода и оптимизировать его.

Таким образом, горячие методы компилируются, а холодные остаются без оптимизаций.

Обе части необходимы для максимальной производительности.

Однако есть «замкнутый круг»: приложение не достигает пика, пока не собраны профили, а профили нельзя собрать, пока приложение достаточно долго не поработает.

Сегодня JVM решает это, тратя ресурсы на сбор профилей в начале работы приложения. В этот период оно работает медленнее, пока JIT не скомпилирует горячие методы.

Решение: собирать профили заранее, во время тренировочного прогона. Тогда в продакшене затраты на профилирование исчезнут, а время разогрева будет определяться только затратами на JIT-компиляцию.

AOT-кэш (введённый в JEP 483) теперь может сохранять профили методов, собранные во время тренировочного прогона. Ранее кэш содержал только классы для ускоренного старта. Теперь он также содержит профили методов, которые JVM иначе собрала бы в начале работы. В результате приложение в продакшене запускается быстрее и быстрее достигает пиковой производительности.

Важно: наличие профилей в кэше не отменяет онлайн-профилирование в продакшене. Поведение приложения может измениться, поэтому JVM продолжит собирать и обновлять профили, комбинируя преимущества AOT-профилей, онлайн-профилирования и JIT-компиляции.

Эффект: JIT запускается раньше и работает точнее, оптимизируя горячие методы быстрее, сокращая разогрев. Поскольку JIT-задачи выполняются параллельно, реальное время разогрева сокращается ещё сильнее на мощных аппаратных ресурсах.

Пример: Программа ниже использует Stream API и грузит почти 900 классов JDK. В итоге JIT-компилирует около 30 горячих методов на максимальном уровне оптимизации:

import java.util.*;
import java.util.stream.*;

public class HelloStreamWarmup {

    static String greeting(int n) {
        var words = List.of("Hello", "" + n, "world!");
        return words.stream()
            .filter(w -> !w.contains("0"))
            .collect(Collectors.joining(", "));
    }

    public static void main(String... args) {
        for (int i = 0; i < 100_000; i++)
            greeting(i);
        System.out.println(greeting(0));  // "Hello, world!"
    }
}

Без профилей в AOT-кеше программа работает 90 мс. С профилями — 73 мс (ускорение на 19%). Кеш с профилями занимает дополнительно ~250 КБ, что всего на ~2,5% больше AOT-кеша без профилей.

Даже для такой маленькой программы выгода заметна, а для более сложных и долгоживущих приложений эффект ещё выше.

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

Это не стоит путать с GraalVM native-image: Graal уже умеет полную AOT-компиляцию, но это отдельный тулчейн за пределами стандартного JDK. JEP 515 — это шаг в сторону интеграции AOT-подхода непосредственно в HotSpot, чтобы со временем получить встроенный AOT-путь без сторонних инструментов.

Как правило, большинство приложений выигрывают от комбинации AOT- и JIT-компиляции. Поэтому профили и AOT-код дополняют друг друга, а не конкурируют.

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

Заключение

Мы рассмотрели вторую часть JEP в Java 25. В заключительной части мы обсудим оставшиеся нововведения, чтобы увидеть полную картину развития Java и оценить, как они изменят повседневную разработку.

Author image

Сергей Лунегов

Директор по продуктам Axiom JDK

Axiom JDK info@axiomjdk.ru Axiom JDK logo Axiom JDK На страже безопасности Java 199 Obvodnogo Kanala Emb. 190020 St. Petersburg RU +7 812-336-35-67 Axiom JDK 199 Obvodnogo Kanala Emb. 190020 St. Petersburg RU +7 812-336-35-67