Как стать автором
Обновить

Вышла Java 24

Время на прочтение26 мин
Количество просмотров9.1K

Вышла общедоступная версия Java 24. В этот релиз попало около 2700 закрытых задач и 24 JEP'а. Release Notes можно посмотреть здесь. Полный список изменений API – здесь.


Java 24 не является LTS-релизом, и у неё будут выходить обновления только полгода (до сентября 2025 года).


Скачать JDK 24 можно по этим ссылкам:


Рассмотрим все JEP'ы, которые попали в Java 24.


Язык


Primitive Types in Patterns, instanceof, and switch (Second Preview) (JEP 488)


Примитивные типы в паттернах, instanceof и switch, которые появились в Java 23 в режиме preview, остаются на второе preview без изменений:

// --enable-preview --source 24

Object obj = 42;
if (obj instanceof int i) { // matches
    System.out.println("int: " + i);
}

switch (obj) {
    case int i -> System.out.println("int: " + i); // matches
    case double d -> System.out.println("double: " + d);
    default -> System.out.println("other");
}

Проверять можно также и то, попадают ли значения в диапазон типа:

int i = 42;
if (i instanceof byte b) { // matches
    System.out.println("byte: " + b);
}

double d = 3.0;
switch (d) {
    case int i -> System.out.println("int: " + i); // matches
    case float f -> System.out.println("float: " + f);
    default -> System.out.println("other");
}

В примерах выше 42 попадает в диапазон byte ([-128; 127]), а 3.0 без потери точности приводится к int. Таким образом, это позволит более безопасно приводить одни числовые типы к другим, не прибегая к ручным проверкам диапазонов.


Подобные проверки могут быть полезны и в паттернах записей:

record JsonNumber(double d) {}

var json = new JsonNumber(3.0);
if (json instanceof JsonNumber(int i)) { // matches
    // ...
}

Если до Java 23/24 типы выражений-селекторов в switch могли быть только int, short, byte и char и для них поддерживались только константные ветки (case 3 и т.п.), то сейчас поддерживаются все примитивные типы и ветки могут быть паттернами:

float f = 1.0f;
switch (f) {
    case 0f -> System.out.println("0");
    case float x when x == 1f -> System.out.println("1"); // matches
    case float x -> System.out.println("other");
}

boolean b = "hello".isEmpty();
switch (b) {
    case true -> System.out.println("empty");
    case false -> System.out.println("non-empty"); // matches
}

В Java 25 возможно будет третье preview этой фичи.


Module Import Declarations (Second Preview) (JEP 494)


Module Import Declarations, которые появились в Java 23 в режиме preview, перешли во второе превью со следующими двумя изменениями.


Во-первых, при импорте модуля-агрегатора java.se, теперь импортируются и классы java.base. В Java 23 при импорте java.se приходилось отдельно импортировать java.base, чтобы пользоваться базовыми классами Java, что выглядело странно:

// --enable-preview --source 23

import module java.se;

public class Main {
  public static void main(String[] args) {
    // Нужен import module java.base, иначе будет error: cannot find symbol
    System.out.println(Set.of());
  }
}

В Java 24 теперь же это не нужно:

// --enable-preview --source 24

import module java.se;

public class Main {
  public static void main(String[] args) {
    // Работает:
    System.out.println(Set.of());
  }
}

Такое стало возможно благодаря тому, что в Java 24 разрешили модулям делать requires transitive java.base (раньше было нельзя, поскольку считалось, что любой модуль всегда и так зависит от java.base неявно):

// --enable-preview --source 24

module M {
  requires transitive java.base; // С Java 24 можно
}

В декларацию java.se строчка requires transitive java.base была соответственно и добавлена.


Во-вторых, импорты со звёздочкой теперь могут перекрывать импорты модулей. До этого перекрывать могли только одиночные импорты:

// --enable-preview --source 23

import module java.desktop;
import java.util.List; // Только так можно перекрыть java.awt.List из java.desktop (import java.util.* не сработает)

public class Main {
  public static void main(String[] args) {
    System.out.println(List.of()); // Резолвится java.util.List
  }
}

// --enable-preview --source 24

import module java.desktop;
import java.util.*; // Теперь можно и так

public class Main {
  public static void main(String[] args) {
    System.out.println(List.of()); // Резолвится java.util.List
  }
}


Напомним, что такое вообще импорт модулей. Декларация import module M эквивалентна импорту всех экспортированных пакетов из модуля M и его транзитивных зависимостей в текущий модуль.


Например, импорт модуля java.base имеет тот же эффект, как если бы мы вручную импортировались все его 54 экспортированных пакета:

import java.io.*;
import java.lang.*;
import java.lang.annotation.*;
// ... 49 packages ...
import javax.security.auth.x500.*;
import javax.security.cert.*;

Таким образом, написав всего лишь один импорт, можно будет получить доступ до таких неотъемлемых классов и интерфейсов как List, Map, Stream, Path, Function и др. без необходимости отдельного импорта их соответствующих пакетов.


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


При использовании простых исходных файлов модуль java.base импортируется автоматически. Об этом следующий JEP 495.


Планируется, что фича выйдет из preview в Java 25.


Simple Source Files and Instance Main Methods (Fourth Preview) (JEP 495)


