Вступ до Project Loom. Частина 1: Virtual Threads

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті.

Мене звуть Денис Макогон, я — Principal Java Developer та Java Advocate у команді Java Developer Relationships в компанії Oracle. Сьогодні я хочу поділитися першою статтею з циклу про OpenJDK Project Loom, а також другою статтею з циклу статтей, що передують релізу OpenJDK 19 цього вересня.

Вступ

До релізу JDK 19 залишилося не так вже й багато часу, лише 3 місяці. А це означає, що зараз саме час розібрати окремий функціонал, який буде доступний розробникам. Цього разу я хочу приділити увагу новому функціоналу, що є частиною великого проєкту Project Loom. Йдеться про JEP 425: Virtual Threads (Preview).

Platform threads

З давніх пір наявність фреймворку для розробки багатопотокових додатків є невід’ємною вимогою до високорівневих мов програмування. У цьому контексті Java не є винятком.

Модель реалізації потоків у Java базується на відповідності потоків JVM до системних, тобто наказуючи JVM створити потік типу:

Thread.ofPlatform().name("platform-thread").start(() -> {
        try {
            Thread.sleep(Duration.ofSeconds(5));
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    });

ви насправді інструктуєте JVM створити для вас системний потік (POSIX thread, або WinNT thread).

Так було дуже довгий час. Мережеві додатки, які реалізують модель типу thread-per-request стикалися з тим, що єдиним шляхом збільшити пропускну здатність було горизонтальне масштабування (створенням ще більшої кількості екземплярів JVM). Справа в тому, що масштабованість регулюється законом Літтла, який визначає, що для заданої тривалості обробки, кількість запитів, які програма обробляє одночасно, повинна зростати пропорційно швидкості надходження запитів на обробку. Тобто є верхня межа, досягнувши якої, вже не можливо приймати ще більше запитів на обробку.

Наприклад, припустимо, що мережевий додаток із середньою затримкою 50мс досягає пропускної здатності 200 запитів на секунду, обробляючи 10 запитів одночасно. Щоб цей додаток масштабувався до пропускної здатності 2000 запитів на секунду, їй потрібно буде обробляти 100 запитів одночасно. Якщо кожен запит обробляється в потоці протягом усього часу запиту, то щоб додаток справлявся із навантаженням, кількість потоків має збільшуватися зі зростанням пропускної здатності.

На жаль, кількість доступних системних потоків, виділених для окремого процесу, є обмеженою, фактичне значення для процессів прописано у конфігурації операційної системи. Враховуючи обмеженість кількості потоків для процесу, підхід thread-per-request із використанням системних потоків є неефективною моделлю. Якщо кожен запит займатиме весь потік протягом усього часу, то кількість потоків часто стає обмежувальним фактором задовго до того, як інші ресурси вичерпаються. Наприклад, кількість потоків, доступних JVM за замовчуванням, становить:

$ sysctl -a | grep num_task
kern.num_tasks: 4096
kern.num_taskthreads: 4096

Це означає, що JVM зможе вичерпати доступні 4096 потоків задовго до того, як в неї закінчиться пам’ять. Але ж в реальності кількість потоків, доступних для використання безпосередньо клієнтським кодом зсередини JVM менша, тобто потоки закінчаться ще швидше, бо JVM резервує за собою певну кількість потоків під Garbage Collector, ForkJoinPool::commonPool і так далі.

Хоч це значення і немале, але реалізація потоків у JDK обмежує пропускну здатність додатків до рівня значно нижчого, ніж може підтримувати апаратне забезпечення сучасних серверів. Це відбувається навіть тоді, коли потоки об’єднані в пул (типу Executors::cachedThreadPool), оскільки об’єднання в пул допомагає уникнути великих витрат на створення та запуск нового потоку, що робить роботу багатопотокових додатків ефективнішою, але не збільшує загальну кількість потоків.

Virtual threads

Проблема моделі thread-per-request

Багато розробників бажають використовувати апаратні можливості серверного устаткування повною мірою. Це означає відмовитися від стилю thread-per-request на користь спільного використання потоків.

Замість того, щоб обробляти запит в одному потоці від початку до кінця, підсистема розподілення потоків по запитах поверне потік до пулу у той час, коли він очікує завершення операції типу I/O, для того, щоб потік міг обслуговувати інші запити. Підхід такого типу дозволяє виконувати велику кількість одночасних операцій без споживання великої кількості потоків.

Зміна підходу

Щоб застосунки могли масштабуватися ефективно, використовуючи всі можливості серверів, при цьому зберігати підхід типу thread-per-request, є вимога реалізувати потоки у такий спосіб, щоб їх реально стало більше, незважаючи на обмежену кількість системних потоків. Так виходить, що операційні системи не можуть реалізувати потоки більш ефективно. Проте JVM може реалізовані потоки таким чином, щоб розірвати пряме співвідношення Java-потоків від системних.

Подібно до того, як операційні системи створюють ілюзію великої кількості пам’яті, створюючи великий віртуальний адресний простір з обмеженою кількістю фізичної оперативної, середовище виконання Java може створити ілюзію великої кількості потоків, створюючи велику кількість віртуальних потоків з обмеженою кількістю потоків, виділених під окремий процес JVM.

Впровадження віртуальних потоків

Отже, так з’явився новий тип Java-потоків — Virtual Thread. На відміну від класичних потоків, віртуальні вже не мають прямого співвідношення до системних потоків. А також їхня кількість більше не залежить від кількості доступних системних потоків, а лише від наявної пам’яті, якою оперує JVM.

Дуже важливим є те, що віртуальний потік є екземпляр java.lang.Thread, тобто це звичайнісінький потік, до якого всі звикли, але він не прив’язаний до певного системного потоку. Ще важливо зазначити те, що потік у тому розумінні та реалізації, який існував до впровадження віртуальних потоків, нікуди не подівся. Навпаки, впровадження віртуальних потоків дає змогу вибирати як ви хочете реалізувати багатопоточність у ваших додатках, бо реальне призначення двох реалізацій потоків відрізняється одне від одного таким чином, що віртуальні потоки створені для I/O-задач, а от звичайні потоки більше підходять для важких задач, де загалом використовується лише CPU.

Оскільки створення віртуальних потоків є дешевшим за створення звичайного і їхня кількість обмежена лише пам’яттю, їх ніколи не слід об’єднувати у пули (кешуючі, фіксовані) бо це не дає ніякої переваги, на відміну від потоків системних, де кешуючі та фіксовані пули мають найбільну перевагу виходячи з того, що створення классичних потоків є дуже дорогим і краще всього робити це на старті JVM.

Треба запам’ятати, що кожен новий віртуальний потік має бути створений під конкретну задачу. Такий підхід призведе до того, що більшість віртуальних потоків будуть існувати відносно невеликий проміжок часу (у рамках та контексті додатку) і матимуть не глибокий стек викликів при умові виконання лише обмеженого контексту завдань, типу виконання HTTP-запиту або запиту до БД через JDBC. А от звичайні потоки, навпаки, важкі та дорогі, бо вимагають створення об’єктів за JVM. Тому часто їх треба об’єднувати у пули задля того, щоб уникнути навантаження на створення самих потоків і лише витрачати час та ресурси на призначення задачі на виконання у потоці.

Базова технологія

У якості базової технології роботи з віртуальними потоками виступає ForkJoinPool типу work-stealing, у режимі First-In-First-Out. При старті JVM, процес ініціалізації ForkJoinPool для віртуальних потоків дуже схожий на процес створення ForkJoinPool::commonPool, фактор паралельності якого теж залежить від кількості наявних CPU, проте фактичне значення можна налаштувати за допомогою системної властивості jdk.virtualThreadScheduler.parallelism.

Варто зауважити, що ForkJoinPool для віртуальних потоків відрізняється від ForkJoinPool::commonPool типом і режимом із потоками, а саме — LIFO.

Класичний потік, який належить до ForkJoinPoolі на який призначений віртуальний потік, називається «carrier thread». Цікаво те, що віртуальний поток взагалі не залежить від свого потоку-носія і може виконуватися на довільному системному треді, який належить до ForkJoinPool. Це означає, що віртуальний потік взагалі нічого не знає про свого носія, бо стеки потоків розділені між собою.

Практичне застосування віртуальних потоків

Разом із впровадженням віртуальних потоків, стандартний набір Threads API був розширений відповідними методами для роботи з новим типом потоків. Наприклад, для створення віртуального потоку тепер існує ось така конструкція:

Thread.startVirtual(
        () -> System.out.println(Thread.currentThread())
    );

Варто відзначити, що це не єдиний спосіб створити потік, бо ще є новий ExecutorService:

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    executor.submit(() -> {
        Thread.sleep(Duration.ofSeconds(1));
    });
}

