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

Java 21 является LTS-релизом, а значит у него будут выходить обновления как минимум 5 лет с момента выхода.

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


Вот список JEP'ов, которые попали в Java 21.


Язык


Pattern Matching for switch (JEP 441)


Паттерн-матчинг для switch наконец-то был финализирован и стал стабильной конструкцией языка. Напомним, что он появился в Java 17 и был в состоянии preview четыре релиза: 17, 18, 19 и 20.


Новый паттерн-матчинг существенно расширяет возможности оператора switch. Начиная с Java 1.0, switch поддерживал только сравнение с примитивными константами. Позже список типов был расширен (Java 5 – перечисления, Java 7 – строки), но в ветках case всё ещё могли быть только константы.


Теперь же switch поддерживает в ветках case так называемые паттерны:


Object obj = …
return switch (obj) {
    case Integer i -> String.format("int %d", i);
    case Long l -> String.format("long %d", l);
    case Double d -> String.format("double %f", d);
    case String s -> String.format("String %s", s);
    default -> obj.toString();
};

Паттерны могут снабжаться условиями с использованием нового ключевого слова when:


Object obj = …
return switch (obj) {
    case Integer i when i > 0 -> String.format("positive int %d", i);
    case Integer i -> String.format("int %d", i);
    case String s -> String.format("String %s", s);
    default -> obj.toString();
};

Также добавлена поддержка матчинга null. Сделать это можно с помощью явной отдельной ветки case null:


Object obj = …
switch (obj) {
    case null -> System.out.println("Null");
    case String s -> System.out.println("String: " + s);
    default -> System.out.println("Other");
}

Если ветка case null отсутствует, то switch с переданным в него null всегда будет выбрасывать NullPointerException (даже если есть ветка default):


Object obj = null;
switch (obj) { // NullPointerException
    case String s -> System.out.println("String: " + s);
    default -> System.out.println("Other");
}

Ветки null и default можно объединять друг с другом:


String str = …
switch (str) {
    case "Foo", "Bar" -> System.out.println("Foo or Bar");
    case null, default -> System.out.println("Null or other");
}


Новый паттерн-матчинг обладает рядом ограничений.


Во-первых, все switch (кроме тех, что были корректными до Java 21) должны быть исчерпывающими. Т.е. в ветках должны покрываться все возможные случаи:


Object obj = …
switch (obj) { // error: the switch statement does not cover all possible input values
    case String s -> System.out.println(s.length());
    case Integer i -> System.out.println(i);
};

Пример выше можно исправить, добавив ветку Object o или default.


Во-вторых, все ветки case должны располагаться в таком порядке, что ни перед одной веткой нет доминирующей ветки:


return switch (obj) {
    case CharSequence cs ->
        "sequence of length " + cs.length();
    case String s -> // error: this case label is dominated by a preceding case label
        "string of length " + s.length();
    default -> "other";
 };

Так как CharSequence это более широкий тип, чем String, то его ветка должна быть расположена ниже.

В-третьих, несколько паттернов в одной ветке работать не будут:

return switch (obj) {
    case String s, Integer i -> "string or integer"; // error: illegal fall-through from a pattern
    default -> "other";
 };

Т.е. сделать тест по нескольким типам в одной ветке пока что нельзя (хотя грамматика языка это позволяет). Это можно обойти, только включив режим preview и заменив s и i на символы подчёркивания (см. JEP про безымянные переменные ниже).

В целом новый паттерн-матчинг значительно увеличивает выразительность языка. Особенно хорошо он сочетается с записями. Паттерны записей мы рассмотрим отдельно, поскольку про них есть свой собственный JEP (см. следующий раздел).

Record Patterns (JEP 440)


Отдельным видом паттернов являются паттерны записей. Они появились в Java 19 в режиме preview и стали стабильными в Java 21.


Паттерны записей позволяют осуществлять деконструкцию значений записей чрезвычайно компактно:


record Point(int x, int y) {}

static void printSum(Object obj) {
    if (obj instanceof Point(int x, int y)) {
        System.out.println(x + y);
    }
}


Или через оператор switch:


static void printSum(Object obj) {
    switch (obj) {
        case Point(int x, int y) -> System.out.println(x + y);
        default -> System.out.println("Not a point");
    }
}


Особая мощь паттернов записей состоит в том, что они могут быть вложенными:


record Point(int x, int y) {}
enum Color { RED, GREEN, BLUE }
record ColoredPoint(Point p, Color c) {}
record Rectangle(ColoredPoint upperLeft, ColoredPoint lowerRight) {}

static void printColorOfUpperLeftPoint(Rectangle r) {
    if (r instanceof Rectangle(ColoredPoint(Point p, Color c), ColoredPoint lr)) {
        System.out.println(c);
    }
}


Используя var, можно сократить код ещё сильнее:


static void printColorOfUpperLeftPoint(Rectangle r) {
    if (r instanceof Rectangle(ColoredPoint(var p, var c), var lr)) {
        System.out.println(c);
    }
}


Паттерны записей отлично сочетаются с паттернами по типу:


record Box(Object obj) {}

static void test(Box box) {
    switch (box) {
        case Box(String s) -> System.out.println("string: " + s);
        case Box(Object o) -> System.out.println("other: " + o);
    }
}


Поддерживается вывод типов записей-дженериков:


record Box<T>(T t) {}