Простые исходные файлы и инстанс-методы main() остаются в режиме preview в четвёртый раз. Эта фича появилась в Java 21 и дважды менялась: сначала в Java 22, а потом в Java 23. На этот раз изменений нет кроме новой терминологии и названия JEP'а (в Java 25 фича возможно опять поменяется).


Новый протокол запуска Java-программ позволяет запускать классы, у которых метод main() не является public static (т.е. является instance-методом) и у которого нет параметра String[] args:

// --enable-preview --source 24
class HelloWorld {
    void main() {
        System.out.println("Hello, World!");
    }
}

В таком случае во время запуска JVM сама создаст экземпляр класса HelloWorld и вызовет у него метод main():

$ java --enable-preview --source 24 HelloWorld.java
Hello, World!


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

// HelloWorld.java

String greeting = "Hello, World!";

void main() {
    System.out.println(greeting);
}

$ java --enable-preview --source 24 HelloWorld.java
Hello, World!

В таком случае виртуальная машина сама объявит неявный класс, в который поместит метод main() и другие верхнеуровневые объявления в файле:

// class <some name> { ← неявно
String greeting = "Hello, World!";

void main() {
    println(greeting);
}
// }

Неявный класс обладает практически всеми возможностями явного класса (возможность содержать методы, поля), но есть несколько отличий:

  • Неявный класс может находиться только в безымянном пакете.
  • Код в неявном классе не может ссылаться на него по имени.
  • Неявный класс всегда имеет один дефолтный конструктор без аргументов.
  • Неявный класс всегда является final и наследуется от java.lang.Object.

При этом неявный класс не является безымянным: у него есть имя, совпадающее с именем файла (но это является деталью реализации, на которую не стоит полагаться).


В простых исходных файлах также можно обращаться напрямую к следующим 5 методам для работы с консолью:

  • public static void println(Object obj);
  • public static void println();
  • public static void print(Object obj);
  • public static String readln(String prompt);
  • public static String readln();

То есть пример выше можно написать ещё короче:

String greeting = "Hello, World!";

void main() {
    println(greeting);
}

Эти методы определены в новом классе java.io.IO (каждый простой исходный файл неявно статически импортирует все его методы).


Также каждый простой исходный файл неявно импортирует модуль java.base (фича из предыдущего JEP 494). Это значит, что ко всем базовым классам Java можно обращаться без необходимости импортов:

void main() {
    println(List.of("James", "Bill", "Guy"));
}


Простые исходные файлы и instance методы main() вводятся в Java с двумя целями:

  1. Облегчить процесс обучения языку. На новичка, только что начавшего изучение Java, не должно сваливаться всё сразу, а концепции должны вводятся постепенно, начиная с базовых (переменные, циклы, процедуры) и постепенно переходя к более продвинутым (классы, области видимости).
  2. Облегчить написание коротких программ и скриптов. Количество церемоний для них должно быть сведено к минимуму.

Планируется, что фича выйдет из preview в Java 25 (и Simple Source Files будут переименованы в Compact Source Files).


Flexible Constructor Bodies (Third Preview) (JEP 492)


Statements before super(), которые появились в Java 22 в режиме preview и были переименованы во Flexible Constructor Bodies в Java 23, остаются на третье preview без значительных изменений.


Flexible Constructor Bodies разрешают писать инструкции кода в конструкторе перед явным вызовом конструктора (super() или this()):

// --enable-preview --source 24
public class PositiveBigInteger extends BigInteger {
    public PositiveBigInteger(long value) {
        if (value <= 0)
            throw new IllegalArgumentException("non-positive value");
        super(value);
    }
}

Напомним, что с самого первого релиза Java 1.0 это было запрещено, поэтому в случаях, когда необходимо выполнить код перед вызовом конструктора, приходилось использовать обходные пути, например, прибегать к вспомогательным статическим методам:

public class PositiveBigInteger extends BigInteger {
    public PositiveBigInteger(long value) {
        super(verifyPositive(value));
    }

    private static long verifyPositive(long value) {
        if (value <= 0)
            throw new IllegalArgumentException("non-positive value");
    }
}

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

public class Super {
    public Super(C x, C y) { ... }
}

public class Sub extends Super {
    private Sub(C x) { // Auxiliary constructor
        super(x, x); // x is shared here
    }

    public Sub(int i) {
        this(new C(i));
    }
}

В Java 24, включив режим preview, то же самое можно реализовать гораздо короче:

// --enable-preview --source 24
public class Sub extends Super {
    public Sub(int i) {
        var x = new C(i);
        super(x, x);
    }
}

Не всякий код можно поместить перед вызовом конструктора: код в прологе не должен ссылаться на конструируемый объект (читать поля, вызывать инстанс-методы). Рассмотрим несколько примеров некорректного кода:

class A {
    int i;

    A() {
        System.out.print(this); // Error
        var x = i;              // Error
        hashCode();             // Error
        super();
    }
}

Ссылаться на родительский объект также нельзя (ведь это тоже часть текущего объекта):

class B {
    int i;
    void m() {}
}

class C extends B {
    C() {
        var x = i; // Error
        m();       // Error
        super();
    }
}

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

class Outer {
    class Inner {
    }

    Outer() {
        new Inner(); // Error
        super();
    }
}