З впровадженням нового типу потоків з’явилася необхідність якось відрізняти потоки за назвами, тобто впровадити відповідну термінологію:

  • platform thread — той тип потоків, який є обгорткою надсистемних потоків;
  • virtual thread — новий тип потоків.

Відповідно до цих назв були введені нові типи білдерів для створення фабрик потоків:

public static Builder.OfPlatform ofPlatform();

та

public static Builder.OfVirtual ofVirtual();

Тобто щоб створити, наприклад, новий ExecutorService певного типу, можна використовувати відповідну конструкцію:

Executors.newThreadPerTaskExecutor(
        Thread.ofVirtual().factory()
    );

або

Executors.newThreadPerTaskExecutor(
        Thread.ofPlatform().factory()
    );

Окрім стандартних методів типу Executors::newVirtualThreadPerTaskExecutor, є можливість використовувати фабрику, відмінну від тієї, яка використовується у стандартній бібліотеці. Тобто можна зробити свою кастомну реалізацію ExecutorService для віртуальних або платформених потоків замість:

 @PreviewFeature(feature = PreviewFeature.Feature.VIRTUAL_THREADS)
    public static ExecutorService newVirtualThreadPerTaskExecutor() {
        ThreadFactory factory = Thread.ofVirtual().factory();
        return newThreadPerTaskExecutor(factory);
    }