static void test(Box<Box<String>> box) {
    if (box instanceof Box(Box(var s))) { // Infers Box<Box<String>>(Box<String>(String s))
        System.out.println("String " + s);
    }
}


К сожалению, паттерны записей могут использоваться только в instanceof и switch, но не могут использоваться сами по себе:


static void usePoint(Point p) {
    Point(var x, var y) = p; // Не сработает
    // Use x and y
}


Будем надеяться, что когда-нибудь добавят и такую возможность.



String Templates (Preview) (JEP 430)


Строковые шаблоны – новая синтаксическая возможность, позволяющая встраивать в строки выражения:


int x = 10;
int y = 20;
String str = STR."\{x} plus \{y} equals \{x + y}";
// В str будет лежать "10 + 20 equals 30"

Таким образом, в Java появилась строковая интерполяция, которая уже давно есть во многих других известных языках программирования. Однако в Java она работает только в режиме preview, т.е. использовать в Java 21 её можно только с включенным флагом --enable-preview.


Реализация строковых шаблонов в Java отличается от большинства реализаций в других языках: в Java строковый шаблон на самом деле сначала превращается в объект java.lang.StringTemplate, а затем процессор, реализующий java.lang.StringTemplate.Processor, конвертирует этот объект в строку (или объект другого класса). В примере выше STR."…" есть ничто иное, как сокращённый вариант следующего кода:


StringTemplate template = RAW."\{x} plus \{y} equals \{x + y}";
String str = STR.process(template);

STR – это стандартный и наиболее часто используемый процессор, который выполняет простую подстановку значений в шаблон и возвращает сконкатенированную строку. STR неявно импортируется в любой исходный файл, поэтому его можно использовать без import.


RAW – это процессор, который ничего не делает со StringTemplate и просто возвращает его. Обычно он не используется, т.к. на практике мало кому нужны сырые представления шаблонов, а нужны результаты интерполяции в виде готовых объектов.


Процессоры были введены для того, чтобы была возможность кастомизировать процесс интерполяции. Например, ещё один стандартный процессор FMT поддерживает форматирование с использованием спецификаторов, определённых в java.util.Formatter:


double length = 46;
System.out.println(FMT."The length is %.2f\{length} cm");
// The length is 46.00 cm

Процессоры необязательно должны возвращать String. Вот общая сигнатура метода process() интерфейса Processor:


public interface Processor<R, E extends Throwable> {
    R process(StringTemplate stringTemplate) throws E;
}

Это значит, что можно реализовать процессор, который будет делать практически всё что угодно и возвращать что угодно. Например, гипотетический процессор JSON будет создавать напрямую объекты JSON (без промежуточного объекта String) и при этом поддерживать экранирование кавычек:


JSONObject doc = JSON."""
    {
        "name":    "\{name}",
        "phone":   "\{phone}",
        "address": "\{address}"
    };
    """;

Если в name, phone или address будут содержаться кавычки, то они не испортят объект, т.к. процессор заменит " на \".


Или, например, процессор SQL будет создавать PreparedStatement'ы, защищая от атак SQL Injection:


PreparedStatement ps = SQL."SELECT * FROM Person p WHERE p.name = \{name}";

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



Unnamed Patterns and Variables (Preview) (JEP 443)


Ещё одно новшество в режиме preview: теперь можно объявлять так называемые безымянные переменные и паттерны. Делается это с помощью символа подчеркивания (_). Это часто необходимо, когда переменная или паттерн не используются:


int acc = 0;
for (Order _ : orders) {
    if (acc < LIMIT) {
        … acc++ …
    }
}

В примере выше важен факт наличия элемента, но сама переменная не нужна. Поэтому, чтобы не придумывать этой переменной название, было использовано подчеркивание вместо имени.


Довольно частый пример нужности безымянных переменных – блок catch с неиспользуемым исключением:


String s = …
try {
    int i = Integer.parseInt(s);
    …
} catch (NumberFormatException _) {
    System.out.println("Bad number: " + s);
}

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


  • Локальная переменная в блоке,
  • Объявление ресурса в try-with-resources,
  • Заголовок for statement,
  • Заголовок улучшенного цикла for,
  • Исключение в блоке catch,
  • Параметр лямбда-выражения,
  • Переменная паттерна (см. ниже).

Внимательный читатель заметит, что в списке выше отсутствуют параметры методов. Действительно, они не могут быть безымянными, и для любых методов (как интерфейсов, так и классов) по-прежнему всегда нужно указывать имена параметров.

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

if (r instanceof ColoredPoint(Point(int x, int y), _)) {
    // Используются только x и y
}

Здесь разработчику понадобились только координаты точки, но не её цвет. Без безымянного паттерна ему пришлось бы объявлять неиспользуемую переменную типа Color и придумывать ей имя:


if (r instanceof ColoredPoint(Point(int x, int y), Color c)) { // Warning: unused c
    // Используются только x и y
}

Такой код менее читабелен и хуже позволяет сфокусироваться на главном (координатах). Кроме того, некоторые IDE подсветили бы неиспользуемую переменную c, что ещё одно дополнительное неудобство.


Есть также возможность объявлять безымянные переменные паттернов:


if (r instanceof ColoredPoint(Point(int x, int y), Color _)) {
    …
}

Безымянные паттерны и переменные паттернов прекрасно сочетаются и со switch:


switch (box) {
    case Box(RedBall _), Box(BlueBall _) -> processBox(box);
    case Box(GreenBall _)                -> stopProcessing();
    case Box(_)                          -> pickAnotherBox();
}

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