Однако если читать поля конструируемого класса до вызова super() нельзя, то инициализировать их можно:

class A {
    int i;

    A(int i) {
        this.i = i; // OK
        super();
    }
}

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

class Super {
    Super() { overriddenMethod(); }

    void overriddenMethod() {
        System.out.println("hello");
    }
}

class Sub extends Super {
    final int x;

    Sub(int x) {
        this.x = x;
    }

    @Override
    void overriddenMethod() {
        System.out.println(x); // new Sub(42) will print 0
    }
}

Чтобы предотвратить такую ситуацию, нужно поместить инициализацию поле выше вызова super():

class Super {
    Super() { overriddenMethod(); }

    void overriddenMethod() {
        System.out.println("hello");
    }
}

class Sub extends Super {
    final int x;

    Sub(int x) {
        this.x = x;
        super();
    }

    @Override
    void overriddenMethod() {
        System.out.println(x); // new Sub(42) will print 42
    }
}

Также инициализация полей до super() можно пригодиться в проекте Valhalla для definite assignment полей null-restricted value-классов.


Интересно, что новая возможность затрагивает исключительно компилятор Java – JVM уже и так давно поддерживает байткод, в котором присутствуют инструкции перед вызовом super() или this(), если эти инструкции не трогают конструируемый объект (JVM даже ещё более либеральна, например, она разрешает несколько вызовов конструкторов, если любой путь обязательно завершается одним вызовом конструктора).


Планируется, что фича выйдет из preview в Java 25.


API


Stream Gatherers (JEP 485)


Stream gatherers, которые в Java 22 и в Java 23 были в режиме preview, стали постоянным API.


Gatherers – это усовершенствование Stream API для поддержки произвольных промежуточных операций.


Напомним, что стримы с появления в Java 8 имели фиксированный набор промежуточных операций (map, flatMap, filter, reduce, limit, skip и т.д). В Java 9 были добавлены takeWhile и dropWhile. Хотя этот стандартный набор операций довольно богатый и покрывает большинство случаев, иногда бывают необходимы более изощрённые промежуточные операции для более сложных задач. Чтобы решить эту проблему, было предложено создать точку расширения для стримов, которая позволит кому угодно создать свои промежуточные операции.


Новая точка расширения – это новый метод Stream::gather(Gatherer), который обрабатывает элементы стрима путём применения объекта, реализующего интерфейс Gatherer, предоставляемого пользователем. Операция gather() аналогична уже имеющейся операции Stream::collect(Collector): если collect() и Collector определяют точку расширения для терминальных операций, то gather() и Gatherer определяют точку расширения для промежуточных.


Gatherer представляет собой трансформацию элементов стрима. Манера трансформации может быть совершенно произвольной: one-to-one, one-to-many, many-to-one или many-to-many. Поддерживается короткое замыкание, если надо в какой-то момент остановить обработку и отбросить все дальнейшие элементы. Бесконечные стримы могут преобразовываться в конечные, и наоборот, конечные могут преобразовываться в бесконечные. Поддерживается параллельное исполнение. Всё это возможно благодаря максимально обобщённой форме интерфейса Gatherer.


gather() также является промежуточной операцией, поэтому может быть несколько gather() в одной цепочке:

source.gather(a).gather(b).gather(c).collect(...)

Вместе с самим Gatherer было добавлено несколько готовых gatherer'ов, определённых в новом классе Gatherers. Это fold, mapConcurrent, scan, windowFixed и windowSliding.


Давайте рассмотрим несколько примеров:

jshell> Stream.of(1,2,3,4,5,6,7,8,9)
   ...>       .gather(Gatherers.fold(() -> "", (str, n) -> str + n))
   ...>       .findFirst()
   ...>       .get();
$1 ==> "123456789"

jshell> Stream.of(1,2,3,4,5,6,7,8,9)
   ...>       .gather(Gatherers.scan(() -> "", (str, n) -> str + n))
   ...>       .toList()
$2 ==> [1, 12, 123, 1234, 12345, 123456, 1234567, 12345678, 123456789]

jshell> Stream.of(1,2,3,4,5,6,7,8).gather(Gatherers.windowFixed(3)).toList()
$3 ==> [[1, 2, 3], [4, 5, 6], [7, 8]]

jshell> Stream.of(1,2,3,4,5,6).gather(Gatherers.windowSliding(3)).toList()
$4 ==> [[1, 2, 3], [2, 3, 4], [3, 4, 5], [4, 5, 6]]

Дизайн интерфейса Gatherer был создан под влиянием интерфейса Collector. Вот основная часть его сигнатуры:

public interface Gatherer<T, A, R> {
    Supplier<A> initializer();
    Integrator<A, T, R> integrator();
    BinaryOperator<A> combiner();
    BiConsumer<A, Downstream<? super R>> finisher();
}

Если взглянуть на Collector, то он также имеет три параметра T, A, R и содержит 4 основных метода: supplier, accumulator, combiner и finisher. Однако Gatherer использует два вспомогательных интерфейса Integrator и Downstream, так как поддержка произвольных промежуточных операций требует немного более сложного устройства, чем терминальных.