використати:

Executors.newThreadPerTaskExecutor(
      Thread.ofVirtual()
      .name("virtual-", 0)
      .allowSetThreadLocals(false)
      .factory()
);

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

Дуже цікавим є той факт, що код, який реалізує блокуючі I/O операції за замовчанням, стане ефективнішим, якщо його запустити у віртуальному треді:

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.Collections;
import java.util.concurrent.*;
import java.util.stream.Collectors;


public class LoomClient {

    public static void main(String...args) throws Exception {
        var tasksNo = 500_000;
        var request = HttpRequest.newBuilder()
                .version(HttpClient.Version.HTTP_1_1)
                .GET();
        var client = HttpClient.newHttpClient();
        
        Thread.startVirtual(() -> {
            var currThread = Thread.currentThread();
            System.out.println(currThread.threadId());
            var resp = client.send(
                    request.uri(new URI(String.format(
                                    "http://localhost:8080/get?virtualThread=%s&counter=%s",
                                    currThread.isVirtual(), currThread.getName())))
                            .build(),
                    HttpResponse.BodyHandlers.ofString()
            );
            return resp.statusCode();
        });
    }
}

Це означає, що певні API-методи типу:

public abstract <T> CompletableFuture<HttpResponse<T>> sendAsync(
            HttpRequest request, BodyHandler<T> responseBodyHandler);

взагалі стануть непотрібними, бо їхня реалізація поки що базується на звичайних потоках.

Цікавий експеримент

Найголовніше те, що віртуальний потік є стовідсотковим Java-об’єктом, тобто максимальна кількість таких потоків дуже залежить від наявної пам’яті. Тобто на відміну від системних потоків, кількість віртуальних є величиною несталою, бо у різні проміжки часу кількість потоків може змінюватися. Але можна уявити, що додаток майже нічого не робить і порахувати, скільки ж віртуальних потоків можна створити. Кількість можна визначити ось таким чином:

import java.time.Duration;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.atomic.AtomicInteger;

public class CalculateThreadMax {
    