Unnamed Classes and Instance Main Methods (Preview) (JEP 445)


Теперь в режиме preview можно запускать программы с методами main(), которые не являются public static и у которых нет параметра String[] args:


class HelloWorld {
    void main() {
        System.out.println("Hello, World!");
    }
}

В таком случае JVM сама создаст экземпляр класса (у него должен быть не-private конструктор без параметров) и вызовет у него метод main().


Протокол запуска будет выбирать метод main() согласно следующему приоритету:


  1. static void main(String[] args)
  2. static void main()
  3. void main(String[] args)
  4. void main()

Кроме того, можно писать программы и без объявления класса вовсе:


String greeting = "Hello, World!";

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

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


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

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

Безымянный класс является синтетическим и final. Его simple name является пустой строкой:


void main() {
    System.out.println(getClass().isUnnamed()); // true
    System.out.println(getClass().isSynthetic()); // true
    System.out.println(getClass().getSimpleName()); // ""
    System.out.println(getClass().getCanonicalName()); // null
}

При этом имя класса совпадает с именем файла, но такое поведение не гарантируется.

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

API


Virtual Threads (JEP 444)


Виртуальные потоки, которые много лет разрабатывались в рамках проекта Loom и появились в Java 19 в режиме preview, теперь наконец-то стали стабильными.


Виртуальные потоки, в отличие от потоков операционной системы, являются легковесными и могут создаваться в огромном количестве (миллионы экземпляров). Это свойство должно значительно облегчить написание конкурентных программ, поскольку позволит применять простой подход «один запрос – один поток» (или «одна задача – один поток») и не прибегать к более сложным асинхронному или реактивному программированию. При этом миграция на виртуальные потоки уже существующего кода должна быть максимально простой, потому что виртуальные потоки являются экземплярами существующего класса java.lang.Thread и практически полностью совместимы с классическими потоками: поддерживают стек-трейсы, interrupt(), ThreadLocal и т.д.


Виртуальные потоки реализованы поверх обычных потоков и существуют только для JVM, но не для операционной системы (отсюда и название «виртуальные»). Поток, на котором в данный момент выполняется виртуальный поток, называется потоком-носителем. Если потоки платформы полагаются на планировщик операционной системы, то планировщиком для виртуальных потоков является ForkJoinPool. Когда виртуальный поток блокируется на некоторой блокирующей операции, то он размонтируется от своего потока-носителя, что позволяет потоку-носителю примонтировать другой виртуальный поток и продолжить работу. Такой режим работы и дешевизна виртуальных потоков позволяет им очень хорошо масштабироваться. Однако на данный момент есть два исключения: synchronized блоки и JNI. При их выполнении виртуальный поток не может быть размонтирован, поскольку он привязан к своему потоку-носителю. Такое ограничение может препятствовать масштабированию. Поэтому при желании максимально использовать потенциал виртуальных потоков рекомендуется избегать synchronized блоков и операции JNI, которые выполняются часто или занимают длительное время.


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


Для создания виртуальных потоков и работы с ними появилось следующее API:


  • Thread.Builder – билдер потоков. Например, виртуальный поток можно создать путём вызова Thread.ofVirtual().name("name").unstarted(runnable).
  • Thread.startVirtualThread(Runnable) – создаёт и сразу же запускает виртуальный поток.
  • Thread.isVirtual() – проверяет, является ли поток виртуальным.
  • Executors.newVirtualThreadPerTaskExecutor() – возвращает исполнитель, который создаёт новый виртуальный поток на каждую задачу.

Для виртуальных потоков также добавилась поддержка в инструментарии JDK (дебаггер, JVM TI, Java Flight Recorder).



Sequenced Collections (JEP 431)


Появились три новых интерфейса SequencedCollection, SequencedSet и SequencedMap.


SequencedCollection является наследником Collection и представляет собой коллекцию с установленным порядком элементов. Такими коллекциями являются LinkedHashSet и все реализации List, SortedSet и Deque. У этих коллекций есть общее свойство последовательности элементов, но до Java 21 их общим родителем был Collection, который является слишком общим интерфейсом и не содержит многих методов, характерных для последовательностей (getFirst(), getLast(), addFirst(), addLast(), reversed() и т.д). При этом у самих вышеописанных коллекций такие методы были несогласованны друг с другом (например, list.get(0) против sortedSet.first() против deque.getFirst()), либо вовсе отсутствовали (например, linkedHashSet.getLast()).


SequencedCollection закрыла эту дыру в иерархии и привела API к общему знаменателю:


interface SequencedCollection<E> extends Collection<E> {
    E getFirst();
    E getLast();
    void addFirst(E);
    void addLast(E);
    E removeFirst();
    E removeLast();
    SequencedCollection<E> reversed();
}

Теперь больше не надо думать, как для конкретной коллекции получить последний элемент, потому что есть универсальный метод getLast(), который есть и у ArrayList, и у TreeSet, и у ArrayDeque.


Особый интерес представляет метод reversed(), который возвращает view коллекции с обратным порядком. Это делает обратный обход коллекции гораздо более лаконичным:


var linkedList = new LinkedList<>(…);

// До Java 21
for (var it = linkedList.descendingIterator(); it.hasNext();) {
    var e = it.next();
    …
}

// С Java 21
for (var element : linkedList.reversed()) {
    …
}