Для написания собственных gatherer'ов, как правило, не приходится с нуля реализовывать интерфейс Gatherer и можно воспользоваться готовыми методами-фабриками: Gatherer::of(Integrator), Gatherer::ofSequential(Integrator) или другими вариациями.


Class-File API (JEP 484)


Стандартное API для парсинга, генерации и трансформации class-файлов, которое было в Java 22 и в Java 23 в режиме preview, стало постоянным API.


Новое API находится в пакете java.lang.classfile. Оно должно заменить копию библиотеки ASM внутри JDK, которую планируется удалить, как только все компоненты JDK перейдут с неё на новое API.


Основная проблема ASM (и других библиотек для работы с class-файлами) – это то, что она не успевает за ускорившимся в последнее время темпом выхода релизов JDK (два раза в год), а соответственно, и за изменениями в формате class-файлов. Кроме того, ASM – это сторонняя библиотека, а значит её поддержка возможностей class-файлов всегда отстаёт от JDK, что создаёт проблемы как в экосистеме, так и в самой JDK. Стандартное API же эволюционирует одновременно с форматом class-файлов. Как только выходит новая версия Java, фреймворки и инструменты, использующие API, немедленно и автоматически получают поддержку нового формата.


Новое API также спроектировано с учётом новых возможностей Java, таких, как лямбды, записи, sealed-классы и паттерн-матчинг. ASM же – очень старая библиотека, основанная на визиторах, что совершенно неуместно в 2025 году.


Warn upon Use of Memory-Access Methods in sun.misc.Unsafe (JEP 498)


При вызове методов доступа к памяти в классе sun.misc.Unsafe теперь выдаётся предупреждение в консоль (при первом вызове к соответствующему методу):

WARNING: A terminally deprecated method in sun.misc.Unsafe has been called
WARNING: sun.misc.Unsafe::setMemory has been called by com.foo.bar.Server (file:/tmp/foobarserver/thing.jar)
WARNING: Please consider reporting this to the maintainers of com.foo.bar.Server
WARNING: sun.misc.Unsafe::setMemory will be removed in a future release

До этого все эти методы стали deprecated for removal. Это произошло в Java 23 и в Java 18. Это почти все методы класса: 77 из 87 методов.


Также в Java 22 стали deprecated for removal 6 методов, не относящиеся к памяти, но в Java 24 при их вызове пока не будет предупреждения.


Вместо методов доступа к памяти необходимо использовать стандартное API в Java:

В Java 23 появилась новая опция командной строки --sun-misc-unsafe-memory-access={allow|warn|debug|deny}:

  • --sun-misc-unsafe-memory-access=allow – при вызове методов предупреждения нет (дефолтное значение в Java 23).
  • --sun-misc-unsafe-memory-access=warn – выдаётся предупреждение при первом вызове (стало дефолтным значением в Java 24).
  • --sun-misc-unsafe-memory-access=debug – выдаётся предупреждение при каждом вызове.
  • --sun-misc-unsafe-memory-access=deny – выбрасывается UnsupportedOperationException (станет дефолтным значением в Java 26 или позже; allow использовать будет нельзя).

В конце концов методы доступа к памяти будут удалены совсем (опция --sun-misc-unsafe-memory-access будет игнорироваться какое-то время, а потом удалится).


Prepare to Restrict the Use of JNI (JEP 472)


С Java 24 началась подготовка к ограничению JNI (Java Native Interface). Первым шагом стало то, что при загрузке нативных библиотек через JNI и линковке теперь будут выдаваться предупреждения в консоль:

WARNING: A restricted method in java.lang.System has been called
WARNING: System::load has been called by com.foo.Server in module com.foo (file:/path/to/com.foo.jar)
WARNING: Use --enable-native-access=com.foo to avoid a warning for callers in this module
WARNING: Restricted methods will be blocked in a future release unless native access is enabled

Разработчик может подавить такие предупреждения с помощью опции командной строки --enable-native-access, в которой необходимо будет явно перечислить модули, которым даётся разрешение на использование нативных библиотек:

java --enable-native-access=M1,M2 ...

Если ваш код находится в class path, то в качестве модуля надо указать ALL-UNNAMED:

java --enable-native-access=ALL-UNNAMED ...

Опция --enable-native-access не нова – она появилась в Java 22 с появлением Foreign Function & Memory API. Но если до Java 24 она контролировала только ограничение на использование FFM API, то теперь распространяется и на JNI.


В Java 24 также появилась новая опция --illegal-native-access={allow,warn,deny}. Она может пригодиться, если не хочется явно перечислять модули через --enable-native-access. Её значение по умолчанию – warn, но если указать allow, то все предупреждения о нелегальном нативном доступе в консоли будут подавлены. Если же указать deny, то при каждом нелегальном доступе, наоборот, будет выбрасываться исключение IllegalCallerException. deny станет значением по умолчанию в одной из будующих версий Java. Когда это случится, можно будет переключиться только на warn (allow будет удалён). Ещё позже будет недоступен уже и warn, и --enable-native-access будет единственным способом разрешения JNI.


Ограничение JNI – это один из этапов перехода на философию Integrity by Default в Java. Смысл её в том, что по умолчанию код, который запускает пользователь, не должен делать ничего кроме использования безопасного API. Если же нужно использовать что-то небезопасное, что потенциально может нарушить целостность, то это возможно только при условии явного разрешения со стороны пользователя.