    private static void runTest(Thread.Builder builder) {
        var counter = new AtomicInteger(0);
        try{
            while (true) {
                builder.start(() -> {
                    counter.incrementAndGet();
                    var threadID = Thread.currentThread().threadId();
                    if (threadID == 1_000_001L) {
                        System.out.println("No. of threads is already beyond 1M!");
                    }
                    if (threadID == 2_000_001L) {
                        System.out.println("No. of threads is already beyond 2M and 1! That's enough!");
                        System.exit(0);
                    }

                    try {
                        Thread.sleep(Duration.ofSeconds(threadID));
                    } catch (Exception e) {
                        System.out.println("MAX threads: " + (counter.get() - 1));
                        System.exit(0);
                    }
                });
            }
        } catch (OutOfMemoryError ex) {
            System.out.println("MAX threads: " + (counter.get() - 1));
            System.exit(0);
        }
    }

    public static void main(String...args) {
        runTest(Thread.ofVirtual());
        // runTest(Thread.ofPlatform());
    }
}

На моєму старенькому MacBook Pro 2018 16Gb RAM за короткий проміжок часу я зміг створити приблизно 2.2 мільйони віртуальних потоків менше ніж за кілька десятків секунд. У порівнянні з реальним значенням доступних системних потоків що становить приблизно 4073 потоків, то 2.2 мільйони виглядає як дуже непристойно велике значення.

От тут і криється одна із технічних проблем, яка досі не вирішена. Враховуючи те, скільки можна створювати віртуальних потоків, неминуче постає питання — а як справи із Thread Locals? До Thread Locals (сховища типу key-value, власником якого є потік) не було питань, коли реальна кількість потоків була у межах 10К за замовчанням. Але коли справа йде про мільйони потоків і кожен з них має своє таке сховище, то виникає питання, наскільки таке рішення підходить для віртуальних потоків. Відповіді на це питання ще нема, проте активна робота ведеться.

Висновки

Впровадження віртуальних потоків є революційним для усієї екосистеми Java, поза жодними сумнівами. Основною задачею, яка стояла перед розробниками, була сумісність між звичайними потоками, які вже на цей час існували два десятиліття. Безпосередня сумісність полягає ще у тому, что реалізація віртуальних потоків та відповідного ExecutorService надає змогу вже прямо зараз інтегрувати новий тип потоків до мережевих додатків, які надають змогу перевизначити механізм розподілення запитів по потоках.

Іншим важливим аспектом є маштабування віртуальних потоків, і як ви бачите, та можете впевнитися, є можливість перевершити кількість потоків у десятки тисяч разів у порівнянні до системних потоків. Але відкритим залишається питання щодо ефективності використання Thread Locals, враховуючи кількість потенційно запущених потоків одночасно, або у короткий проміжок часу.

Усі нові впроваждення, що стосуються Project Loom, можно вже використовувати, починаючу із JDK 19+26 Early Access Build:

sdk install java 19.ea.26-open

P.S.

Пам’ятайте про те, що Virtual Threads це нове Preview API. Для того, щоб працювати з цим API, треба:

  • Скомпілюйте програму за допомогою javac --release 19 --enable-preview Main.java та запустіть її за допомогою java --enable-preview Main; або,
  • Використовуючи програму запуску вихідного коду, запустіть програму з java --source 19 --enable-preview Main.java; або,
  • Використовуючи jshell, почніть його з jshell --enable-preview.

------

dev.java | inside.java | Java on YouTube | Java on Twitter

Сподобалась стаття? Натискай «Подобається» внизу. Це допоможе автору виграти подарунок у програмі #ПишуНаDOU

👍ПодобаєтьсяСподобалось10
До обраногоВ обраному1
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Дякую за статтю!
Для тих в кого питання що ж буде з Project Reactor та чи планує щось робити Spring, радив би подивитись доповідь „Project Loom — A Friend or Foe of Reactive?”:
— www.youtube.com/watch?v=YwG04UZP2a0 (EN)
— www.youtube.com/watch?v=tG6bSC1VKLg (RU)

Усі віртуальні потоки лягають на один системний потік, чи JVM все-таки створить декілька системних потоків і розподілить їх?

Кількість потоків-носіїв (carrier threads) у ForkJoinPool який відповідає за віртуальні треди за замовчанням дорівнює кількості наявних процессорів мінус один, але не менше одного. На мойому macbook pro 2018 їх кількість дорівнює трьом.

У статті ще зазначено, що кількість потоків-носіїв залежить від параметру JVM

jdk.virtualThreadScheduler.parallelism

