Двадцать может показаться особым числом (по крайней мере, в десятичной области), своего рода вехой, имеющей особое значение, для этого релиза это лишь отчасти так. Хотя это и не долгосрочная поддержка, 20 очень важен, поскольку он настраивает все для следующего выпуска, номер 21, который будет LTS (выпуск с долгосрочной поддержкой) и, как таковой, первый LTS, который представляет потомков Project Loom Virtual Потоки, структурированный параллелизм и новая функция Scoped Values ​​в основной экосистеме Java.

Ограниченные значения

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

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

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

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

Локальные переменные потока, введенные в Java 2, были основным механизмом достижения описанного способа обмена данными, и по совпадению Java 20 предоставляет нам новый механизм, называемый Scoped Values, в основном предназначенный для хорошей работы с виртуальными потоками, которые теперь могут порождать миллионы зеленых потоков вместо несколько тысяч «тяжелых» потоков платформы (ОС).

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

class Handler {
  final static ThreadLocal<String> userInfo = new ThreadLocal<>();
  ...
}

После создания ваша переменная может быть установлена ​​и доступна произвольное количество раз, разные потоки будут устанавливать разные значения, и JVM гарантирует вам, что значение будет локальным и изолированным для каждого отдельного потока, что означает чтение значения позже в выполнении, которое вы гарантированно прочитать значение, которое установил *ваш* поток, и не сможет прочитать данные, установленные любым другим потоком.

Доступ к данным осуществляется через очень простой API:

userInfo.set("user-name");

....

Handler.userInfo.get();

Возможно, вы уже заметили первую потенциальную ловушку дизайна Thread Local, которая заключается в неизменяемости или, скорее, в ее отсутствии. В подавляющем большинстве случаев использования вы устанавливаете данные один раз и только читаете эти данные в контексте запроса, но это не может быть легко реализовано, что означает, что data.set("data") можно вызывать в любое время, в любом месте и любое количество раз. Это первое конструктивное отличие Scoped Values ​​— они делают данные неизменяемыми, устанавливаются только один раз и впоследствии доступны только для чтения.

Второй потенциальной ловушкой является время жизни данных, когда запрос не является кратковременным. Для длительных запросов, когда вы храните тяжелые/дорогие данные в хранилище ThreadLocal, вы можете захотеть освободить данные, как только они больше не понадобятся. API позволяет вам освобождать данные вручную, пока поток все еще работает, вызывая метод remove(). Проблема в том, чтобы знать, когда безопасно вызывать удаление, вы полностью уверены, что никто не вызовет data.get() после того, как вы его удалили? Если сегодня в вашей кодовой базе безопасно, будут ли все, кто возится с кодом в будущем, знать, что данные удаляются и после определенного момента получить их невозможно? Следовательно, несмотря на то, что функция удаления весьма полезна, она может быть подвержена ошибкам, и ее трудно сделать правильно.

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

Чтобы решить все упомянутые ловушки, Oracle представляет новую упрощенную систему обмена данными, которая делает данные неизменяемыми, поэтому они могут эффективно использоваться дочерними потоками.

Функция значений с ограниченной областью была напрямую вдохновлена ​​диалектами Лиспа, которые обеспечивают поддержку переменных с динамической областью видимости, поэтому ее синтаксис может немного отличаться от того, что многие ожидают в «традиционном» коде Java.

Определение Scoped Value почти такое же, как и для ThreadLocal:

final static ScopedValue<String> userInfo = new ScopedValue<>();

Но само использование не так просто, как вызов .get() и .set(...)

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

В начале обработчика запроса вы вызовете ScopedValue.where(...), представив значение в области действия и объект, к которому оно должно быть привязано. Вызов run(...) связывает значение области действия, предоставляя воплощение, характерное для текущего потока, а затем выполняет лямбда-выражение, переданное в качестве аргумента. В течение времени существования вызова run(...) лямбда-выражение или любой метод, вызываемый прямо или косвенно из этого выражения, может считывать значение области через метод get() значения. После завершения метода run(...) привязка уничтожается.

final static ScopedValue<...> V = new ScopedValue<>();

// In some method, most of the time at the beginning of the request handler
ScopedValue.where(V, <value>)
           .run(() -> { ... V.get() ... call methods ... });

....


// In a method called directly or indirectly from the lambda expression
... V.get() ...

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