Permanently Disable the Security Manager (JEP 486)


Security Manager, который стал deprecated for removal в Java 17, теперь полностью отключён. При попытке его включить через командную строку виртуальная машина откажется стартовать, выдав ошибку:

$ java -Djava.security.manager -jar app.jar
Error occurred during initialization of VM
java.lang.Error: A command line option has attempted to allow or enable the Security Manager. Enabling a Security Manager is not supported.
        at java.lang.System.initPhase3(java.base@24/System.java:2067)

С любыми другими значениями свойства java.security.manager (кроме disallow) будет происходить то же самое:

  • -Djava.security.manager=""
  • -Djava.security.manager=allow
  • -Djava.security.manager=default
  • -Djava.security.manager=com.foo.CustomM

Также при попытке установки Security Manager'а в рантайме через System::setSecurityManager будет всегда выбрасываться UnsupportedOperationException:

Exception in thread "main" java.lang.UnsupportedOperationException: Setting a Security Manager is not supported.
        at java.base/java.lang.System.setSecurityManager(System.java:286)
        ...

Другие изменения в поведении:

  • System::getSecurityManager всегда возвращает null.
  • Методы AccessController::doPrivileged просто выполняют действие немедленно.
  • AccessController::checkPermission всегда выбрасывает AccessControlException.
  • Policy::setPolicy всегда выбрасывает UnsupportedOperationException.
  • Методы SecurityManager::check* всегда выбрасывают SecurityException.
  • Игнорируются некоторые свойства, связанные с Security Manager (java.security.policy, jdk.security.filePermCompat и другие).

Сами классы, связанные с Security Manager API, пока что остаются. Окончательное их удаление планируется в будущем.


Отказаться от Security Manager было решено по причине того, что он почти не имел спроса среди разработчиков и при этом слабо отвечал современным требованиям безопасности. Сейчас существуют гораздо более эффективные способы обеспечения безопасности приложений: контейнеры, гипервизоры, песочницы на уровне OS и т.д. Цена же поддержки Security Manager'а оставалось крайне высокой из-за сложной программной модели. Избавившись от необходимости поддерживать Security Manager, разработчики OpenJDK смогут лучше сфокусироваться на других более важных частях JDK, связанных с безопасностью.


Structured Concurrency (Fourth Preview) (JEP 499)


Structured Concurrency, которое было в режиме preview в Java 21, Java 22 и Java 23, остаётся на четвёртый раунд preview без изменений.


Structured Concurrency – это подход многопоточного программирования, который заимствует принципы из однопоточного структурного программирования. Главная идея такого подхода заключается в следующем: если задача расщепляется на несколько конкурентных подзадач, то эти подзадачи воссоединяются в блоке кода главной задачи. Все подзадачи логически сгруппированы и организованы в иерархию. Каждая подзадача ограничена по времени жизни областью видимости блока кода главной задачи.


В центре нового API класс StructuredTaskScope, у которого есть два главных метода:

  • fork() – создаёт подзадачу и запускает её в новом виртуальном потоке,
  • join() – ждёт, пока не завершатся все подзадачи или пока scope не будет остановлен.

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

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());
}

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

  • Время жизни всех потоков подзадач ограничено областью видимости блока try-with-resources. Метод close() гарантированно не завершится, пока не завершатся все подзадачи.
  • Если одна из операций findUser() и fetchOrder() завершается ошибкой, то другая операция отменяется автоматически, если ещё не завершена (в случае политики ShutdownOnFailure, возможны другие).
  • Если главный поток прерывается в процессе ожидания join(), то обе операции findUser() и fetchOrder() отменяются при выходе из блока.
  • В дампе потоков будет видна иерархия: потоки, выполняющие findUser() и fetchOrder(), будут отображаться как дочерние для главного потока.

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


В Java 25 возможно будет пятое preview Structured Concurrency с изменениями в API.


Scoped Values (Fourth Preview) (JEP 487)


Scoped Values, которые были в preview в Java 21, Java 22 и Java 23, остаётся на четвёртый раунд preview.


В четвёртом preview в классе ScopedValue были удалены методы callWhere() и runWhere().


Класс ScopedValue позволяет обмениваться иммутабельными данными без их передачи через аргументы методов. Он является альтернативой существующему классу ThreadLocal.


Классы ThreadLocal и ScopedValue похожи тем, что решают одну и ту же задачу: передать значение переменной в рамках одного потока (или дерева потоков) из одного места в другое без использования явного параметра. В случае ThreadLocal для этого вызывается метод set(), который кладёт значение переменной для данного потока, а потом метод get() вызывается из другого места для получения значения переменной. У данного подхода есть ряд недостатков:

  • Неконтролируемая мутабельность (set() можно вызвать когда угодно и откуда угодно).
  • Неограниченное время жизни (переменная очистится, только когда завершится исполнение потока или когда будет вызван ThreadLocal.remove(), но про него часто забывают).
  • Высокая цена наследования (дочерние потоки всегда вынуждены делать полную копию переменной, даже если родительский поток никогда не будет её изменять).

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