Для LinkedHashSet эффективного способа обратного обхода и вовсе не было.


Для последовательных множеств ввели интерфейс SequencedSet:


interface SequencedSet<E> extends Set<E>, SequencedCollection<E> {
    SequencedSet<E> reversed();
}

Его реализациями являются LinkedHashSet и наследники SortedSet.


Также ввели интерфейс SequencedMap:


interface SequencedMap<K,V> extends Map<K,V> {
    Entry<K, V> firstEntry();
    Entry<K, V> lastEntry();
    Entry<K, V> pollFirstEntry();
    Entry<K, V> pollLastEntry();
    V putFirst(K, V);
    V putLast(K, V);
    SequencedSet<K> sequencedKeySet();
    SequencedCollection<V> sequencedValues();
    SequencedSet<Entry<K,V>> sequencedEntrySet();
    SequencedMap<K,V> reversed();
}

Его реализациями являются LinkedHashMap и наследники SortedMap.



Scoped Values (Preview) (JEP 446)


Scoped Values, которые появились в Java 20 в инкубационном статусе, теперь стали Preview API.


Новый класс 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 может быть единственно возможным решением.



Structured Concurrency (Preview) (JEP 453)


Ещё одно API, которое ранее было в инкубационном статусе (Java 19 и 20), а теперь стало Preview API – это Structured Concurrency.


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 forks
         .throwIfFailed();  // ... and propagate errors

    return new Response(user.get(), order.get());
}

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


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

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



Foreign Function & Memory API (Third Preview) (JEP 442)


Foreign Function & Memory API, ставшее preview в Java 19, продолжает находиться в этом статусе. API находится в пакете java.lang.foreign.


Напомним, что FFM API много лет разрабатывается в проекте Panama с целью заменить JNI. В Java 22 API выйдет из состояния preview.



Vector API (Sixth Incubator) (JEP 448)


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


Векторное API останется в инкубаторе, пока необходимые фичи проекта Valhalla не станут preview.



Key Encapsulation Mechanism API (JEP 452)


В пакете javax.crypto появилось новое API, реализующее механизм инкапсуляции ключей.


Механизм инкапсуляции ключей (KEM) – это современная криптографическая техника, позволяющая обмениваться симметричными ключами, используя асимметричное шифрование. Если в традиционной технике симметричный ключ генерируется случайным образом и шифруется с помощью открытого ключа (что требует паддинга), то в KEM симметричный ключ выводится из самого открытого ключа.


В Java KEM API состоит из трёх главных классов.


KEM – входная точка API. У него есть метод getInstance(), возвращающий объект KEM для указанного алгоритма.


Encapsulator – представляет собой функцию инкапсуляции, которая вызывается отправителем. У этого класса есть метод encapsulate(), который принимает открытый ключ и возвращает секретный ключ, а также key encapsulation message (которое шлётся принимающей стороне).


Decapsulator – функция декапсуляции, которая вызывается принимающей стороной. У класса есть метод decapsulate(), который принимает key encapsulation message и возвращает секретный ключ. Таким образом, у обеих сторон теперь есть одинаковый симметричный ключ, с помощью которого можно дальше обмениваться данными с помощью обычного симметричного шифрования.


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


// Receiver side
var kpg = KeyPairGenerator.getInstance("X25519");
var kp = kpg.generateKeyPair();

// Sender side
var kem1 = KEM.getInstance("DHKEM");
var sender = kem1.newEncapsulator(kp.getPublic());
var encapsulated = sender.encapsulate();
var k1 = encapsulated.key();

// Receiver side
var kem2 = KEM.getInstance("DHKEM");
var receiver = kem2.newDecapsulator(kp.getPrivate());
var k2 = receiver.decapsulate(encapsulated.encapsulation());

assert Arrays.equals(k1.getEncoded(), k2.getEncoded());


Для KEM также добавлен интерфейс KEMSpi, позволяющий предоставлять пользовательские реализации алгоритмов KEM.



JVM


Generational ZGC (JEP 439)


В сборщик мусора ZGC, который появился в Java 15, добавили поддержку поколений. Поколения в ZGC пока что отключены по умолчанию, и для их включения требуется ключ -XX:+ZGenerational:


java -XX:+UseZGC -XX:+ZGenerational ...

В будущих версиях Java режим работы с поколениями будет по умолчанию, и ключ -XX:+ZGenerational уже требоваться не будет.


Поколения в ZGC должны улучшить производительность Java-программ, т.к. молодые объекты, которые склонны умирать рано согласно слабой гипотезе о поколениях, будут собираться чаще, а старые объекты – более редко. При этом характеристики ZGC не должны от этого пострадать: время отклика по-прежнему должно быть сверхнизким (< 1ms) и кучи гигантских размеров (несколько терабайт) должны продолжать поддерживаться.


Напомним, что также ведётся работа над поддержкой поколений в другом сборщике мусора Shenandoah, похожем по характеристикам на ZGC. Однако в Java 21 Generational Shenandoah попасть не успел.


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



Prepare to Disallow the Dynamic Loading of Agents (JEP 451)


При динамической загрузке агентов теперь выдаётся предупреждение:


WARNING: A {Java,JVM TI} agent has been loaded dynamically (file:/u/bob/agent.jar)
WARNING: If a serviceability tool is in use, please run with -XX:+EnableDynamicAgentLoading to hide this warning
WARNING: If a serviceability tool is not in use, please run with -Djdk.instrument.traceUsage for more information
WARNING: Dynamic loading of agents will be disallowed by default in a future release