Хотя Scoped Values ​​— это совершенно новая вещь, две следующие функции, которые мы обсудим, — это Second Preview of Record Patterns и Second Incubator of Structured Concurrency. Эти два были незначительно изменены по сравнению с их первым появлением в Java 19 несколько месяцев назад, поэтому содержание ниже в основном совпадает с моим прошлым обзором Java 19. Это означает, что если вы полностью знакомы с материалом, представленным в 19, вы не увидите ничего нового в следующих абзацах.

Виртуальные потоки

Мотивация добавления встроенной поддержки облегченных/виртуальных потоков не в том, чтобы отказаться от поддержки или заменить текущий традиционный Java Thread API. А скорее ввести эту мощную парадигму, которая значительно (по словам Oracle «значительно») сократит усилия по созданию высокомасштабных параллельных рабочих процессов на Java. Что-то, что было в других языках, таких как Go или Erlang, годами или десятилетиями.

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

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

Почему бы «просто» не использовать реактивное программирование для высокопроизводительных java-приложений? Ну, по мнению основной команды Java, реактивная парадигма не гармонирует с остальной частью платформы Java, и это не естественный способ написания программ на Java. Следовательно, внедрение виртуальных потоков, которые, согласно Oracle, идеально согласуются со всем, что в настоящее время существует в Java, и в будущем это должно стать подходом номер один при создании крупномасштабных программ в стиле потоков на запрос в Java.

Проще говоря, виртуальный поток не связан напрямую с конкретным потоком ОС, в то время как поток платформы представляет собой тонкую оболочку вокруг потока ОС. На практике это приводит к тому, что среда выполнения Java может создавать иллюзию большого количества потоков, отображая большое количество виртуальных потоков на небольшое количество реальных потоков ОС, а это означает, что код приложения в стиле поток на запрос может выполняться в виртуальном потоке. thread на протяжении всей длительности запроса, но виртуальный поток потребляет поток ОС только во время выполнения вычислений на CPU.

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

private static void virtualThreadsDemo(int numberOfThreads) {
  try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
        IntStream.range(0, numberOfThreads).forEach(i -> {
            executor.submit(() -> {
                Thread.sleep(Duration.ofMillis(1));
                return i;
            });
        });
  }
}

Очень простой бенчмаркинг на процессоре Intel (i5–6200U) показывает полсекунды (0,5 с) на создание 9000 потоков и всего пять секунд (5 с) на запуск и выполнение одного миллиона виртуальных потоков.

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

private static void platformThreadsDemo(int numberOfThreads) {
  try (var executor = Executors.newCachedThreadPool()) {
     IntStream.range(0, numberOfThreads).forEach(i -> {
         executor.submit(() -> {
             Thread.sleep(Duration.ofMillis(1));
             return i;
         });
     });
  }
}

Запуск 9000 потоков платформы на самом деле не показал большой разницы, время выполнения было таким же, но тест на миллион потоков занял одиннадцать секунд (11 с), что более чем в два раза превышает время по сравнению с виртуальными потоками.

Разумеется, бенчмаркинг в Java не может быть выполнен просто путем измерения затраченного времени из-за того, как работает Hot Spot VM, и из-за времени прогрева, необходимого для достижения оптимальных уровней компиляции перед бенчмаркингом.

Подводя итог, можно сказать, что виртуальные потоки — очень интересное дополнение к платформе Java, и кажется, что стремление Oracle к простоте, удобству использования и интероперабельности принесет свои плоды.

Структурированный параллелизм

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

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

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

try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    Future<User> mediumUser 
        = scope.fork(() -> getMediumUser());    
    Future<SubscriptionTier> subscriptionTier 
        = scope.fork(() -> getSubscriptionTier());    
    Future<UserInterests> userInterests 
        = scope.fork(() -> getUserInterests());

    scope.join();
    scope.throwIfFailed(IllegalArgumentException::new);
    return new Response(mediumUser.resultNow(),
                        subscriptionTier.resultNow(), 
                        userInterests.resultNow());
}

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

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

Флаги VM, необходимые для опробования Virtual Threads и Structure Concurrency:

--enable-preview --add-modules jdk.incubator.concurrent

Связывая вещи с вводным абзацем, редакция 20 определенно двигает иглу в правильном направлении при подготовке следующего выпуска, редакция 21 выйдет в конце 2023 года, которая будет иметь долгосрочную поддержку, для успеха и плавного перехода к внедрению долгожданных функций Project Loom в основная экосистема Java-кода, библиотек и фреймворков.

Захватывающие времена впереди.