ScopedValue лишён вышеперечисленных недостатков. В отличие от ThreadLocal, ScopedValue не имеет метода set(). Значение ассоциируется с объектом ScopedValue путём вызова другого метода where(). Далее вызывается метод run(), на протяжении которого это значение можно получить (через метод get()), но нельзя изменить. Как только исполнение метода run() заканчивается, значение отвязывается от объекта ScopedValue. Поскольку значение не меняется, решается и проблема дорогого наследования: дочерним потокам не надо копировать значение, которое остаётся постоянным в течение периода жизни.


Пример использования ScopedValue:

private static final ScopedValue<FrameworkContext> CONTEXT = ScopedValue.newInstance();

void serve(Request request, Response response) {
    var context = createContext(request);
    ScopedValue.where(CONTEXT, context)
               .run(() -> Application.handle(request, response));
}

public PersistedObject readKey(String key) {
    var context = CONTEXT.get();
    var db = getDBConnection(context);
    db.readKey(key);
}

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


Key Derivation Function API (Preview) (JEP 478)


В пакете javax.crypto в режиме preview появилось новое API для функций выведения ключа (KDF — Key Derivation Functions). Такие функции могут использоваться для вывода криптографически сильных секретных ключей (например, AES) на основе материала ключа (например, пароля) и других данных (например, соли).


Новое KDF API является гораздо более подходящим для задач выведения ключей, чем старое API на основе классов KeyGenerator и SecretKeyFactory.


Пока что единственной реализацией KDF API в JDK является HKDF (HMAC-based Extract-and-Expand Key Derivation Function), но в будущем планируется реализовать и другие KDF, например, Argon2.


Пример выведения секретного AES-ключа с использованием HKDF:

// Create a KDF object for the specified algorithm
KDF hkdf = KDF.getInstance("HKDF-SHA256");

// Create an ExtractExpand parameter specification
AlgorithmParameterSpec params =
    HKDFParameterSpec.ofExtract()
                     .addIKM(initialKeyMaterial)
                     .addSalt(salt).thenExpand(info, 32);

// Derive a 32-byte AES key
SecretKey key = hkdf.deriveKey("AES", params);

// Additional deriveKey calls can be made with the same KDF object

Ранее в Java 21 появилось API для механизма инкапсуляции ключей (KEM). Вместе с KDF эти два API являются важными шагами для поддержки в Java Hybrid Public Key Encryption (HPKE), криптографической схемы, устойчивой к квантовым атакам.


Quantum-Resistant Module-Lattice-Based Key Encapsulation Mechanism (JEP 496)


В Java появилась реализация механизма инкапсуляции ключей ML-KEM. Это современный криптографический алгоритм обмена ключей, устойчивый к квантовым атакам.


Реализация ML-KEM использует KEM API, появившееся в Java 21. Поддерживается три набора параметров: ML-KEM-512, ML-KEM-768 и ML-KEM-1024.


Пример генерации симметричного ключа и его передачи:

// Receiver side
KeyPairGenerator g = KeyPairGenerator.getInstance("ML-KEM");
KeyPair kp = g.generateKeyPair(); // По умолчанию создаёт пару ML-KEM-768

// Sender side
KEM kem1 = KEM.getInstance("ML-KEM");
KEM.Encapsulator enc = kem1.newEncapsulator(kp.getPublic());
KEM.Encapsulated encapsulated = enc.encapsulate();
byte[] msg = encapsulated.encapsulation(); // Send this to receiver
SecretKey k1 = encapsulated.key(); // Generated symmetric key

// Receiver side
KEM kem2 = KEM.getInstance("ML-KEM");
KEM.Decapsulator dec = kem2.newDecapsulator(kp.getPrivate());
SecretKey k2 = dec.decapsulate(msg);

// k1 и k2 должны быть равны


Quantum-Resistant Module-Lattice-Based Digital Signature Algorithm (JEP 497)


В Java появилась реализация механизма цифровой подписи ML-DSA. Это современный алгоритм цифровой подписи, устойчивый к квантовым атакам.


Поддерживается три набора параметров: ML-DSA-44, ML-DSA-65 и ML-DSA-87.


Пример подписи сообщения и её верификации:

KeyPairGenerator g = KeyPairGenerator.getInstance("ML-DSA");
KeyPair kp = g.generateKeyPair(); // По умолчанию создаёт пару ML-DSA-65

// Подпись
byte[] msg = ...;
Signature ss = Signature.getInstance("ML-DSA");
ss.initSign(kp.getPrivate());
ss.update(msg);
byte[] sig = ss.sign();

// Верификация
byte[] msg = ...;
byte[] sig = ...;
Signature sv = Signature.getInstance("ML-DSA");
sv.initVerify(kp.getPublic());
sv.update(msg);
boolean verified = sv.verify(sig);


Vector API (Ninth Incubator) (JEP 489)


Векторное API в модуле jdk.incubator.vector, которое появилось ещё аж в Java 16, остаётся в инкубационном статусе в девятый раз с некоторыми изменениями.


Векторное API остаётся так долго в инкубаторе, потому что зависит от некоторых фич проекта Valhalla (главным образом, от value-классов), который пока что находится в разработке. Как только эти фичи станут доступны в виде preview, векторное API сразу же перейдёт из инкубатора в статус preview.


JVM


Synchronize Virtual Threads without Pinning (JEP 491)