Агент – это компонент, который может изменять (инструментировать) код Java-приложения во время работы. Поддержка агентов появилась в Java 5, чтобы была возможность писать продвинутые инструменты вроде профилировщиков, которым необходимо добавлять эмиссию событий в классы, или AOP-библиотек. Для включения агентов требовались опции командной строки -javaagent или -agentlib, поэтому все агенты тогда могли включаться только явно при старте приложения.


Однако в Java 6 появился Attach API, который, кроме всего прочего, позволил загружать агенты динамически прямо в работающий JVM. Благодаря этому библиотеки получили возможность подключаться к приложению и по-тихому изменять классы, не имея на то согласия от владельца приложения. Причём изменяться могут не только классы приложения, но и классы JDK. Таким образом, подвергается риску строгая инкапсуляция, которая является одним из краеугольных камней Java.


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


Чтобы подавить предупреждение, необходимо запускать JVM с опцией -XX:+EnableDynamicAgentLoading, либо загружать агенты при старте JVM, явно перечисляя их с помощью опций -javaagent или -agentlib.


В будущих версиях Java планируется полностью отключить динамическую загрузку по умолчанию, и она уже не будет работать без -XX:+EnableDynamicAgentLoading.



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


32-битный порт OpenJDK под Windows стал deprecated for removal. В будущем планируется избавиться от него полностью.

Удаление порта позволит ускорить разработку платформы. Также причиной стало отсутствие нативной реализации виртуальных потоков на 32-битной версии JDK 21 под Windows: виртуальные потоки в этой версии реализованы через платформенные потоки.

Полный список JEP'ов, попавших в JDK 21, начиная с JDK 17: ссылка.