Тобто ви можете зазначити свою власну кількість потоків тут, але треба розуміти, що найкраща продуктивність досягається тоді, коли виконуєтья умова типу «один поток на один CPU».

UPD: не OC відповідає за розподілення віртуальних потоків, а JVM.

Дякую за статтю. Давно стежу за project loom, дуже корисна штука, сподіваюсь вже в наступному LTS це стане stable фічею. Цікаво буде подивитись як різні ліби типу спрінга його завезуть в свої reactive та async апішки.

Я знаю, ще деякі фремворки вже працюють над впровадженням, і є навіть POC.

Враховуючи те, що стандартний інтерфейс потоків захований від розробників за абстракціями типу Futute, то перехід буде безболісним, питання, мабудь, лише в ThreadLocals — як багато фремворки використовують це сховище? Бо якщо створити ExecutorService в якому по-замовчуванню не буде ThreadLocals працювати на запис, то як сильно все зламається — питання відкрите.

Насправді на ThreadLocals багато чого зав’язано, MDC наприклад. Буде добре якщо зроблять якусь помилку компіляції якщо користувач намагається їх використовувати з віртуальними тредами, бо інакше буде дуже багато неочікуваних проблем. З цієї точки зору навіть не впевнений що зробити VirtualThread інстансом Thread то була гарна ідея. Можливо краще було б завести окремі інтерфейси.

У коді віще є фабрика яка створює потоки із іммутабельними ThreadLocals.

А взагалі у розробці зараз знаходиться альтернатива ThreadLocal — Extent Locals. Коли JEP дійде до статусу «кандидата», я напишу статтю про них.

Круть, буде цікаво почитати )

бо інакше буде дуже багато неочікуваних проблем

Не буде )
Судячи з апі, для розробника на рівні джава коду між платформенними і віртуальними тредами не буде жодної різниці.

ТредЛокали використовуються під капотом майже скрізь. Як приклад — транзакції JPA у спрінгу прив’язані до тредів саме через тред локали, @Transactional так само лізе в тред локали.

Але проблем не буде. Віртуальний тред, судячи з усього, має інший ід та хеш ніж платформенний тред, що тимчасово обсулговує віртуальний. А так як ТредЛокал це по суті тупо синхронізована

Map<Thread, ?>
 то і проблем не буде, тому що кожен тред, що віртуальний що платформений буде мати свої копії змінних. Я так розумію.
Звичайно я можу помилятися, бо не експериментував, але якщо помиляюсь, то це було б дуже контрінтуітивне рішення зі сторони розробників яке б прямо дуже сильно все ламало б. Але я дуже сумніваюсь.

Саме щоб уникнути оцієї плутанини з типізацією віртуальні треди і зробили на основі звичайних.

А так як ТредЛокал це по суті тупо синхронізована Map то і проблем не буде, тому що кожен тред, що віртуальний що платформений буде мати свої копії змінних

Так, все вірно! Але ж питання у тому, що кількість екземплярів ThreadLocals при використанні звичайних тредів обмежена фізичною кількістю ОС потоків на одни процес, а от із віртуальними тредами постане питання ефективності їх використання враховуючи майже необмежене масштабування віртуальних тредів (обмежених наявною пам’яттю).

Тобто, мережевий додаток стане «швидшим», але якщо сам додаток активно використовує ThreadLocals, то буде явне просідання по пам’яті яка є спільним ресурсом.

Не бачу жодної проблеми у цьому плані.

Якщо у коді все ок з очищенням ThreadLocal, тобто не забули зробити .remove(), то створюйте хоч мільярди віртуальних тредів — це питання стає еквівалентним "чи можна додавати багато об"єктів у хешмапу", що впирається тільки у математику хеш-функції, розмір хіпу і гарбаж коллектор.

А якщо забули зробити ThreadLocal.remove() то це звичайнісінький memory leak, і нічого нового в ньому немає, він буде так само актуальний і при використання платформених тредів.

Інше питання, що фактично не буде ніяких мільонів тредів що пишуть щось у ThreadLocal — сценарії використання багатопоточості не зміняться, ми все так самое будемо мати пули та runAsync() і тому подібні прості високорівневі речі, які велика частина розробників у повсякденній роботі використовують настільки рідко, що вже забули. Впровадження віртуальних тредів нічого не змінить — просто існуючий потоковий код стане ефективніший за рахунок того, що у сконфігурованих пулах просто подмінять фабрику на на віртуальну.