Виртуальные потоки больше не пинятся на свои потоки-носители внутри блоков synchronized. Такое ограничение существовало с Java 21 по Java 23, где виртуальный поток, захватив монитор, не мог размонтироваться и освободить платформенный поток для другой работы. Особенно плохо это проявлялось в ситуации, в которой код внутри блока synchronized вызывает долгую блокирующую операцию:

synchronized byte[] getData() {
    byte[] buf = ...;
    int nread = socket.getInputStream().read(buf); // Can block here
    ...
}

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


Ещё одним случаем пиннинга было ожидание освобождения монитора при входе в блок synchronized виртуальным потоком. Здесь тоже поток-носитель не мог размонтироваться, до тех пор пока поток не захватит монитор.


Чтобы предотвратить пиннинг, пользователям и авторам библиотек приходилось переписывать код с synchronized на классы из пакета java.util.concurrent.locks, которые не имеют вышеописанных проблем (но код становится более громоздким). С Java 24 это больше не требуется.


Виртуальные потоки появились в Java 21. Они являются легковесной заменой потоков операционной системы, и их можно создавать в огромном количестве (миллионы экземпляров), что облегчает написание конкурентных программ. Их разработка ведётся в проекте Loom, который был инициирован в сентябре 2017 года.


Compact Object Headers (Experimental) (JEP 450)


В экспериментальном режиме появилась опция, уменьшающая размер заголовков объектов в JVM с 96/128 бит до 64 бит на 64-битных платформах:

-XX:+UnlockExperimentalVMOptions -XX:+UseCompactObjectHeaders

Заголовки объектов занимают достаточно большой процент памяти, потребляемой JVM. Эксперименты показывают, что в большинстве случаев объекты занимают от 256 до 512 бит. То есть уменьшив заголовки до 64 бит, можно уменьшить потребление памяти на 6-12%. Кроме уменьшения кучи это может улучшить и производительность благодаря более высокой скорости выделения новых объектов, более низкой нагрузки на GC и лучшей локальности данных.


Сжатие заголовков достигается за счёт объединения mark-слова (64 бит) и class-слова (64 или 32 бит, если включены сжатые указатели на классы) в одно 64-битное слово. В новой схеме указатели на классы всегда являются сжатыми, и количество бит для них уменьшается с 32 до 22. Identity хеш-код остаётся неизменным: 31 бит. Количество тег-битов становится на один больше (для GC self forwarding). Битов для возраста GC остаётся 4, как и было. Также 4 бита резервируются на будущее для Valhalla.


Работа по сжатию заголовков в OpenJDK ведётся в проекте Lilliput, инициированным в марте 2021 года. JEP 450 является первым результатом работы, попавшим в мейнстрим. В дальнейшем возможно ещё большое сжатие заголовков до 32 бит, что уменьшит потребление памяти ещё сильнее.


Ahead-of-Time Class Loading & Linking (JEP 483)


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

$ java -XX:AOTMode=record -XX:AOTConfiguration=app.aotconf \
       -cp app.jar com.example.App ...

После такого запуска генерируется AOT-конфигурация в файле app.aotconf, которую нужно преобразовать в AOT-кэш:

$ java -XX:AOTMode=create -XX:AOTConfiguration=app.aotconf \
       -XX:AOTCache=app.aot -cp app.jar

(второй шаг не запускает приложение, а только создаёт кэш)


Далее все последующие запуски приложения с использованием кэша app.aot должны быть быстрее:

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

Запуск приложения становится быстрее благодаря тому, что вся работа по чтению, парсингу, загрузке и линковке классов была сделана ahead-of-time, и JVM уже можно её не делать just-in-time.


Замеры показывают, что простое приложение, использующее Stream API, выполняется на 42% быстрее, если его запустить с использованием AOT-кэша. Другое серверное приложение Spring PetClinic стартует также на 42% быстрее с использованием кэша.


У технологии есть ряд ограничений:

  • Все последующие запуски должны выполняться на том же релизе JDK и той же архитектуре.
  • Class path должен быть консистентен тому, что использовался для тренировки.
  • Граф модулей должен быть идентичен. Аргументы -m, --module, -p, --module-path, --add-modules и --enable-native-access должны быть одинаковы. Аргументы --add-exports, --add-opens, --add-reads, --illegal-native-access, --limit-modules, --patch-module и --upgrade-module-path не должны использоваться.
  • Сборщик мусора ZGC пока не поддерживается.
  • Могут быть закэшированы только классы, загруженные встроенными загрузчиками классов (пользовательские загрузчики пока не поддерживаются).

Ahead-of-Time Class Loading & Linking – это дальнейшее развитие технологии CDS (Class Data Sharing), которая уже давно есть в Java. До этого CDS могла использоваться только для чтения и парсинга class-файлов. Сейчас же поддерживается их загрузка и линковка.


Работа над ускорением запуска JVM ведётся в проекте Leyden. Он был инициирован в апреле 2020 года.


ZGC: Remove the Non-Generational Mode (JEP 490)


Режим работы с поколениями в сборщике мусора ZGC (-XX:+UseZGC), который появился в Java 21 и стал включённым по умолчанию в Java 23, теперь стал единственным режимом работы. Режим работы без поколений был удалён, и опция -XX:±ZGenerational более не имеет эффекта. В будущем при использовании этой опции JVM будет выбрасывать ошибку и будет отказываться стартовать.