Комментарии (44)


  1. Ares_ekb
    19.09.2023 15:49
    +18

    Да, это жестко, волею судеб мы используем на одном проекте Java 11. Но часть зависимостей перешли на Java 17 и пришлось на OpenRewrite писать транслятор рекордов в классы, транслятор instanceof с pattern-матчингом в обычные instanceof с отдельным объявлением переменной, транслятор многострочных литералов в обычные литералы и т.д. Я узнал много всего интересного, например, что можно писать так:

    if (!(obj instanceof String str)) {
        throw new IllegalArgumentException();
    }
    System.out.println(str.length());

    А ещё так (это два разных str):

    if (!(obj instanceof String str)) {
        Integer str = null;
        System.out.println(str);
    } else {
        System.out.println(str);
    }

    Спорим, вы не догадаетесь просто глядя на код какие варианты корректные (в каких вариантах str доступен в if, а в каких в else):

    if (!(obj instanceof String str) || false) {
        System.out.println(obj);
    } else {
        System.out.println(str);
    }
    
    if (!(obj instanceof String str) || true) {
        System.out.println(obj);
    } else {
        System.out.println(str);
    }
    
    
    if (!(obj instanceof String str) && false) {
        System.out.println(obj);
    } else {
        System.out.println(str);
    }
    
    if (!(obj instanceof String str) && true) {
        System.out.println(obj);
    } else {
        System.out.println(str);
    }

    Если справились, то тут вообще без труда найдёте вариант с ошибкой:

    if (!(obj instanceof String str) && obj instanceof String str) {
        System.out.println(str);
    } else {
        System.out.println(obj);
    }
    
    if (obj instanceof String str || !(obj instanceof String str)) {
        System.out.println(obj);
    } else {
        System.out.println(str);
    }
    
    if (!(obj instanceof String str) || obj instanceof String str) {
        System.out.println(obj);
    } else {
        System.out.println(str);
    }

    Если это слишком просто для вас, то добавьте ещё условий, вложенных if'ов, с несколькими переменными и т.д. на свой вкус. Это как бы такая переменная Гейзенберга пока не скопируешь код в IDE нельзя быть точно уверенным какая у неё область видимости.

    Мне пришлось написать тестов больше чем само преобразование кода. Рекорды - это просто кайф.

    А теперь сбылась моя мечта и они соединили instanceof и record! Наконец-то! Я уже предчувствую сколько ещё увлекательной работы мне предстоит! Если вы разрабатываете какой-нибудь популярный фреймвок, то бросьте все дела и срочно переведите его на Java 21! Чтобы всякие ретрограды на Java 11 страдали.

    Это конечно всё ирония. Вообще интересно когда люди добавляли pattern-matching в instanceof рассматривали ли они все эти случаи. Понятно что так лучше не писать код, но это открытый вопрос нужно ли добавлять такие конструкции в язык, чтобы потом обвешивать их правилами линтера. И ещё интересный вопрос есть ли теоретическая возможность добавлять в язык новый синтаксический сахар, но чтобы код мог выполняться и на предыдущих версиях JVM...


    1. kemsky
      19.09.2023 15:49

      Да, это странно, реализация практически один-в-один совпадает с С#, но там таких пазлов нет. Вероятно у них какие-то свои соображения были, хотелось бы знать какие.


    1. slonopotamus
      19.09.2023 15:49
      +3

      волею судеб мы используем на одном проекте Java 11

      Что мешает обновиться?


      1. Xobotun
        19.09.2023 15:49
        +4

        Что угодно, полагаю. Начиная от менеджмента и отсутствия подходящих инструментов у девопсов, заканчивая Jigsaw и несовместимыми с новой версией джавы зависимостями.


        1. shybovycha
          19.09.2023 15:49
          +2

          а вот интересно, что вы имеете в виду под "инструментами девопсов"? и как они привязаны к java 11?


          1. gsaw
            19.09.2023 15:49
            +2

            У нас билд сервис, глобальный для всех команд, который поддерживают люди из разных команд на пол ставки так сказать. И билд сервис поддерживает только java 17. Пока они не добавят 21, все будут вынуждены использовать java 17 макс. А когда они это сделают, зависит от менеджмента, от занятости в своих проектах и тд.


        1. neJS69T4urCFe3gXW6Er
          19.09.2023 15:49
          +7

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


          1. Ares_ekb
            19.09.2023 15:49
            +7

            Менеджменту без разницы какая Java и какие фреймвоки используются, это техническое решение. Есть такая задача: нужно написать приложение, при этом у заказчика Java 11 и с этим сложно что-то сделать и ещё есть фреймвок, который упростит разработку приложения в сто раз, но он на Java 17. Написать транслятор кода - это самое простое решение на мой взгляд в данной ситуации. Они не на столько сложные, если интересно:

            Причем первые три я закоммитил в репозиторий OpenRewrite и частично переложил на сообщество ответственность по их сопровождению :) Транслятор рекордов они к сожалению не взяли, потому что у них позиция что они наоборот переводят код на новые версии Java, а даунгрейд рекордов в классы не соответствует целям их проекта. Хотя для меня это спорный вопрос, я думаю, что могут быть разные причины для преобразования рекорда в класс. Хотя, блин, сейчас задумался какие, в принципе для рекордов можно реализовать кастомные геттеры, equals(), toString() и т.д. если нужно. Наверное основная причина - это если понадобилось сделать сущность мутабельной и возможно это не на столько частый сценарий.


      1. Ares_ekb
        19.09.2023 15:49
        +3

        Что мешает обновиться?

        Это приложение с повышенными требованиями к безопасности. У заказчика кастомная Java, из которой выпилены вещи типа изменения уровня доступа к методам с private на public через рефлексию и т.д. Вносить все эти изменения в JDK долго и сложно и технически, и организационно. Реально на порядок проще просто форкнуть проекты на Java 17 и написать транслятор кода на Java 11. С Java 21 это будет сложнее, но я надеюсь, что люди не побегут сразу переписывать весь код с использованием новых фич языка. Когда внутренний проект переводится на новую Java, то проблем вообще нет. Но я не понимаю зачем спешить с обновлением Java в фреймвоках, которые используются в куче проектов.

        Самая жесть, что эти вещи с рефлексией зачем-то используют в достаточно популярных фреймвоках типа Spring Boot. Что очень затрудняет их применение в проектах с высокими требованиями к безопасности.

        К тому же Java 11 не на столько древняя. Ей всего лишь 5 лет и поддержка ещё не закончилась.

        В общем если вы думаете, что мешает обновиться лень или какие-то надуманные внутренние организационные причины, то точно нет. Например, недавно обновил на фронте React до 18-ой версии, хотя в части зависимостей используется React 17. Там конечно были танцы с бубнами, но всё получилось.

        Если в целом говорить об обратной совместимости, то мне конечно больше всего нравится Lisp. Потому что в нём вообще толком нет синтаксиса. Там не нужно 10 лет ждать когда в языке наконец появится какой-то синтаксический сахар. Если он нужен, то можно просто самому его написать. Или наоборот если в каком-то фреймвоке этот синтаксический сахар используется, то чтобы использовать этот фреймвок у себя не нужно обновлять версию компилятора или виртуальной машины. Короче в Lisp просто в принципе проблемы с этим отсутствуют.

        И на втором месте после Lisp - это JavaScript. Идея с полифилами просто отличная. Если какая-то фича в языке нужна уже сейчас, то ненужно ждать её релиза. И, наоборот, если эта фича уже есть в языке, но не поддерживается в браузерах у части клиентов, то тоже проблем нет. И это вменяемый нормальный подход. Для Java мне фактически приходится сейчас писать те же полифилы на OpenRewrite. Если бы их написали авторы этого синтаксического сахара, то всё было бы проще.


        1. PrinceKorwin
          19.09.2023 15:49
          +10

          Самая жесть, что эти вещи с рефлексией зачем-то используют в достаточно популярных фреймвоках типа Spring Boot

          Ну это просто. Рефлексия и кодогенерация позволяет очень сильно упростить логику взаимодействия внешнего кода с фреймворком.

          Кстати. Именно такие фреймворки и сподвигли сделать оптимизации в JVM благодаря чему мы теперь имеем быструю рефлексию, serialisation/deserialisation, dyn-функции и т.д.


        1. slonopotamus
          19.09.2023 15:49

          Ужос какой.


    1. buldo
      19.09.2023 15:49

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

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


      1. Ares_ekb
        19.09.2023 15:49

        Было бы очень круто, если бы код на Java 21 можно было скомпилировать таким образом, чтобы он запускался на Java 11. С одной стороны, возможно это какие-то дополнительные сложности для разработчиков языка. Но в JavaScript это ведь получилось через полифилы. Давно не писал под .NET, но если я правильно помню там тоже с этим проблем нет, можно выбрать предыдущую версию целевого фреймворка.


        1. Akela_wolf
          19.09.2023 15:49
          +7

          В Java это целая история т.к. стандартная библиотека прибита к JRE. И компилировать код 17-ой версии для запуска со стандартной библиотекой 11-ой версии - можно, конечно. Но это нужно будет делать очень аккуратно (и не всегда получится, если код требует фишек JVM 17-ой версии)

          В этом плане мне больше нравится Котлин, который до сих пор умеет компиляцию в байткод JVM 8 (а стандартная библиотека у него своя). То есть можно писать на последней версии Котлина, используя весь доступный там синтаксический сахар, а затем завернуть в докер-образ на базе JRE 8 и отдать на стенды.


  1. scroogemcfawk
    19.09.2023 15:49
    +1

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


    1. vooft
      19.09.2023 15:49
      +2

      Вряд ли, в джаве многие вещи пытаются сделать правильно с математической точки зрения, в то время как в Котлине подход более прагматический. Бреслав как-то рассказывал, что в джаве дженерики у методов идут перед названием метода (Collections.<String>emptyList()), потому что это правильно, а в Котлине они идут после, потому что это естественее (listOf<String>()) .


      1. Djaler
        19.09.2023 15:49
        +27

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

        foo(bar<A, B>(x+1))

        Кажется, что тут вызов функции bar с двумя типовыми аргументами и одним обычным.
        Но можно чуть-чуть поменять форматирование, чтоб стало понятно, что интерпретировать это можно и иначе:

        foo(bar < A, B > (x+1))

        Тут уже никакой функции bar нет, а просто в функцию foo передаются два аргумента.

        В джаве решили эту проблему, переместив дженерики в начало. И это не консистентно с дженериками у конструкторов.
        А в котлине сказали "Нам пофиг, интерпретируем это как вызов функции с типовыми аргументами. Хотите чтоб интерпретировалось иначе - поставьте скобки"

        Тут про это рассказано


        1. MiraclePtr
          19.09.2023 15:49
          +4

          В джаве решили эту проблему, переместив дженерики в начало.

          Ха, в С++ они тоже в конце, и там эту проблему решили так, что если компилятор видит неоднозначность в синтаксисе, то он просит явно добавить слово template перед фигурными скобками и именем типа.


    1. KReal
      19.09.2023 15:49
      +4

      Я бы сказал к C# с точностью до особенностей синтаксиса)


  1. Wan-Derer
    19.09.2023 15:49
    +6

    С трудом могу себе представить зачем нужен pattern matching по типу. А вот чего мне не хватает, так это case с условиями, типа:

    int n = ....;
    switch (){
        case n < 6 -> ....;
        case n > 7 && n < 10 -> ....;
        case n in 22..65 -> ....;
    }


    1. BugM
      19.09.2023 15:49
      +9

      В типобезопасных системах.

      Можно и ограничить наследников и перебрать их в switch. И этот код будет точно работать так как ожидалось или упадёт при компиляции. Джава стала еще на шаг ближе к хорошим языкам.


      1. alhimik45
        19.09.2023 15:49
        +4

        Эх, когда же discriminated unions уже завезут в Java/C#. Всё какие-то полумеры


        1. BugM
          19.09.2023 15:49

          Я бы JEP с ними почитал. Мне даже не особо понятно как их корректно и красиво в джава синтаксисе выразить и не сломать при этом весь уже написанный код.


        1. KReal
          19.09.2023 15:49
          +2

          В C# мне ОЧЕНЬ зашёл пакет OneOf, почитайте про него.


          1. alhimik45
            19.09.2023 15:49
            +1

            Да, я его юзаю во внутренней логике, но всё же его использование выглядит громоздко, AutoMapper не смапит его автоматически, из API OneOf<> не вернешь без приседаний и спеку swagger нативно для него не сгенерирует.


            Будь DU нативно в языке, тулинг бы подтянулся, в отличие от библиотеки о которой не слышали 95% разработчиков


            1. KReal
              19.09.2023 15:49

              Что касается API — Swagger и DU не подхватит, наверное (на крайняк это будет огромный JSON со всеми возможными полями и собственно дискриминаторами). Но оно у нас хорошо зашло в сервисном слое — от бизнес-логики приходит OneOf, а контроллер его уже раскладывает по разным Result'ам. Довольно наглядно получается.


              1. alhimik45
                19.09.2023 15:49
                +1

                Вообще в спеке есть one-of.
                Правда обычно хотя бы один из OneOf это некоторая ошибка, а её не только как body вернуть надо, но и statusCode выставить нужный, так что пожалуй действительно от ручного маппинга до конца не получится избавиться.


        1. Akela_wolf
          19.09.2023 15:49
          +7

          А чем вас sealed class + record + pattern matching в качестве этих самых discriminated union не устраивают?

          public class Main {
          
            sealed interface Data permits SuccessData, ErrorData {}
            record SuccessData(String result) implements Data {}
            record ErrorData(int errorCode) implements Data {}
          
            public static void main(String[] args) {
              final String message = switch(getData()) {
                case SuccessData s -> s.result;
                case ErrorData e -> String.valueOf(e.errorCode);
              };
          
              System.out.println(message);
            }
          
            private static Data getData() {
          //    return new SuccessData("TEST");
              return new ErrorData(404);
            }
          
          }

          Код написал для Java 17, так как она под рукой, в более новой версии будет еще красивее, т.к. можно деконструировать record сразу в pattern matching-е.


          1. alhimik45
            19.09.2023 15:49
            +1

            Я прост шарпист, за джавой не особо слежу :) Ключевое в вашем примере насколько я понял это sealed interface Data permits SuccessData, ErrorData, благодаря которым компилятор может проверить switch на полноту покрытия, чего не происходит в C#. Тогда действительно это вполне можно юзать как DU


            1. Akela_wolf
              19.09.2023 15:49

              Да, именно так.


        1. Alekfov
          19.09.2023 15:49
          -1

          А почему нельзя рядом с основным С# кодом, создать F# проект. И в нем создать типы, а в C# использовать.

          Как-то я ничего не смог нагуглить про такой трюк, а в официальном дискорде по C#, мой вопрос то ли пропустили, то ли проигнорировали.


          1. mayorovp
            19.09.2023 15:49

            Потому что для работы таких фич как паттерн-матчинг или проверка полноты в switch недостаточно импортировать типы, нужна ещё и их поддержка со стороны компилятора. А без "сопутствующих" фич discriminated unions в языке нафиг не нужны.


            1. Alekfov
              19.09.2023 15:49

              Хорошо, нечто более продвинутое, увы не работает.
              Но как минимум можно создавать всякие CustomerId которым нельзя присвоить просто int или OrderId. Уж это-то компилятор шарпа не даст перепутать.
              Я собственно из-за это фичи и спрашивал изначально. Или такое улучшение того не стоит в масштабах проекта?


              1. alhimik45
                19.09.2023 15:49

                А зачем для этого F#, если можно просто record struct в С# объявлять?


                1. Alekfov
                  19.09.2023 15:49

                  Изначально кажется что record struct сложный тип, чрезмерный для одного значения. Или иными словами даже в голову не пришло.
                  Но учитывая то как доступен F# тип в C#, особой разницы уже не видно.
                  Нет в мире идеала.


              1. mayorovp
                19.09.2023 15:49

                Cделать тип CustomerId можно и на C#, это не так сложно (пусть и сложнее чем newtype в F#). Только вот дальше придётся:


                1. объяснить (де)сериализатору как его принимать и передавать;
                2. объяснить сваггеру как его публиковать в API;
                3. объяснить EF как его хранить в базе;
                4. и так с каждой интеграцией.

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


                И даже если все пункты будут выполнены — про связь между CustomerId и Customer тот же EF знать не будет, а значит возможность ошибки будет оставаться.


                В итоге, такое улучшение того не стоит в масштабах проекта.


                1. Alekfov
                  19.09.2023 15:49
                  +1

                  Из вне и в базе будет long.
                  То есть контроллер принимает модель.
                  Модель валидируется.
                  Мапится на модель/тип на прямую.
                  Некие действия/хождения по логике.
                  EF:
                  modelBuilder.Entity<TestEFInterop>() .Property(x => x.Id) .HasConversion<long>();
                  Клиенту возвращается специфичная DTO.
                  Итого пара строчек для EF на каждое такое поле, и в маппере с/на каждую связанную DTO.
                  Cериализатора и сваггера не касается.
                  Да местами чуть больше возни, но и больше гарантий что что-то не то не улетит не туда.


                  1. mayorovp
                    19.09.2023 15:49

                    Это полумера. Вы никак не гарантируете, что у сущности Customer будет первичный ключ CustomerId, а у других сущностей будет другой ключ. Да и соответствие типа внешнего ключа первичному можно проверить только при запуске, но не при компиляции.


                    Cериализатора и сваггера не касается.

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


                    1. Alekfov
                      19.09.2023 15:49
                      +3

                      Касательно "что у сущности Customer будет первичный ключ CustomerId".
                      Предполагаю что изначальная ошибка в бойлерплейте существенно менее вероятна и будет отловлена раньше чем ошибка в логике.
                      К тому же это все пишется однократно, а в логике используются/переписывается много кратно, с большим количеством разнообразного контекста в голове.
                      Внешний ключ это что? Вне системы есть только int/long и.т.д. . Дальше маппер конвертирует в правильный тип. А ошибка в конвертации - начало это комментария. Неверное использования API, вообще другого рода проблема.
                      Глобальная мысль в том что добавляются правила в которых можно ошибиться, но вероятность ниже чем ошибка без этих правил.


                    1. kemsky
                      19.09.2023 15:49

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


    1. Xobotun
      19.09.2023 15:49
      +3

      Там в первых примерах был when. Надеюсь, это оно, и что там может быть что угодно, приводимое к boolean'у.

      ```

      Object obj = …
      return switch (obj) {
      case Integer i when i > 0 -> String.format("positive int %d", i);
      case Integer i -> String.format("int %d", i);
      case String s -> String.format("String %s", s);
      default -> obj.toString();
      };

      ```

      UPD: что-то не могу копипасту из статьи в маркдаун с телефона облечь...


      1. Wan-Derer
        19.09.2023 15:49
        +2

        Как бы да, но что-то громоздко как-то получается. Приходится мешать одно с другим и лёгкий прозрачный синтаксис switch-case куда-то исчезает...


    1. buldo
      19.09.2023 15:49
      +5

      Значит вам повезло. Периодически встречал паттерн "метод принимает аргуметр базового типа, но в завистимости от типа надо дёрнуть у аргумента разные методы или сделать другие разные действия"


  1. vvk78
    19.09.2023 15:49
    +1

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

    Это по-моему только к InheritableThreadLocal относится.