Легко завезуть.
API віртуальних тредів продумане добре, не буде проблем.

Було б добре, якщо б це було правдою, але явно відбудеться ревізія архітектури фрейморків.

Впевнений, що ні.
Перших хеловорлдних тестів я думаю буде достатньо, щоб вся ревізія звелась до підсовування Thread.Builder.OfVirtual.factory() у конструктори пулів, що використовуються фреймворком, на чому перехід на віртуальні треди і завершиться.

А якщо щось буде падати із таким підходом — то це вже буде питання не до фреймворків, а до JDK, тому що дизайном API вони заявили сумісність і можливість такого підходу. І після фіксів все знову ж таки повернеться до простої заміни фабрик в пулах.

В догонку до теми.

Сидів собі думав і прийшов до висновку, що у 99% випадків всі треди треба буде робити віртуальними, бо у повсякденних задачах майже не знайти задач що вимагають важких обчислень на цпу. Тобто фактично треба буде всі екзекьютори в конфігах аплікацій переводити на фабрику віртуальних тредів.

Там, де справді дуже потрібні треди платформи можу пригадати тільки JavaFX Application Thread, але він прибитий гвіздками, і його можна тільки використовувати, а не конфігурувати.

Ну я так зрозумів, що взагалі не буде сенсу юзати старі треди, мабуть якщо тільки важливе якесь лоу левел айпіай

Буде, у тих задачах де CPU дуже потрібен. Наприклад операції з SIMD. З усім нативним кодом де потрібна синхронізація.

синхронізація — маєте на увазі стандартий synchronize javовський ?

Якщо просто, то так. Є багато питань до нативних бібліотек, бо там дуже багато неявного використання паралельних потоків, і от такий підхід вимагає ще на стороні JVM теж певних блокувань у рантаймі.

нативні бібліотки це ті які юзаються в джаві через native ?
Чи це просто підкапотом JVM неявні потоки ?

Саме ті ліби які наразі працють через JNI, а вже скоро через Foreign Function & Memory API (dou.ua/forums/topic/38466). Під капотом у JVM все добре, PR який впровадив Virtual Threads був більше зачіпив більше ніж 11 тисяч фалів =)

PR який впровадив Virtual Threads був більше зачіпив більше ніж 11 тисяч фалів

Красіво ) Цікаво скільки коштував ревью цього ПРу ?)

Трохи менше року, на скільки я знаю.

А при чому тут synchronize до нативних бібліотек? Не бачу логіки. synchronize так само як і віртуальні треди існує лише не рівні джави, він не йде ні до яких блокувань на рівні ОС.

Synchronize не дозволить двом Java потокам одночасний доступ до непотокобезпечного нативного коду, є багато прикладів C/C++ API типу OpenCV::VideoCapture.

А що планується у наступних частинах? Structured concurrency? Locks?

Structured concurrency

Так.

Ще буде реалізація та деякі тести багатопотового HTTP серверу.
Та ще сподіваюся на Extent Locals (JEP ще у статусі драфта, але робота ведеться дуже активна;.

Спасибо за статью. Интересно будет попробовать на практике, наверно люди массово доберутся уже в LTS версии.

Досвід підказує мені, що тестування повинно починатися заздалегідь, так як це робить той же maven та gradle. Бо із новим LTS питання впровадження стане вже не у контексті тестування, а у контексті впровадження.

К сожалению есть компании которые еще на 11 не переехали. Думаю у них есть время потестировать фичи 11 версии даже после выхода LTS. Но для разработчиков конечно чем раньше изучать тем лучше

Дякую за статтю! З нетерпінням чекаю на наступні частини.

Дякую за статтю!

Відсутність коментарів якби символізує, що більшість джавістів не працює з багатопоточністю безпосередньо, стикаючись максимум із @Async :)

Та всі із нею працюють, але опосередковано, бо так набагато простіше, загалом усі мають справу із Future/CompletableFuture. Але точно мають справу із ExecutorService, наприкладі того ж вбудованого HTTP серверу, та сучасних фрейворків типу Helidon, Quarqus.

Підписатись на коментарі