Сборщиком мусора по умолчанию по-прежнему остаётся G1. Он стал дефолтным сборщиком мусора в Java 9 (до него дефолтным был Parallel GC).


Generational Shenandoah (Experimental) (JEP 404)


Сборщик мусора Shenandoah стал поддерживать поколения в экспериментальном режиме:

java -XX:+UseShenandoahGC -XX:+UnlockExperimentalVMOptions -XX:ShenandoahGCMode=generational

Режим работы без поколений остаётся режимом по умолчанию в Shenandoah. Планируется, что режим с поколениями станет дефолтным в будущем.


Remove the Windows 32-bit x86 Port (JEP 479)


32-битный порт OpenJDK под Windows был полностью удалён. Это означает, что из кодовой базы OpenJDK были удалены части кода, отвечающие за Windows 32-бит. Собрать JDK под эту платформу больше нельзя.


32-битный порт под Windows был помечен как deprecated for removal в Java 21. Главной причиной удаления порта стало желание упростить и ускорить разработку платформы. Также в 32-битной версии нет эффективной реализации виртуальных потоков: в ней они мапятся 1:1 на платформенные потоки. Кроме того, 32-битная версия Windows сама доживает свои последние дни: Microsoft прекращает поддержку Windows 10 в октябре (последняя версия Windows, поддерживающая 32 бит).


Deprecate the 32-bit x86 Port for Removal (JEP 501)


Вместе с полным удалением 32-битного порта под Windows вся 32-битная x86 архитектура стала deprecated for removal. Практически это означает, что подлежит удалению порт OpenJDK под Linux 32-бит x86, так как это единственная оставшаяся операционная система, для которой есть 32-битный x86 порт.


Причины такого решения те же самые, что и у удаления 32-битного порта Windows: сложность поддержки, отсутствие эффективной реализации виртуальных потоков и скорое прекращение поддержки 32-бит x86 в Debian.


Полностью удалить 32-битный x86 порт планируется в Java 25. После полного удаления единственным способом запуска Java на 32-бит x86 процессорах будет использование порта Zero.


Linking Run-Time Images without JMODs (JEP 493)


JDK теперь может иметь примерно на 25% меньший размер, если в нём будет включена опция, позволяющая собирать кастомные образы с помощью утилиты jlink без использования JMOD-файлов. Такая опция указывается при сборке JDK, и она выключена по умолчанию, но вендоры JDK могут ей воспользоваться, если захотят предоставлять более лёгкие образы JDK.


В некоторых случаях размер JDK является критичным параметром, например, в облачных окружениях, где JDK часто передаётся по сети. Уменьшение размера JDK может улучшить эффективность этих операций. JMOD-файлы, находящиеся в директории jmods – это одна из причин слишком большой «раздутости» JDK, так как в них содержатся копии всех class-файлов, нативных библиотек и прочих ресурсов, которые уже и так есть внутри образа. Избавиться от JMOD-файлов можно добавив возможность линковать кастомные образы, вытаскивая эти файлы из самого образа, а не из JMOD-файлов. Что и было сделано в JEP 493.


Проверить, поддерживает ли ваш образ JDK создание кастомных образов без использования JMOD-файлов, можно запустив команду jlink --help и посмотреть, что написано в Capabilities:

$ jlink --help
Usage: jlink <options> --module-path <modulepath> --add-modules <module>[,<module>...]
...
Capabilities:
      Linking from run-time image enabled


Образы JDK с включённой опцией имеют ряд ограничений:

  • Сама утилита jlink не может быть в сгенерированном образе после запуска jlink.
  • jlink выдаст ошибку, если были изменения в конфигурационных файлах.
  • Кросс-линковка (например, запуск jlink на Linux/x64 для создания образа под Windows/x64) не поддерживается.
  • Линковка не будет работать, если образ использует опцию --patch-module.
  • Линковка с извлечением модулей из другого образа не поддерживается.

Опция может быть включена по умолчанию в будущем.


Late Barrier Expansion for G1 (JEP 475)


Реализация барьеров в сборщике мусора G1 была упрощена путём перемещения экспансии барьеров с ранней фазы JIT-компилятора C2 на позднюю.


Ранняя экспансия барьеров требует от разработчика глубокого знания внутренностей C2, что может замедлить эволюцию и оптимизацию G1. Кроме того, ранняя экспансия увеличивает накладные расходы C2 на 10-20% из-за сложности промежуточного представления (IR) барьеров.


В Java 24 экспансия барьеров перемещена с самой первой фазы (парсинга байткода в IR) на самую последнюю (эмиссия машинного кода). Такая схема существует в другом сборщике мусора ZGC c Java 14 и доказала свою успешность: экспансия барьеров в самый последний момент не требует никаких специфичных для C2 знаний и не имеет практически никаких накладных расходов на JIT-компиляцию.

Теги:
Хабы:
+38
Комментарии0

Другие новости

Истории

Работа

Java разработчик
210 вакансий

Ближайшие события

4 – 5 апреля
Геймтон «DatsCity»
Онлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань
20 – 22 июня
Летняя айти-тусовка Summer Merge
Ульяновская область