JavaRush /Курси /JAVA 25 SELF /Використання Executor з віртуальними потоками

Використання Executor з віртуальними потоками

JAVA 25 SELF
Рівень 57 , Лекція 3
Відкрита

1. Коротко про головне

Ви вже трохи знайомі з класами з пакета java.util.concurrent, особливо з ExecutorService. Це такий «менеджер завдань»: ви надсилаєте йому роботу (наприклад, через submit()), а він сам вирішує, коли і яким потоком її виконати. Зазвичай під капотом працює пул потоків фіксованого розміру, який заощаджує ресурси та не створює новий потік для кожного завдання.

Проте з віртуальними потоками усе змінюється! Тепер можна дозволити собі розкіш: на кожне завдання — окремий потік, і при цьому не боятися, що JVM переповниться.

Новий спосіб: Executors.newVirtualThreadPerTaskExecutor()

У Java 21 з’явився новий спосіб створити ExecutorService, який запускає кожне завдання в окремому віртуальному потоці:

ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();

Важлива відмінність:

  • Старі пули потоків (Executors.newFixedThreadPool, Executors.newCachedThreadPool) обмежували кількість одночасних завдань через високу вартість потоків ОС.
  • Новий віртуальний Executor майже не обмежений: на кожне завдання — власний легковаговий віртуальний потік.

Простий приклад

Надішлемо 10 завдань у віртуальний Executor:

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class VirtualExecutorDemo {
    public static void main(String[] args) {
        ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();

        for (int i = 1; i <= 10; i++) {
            int taskId = i; // захоплюємо змінну для лямбда-виразу
            executor.submit(() -> {
                System.out.println("Task " + taskId + " is running in thread: " +
                        Thread.currentThread());
            });
        }

        executor.shutdown();
    }
}

Що відбувається?
Кожне завдання буде запущене у своєму віртуальному потоці, і ви побачите рядки на кшталт:

Task 1 is running in thread: VirtualThread[#24]/runnable@ForkJoinPool-1-worker-1
...

2. Масовий паралелізм: тисячі завдань — не проблема!

Щоб відчути всю міць віртуальних потоків, спробуймо надіслати в ExecutorService не 10, а, скажімо, 100_000 завдань. У класичних пулах це було б схоже на спробу запхнути слона в холодильник: JVM швидко вичерпала б пам’ять або почала б сильно гальмувати. З віртуальними потоками — усе інакше!

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class VirtualExecutorMassiveDemo {
    public static void main(String[] args) {
        ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();

        for (int i = 1; i <= 100_000; i++) {
            int taskId = i;
            executor.submit(() -> {
                // Для прикладу — просто спимо 1 мс
                try {
                    Thread.sleep(1);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
                // System.out.println("Task " + taskId + " done."); // Не друкуємо, інакше буде занадто багато рядків!
            });
        }

        executor.shutdown();
    }
}

Увага: виводити 100_000 рядків на екран — погана ідея: консоль «захлинеться» швидше, ніж віртуальні потоки. Краще або не писати в консоль, або виводити лише перші кілька завдань.

3. Як працює newVirtualThreadPerTaskExecutor

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

Архітектурно:

  • Віртуальні потоки «відображаються» на невеликий пул реальних (carrier) потоків ОС.
  • JVM сама вирішує, коли і який віртуальний потік запускати, призупиняти та відновлювати.
  • Якщо потік блокується (наприклад, на читанні файлу або під час очікування мережі), JVM може «заморозити» віртуальний потік і звільнити carrier-потік для інших завдань.

4. Приклад: обробка результатів із Future

ExecutorService повертає об’єкт типу Future, якщо завдання повертає результат. Усе працює так само, як і зі звичайними потоками:

import java.util.concurrent.*;

public class VirtualExecutorWithResult {
    public static void main(String[] args) throws InterruptedException, ExecutionException {
        ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();

        Future<String> future = executor.submit(() -> {
            Thread.sleep(500);
            return "Hello from virtual thread!";
        });

        System.out.println("Result: " + future.get()); // Чекаємо на результат

        executor.shutdown();
    }
}

Усе звично: можна надсилати завдання з поверненням значення, чекати результат через get(), а винятки обробляються стандартно.

5. Як правильно завершувати роботу Executor

Дуже важливо не забувати завершувати роботу ExecutorService, щоб програма не зависала (навіть якщо потоки віртуальні, а не «справжні»).

shutdown() і awaitTermination

executor.shutdown(); // Кажемо: більше завдань не приймаємо
executor.awaitTermination(1, TimeUnit.MINUTES); // Чекаємо завершення всіх завдань (максимум 1 хвилину)

Чому це важливо?
Якщо не викликати shutdown(), то віртуальні потоки можуть продовжувати жити, і програма не завершиться навіть після виконання main(). Це типова помилка новачків.

6. Корисні нюанси

Порівняння: віртуальний Executor проти класичного пулу потоків

Класичний пул (newFixedThreadPool) Віртуальний Executor (newVirtualThreadPerTaskExecutor)
Кількість потоків Обмежена розміром пулу Один віртуальний потік на завдання, майже без обмежень
Завдання в черзі Так, якщо всі потоки зайняті Як правило, ні: завдання одразу отримує потік
Вартість потоку Висока (стек, ресурси ОС) Дуже низька (планування виконує JVM)
Масштабованість Обмежена Майже не обмежена
Для чого підходить CPU-bound завдання, обмежена паралельність I/O-bound завдання, масовий паралелізм

Інтеграція з веб-серверами

Сучасні веб-сервери (наприклад, Tomcat, Jetty, Undertow) вже починають підтримувати віртуальні потоки. Це означає, що можна обробляти кожен HTTP-запит в окремому віртуальному потоці, не боячись «захлинутися» під час напливу користувачів.

Перевага: немає потреби вигадувати складні асинхронні схеми із зворотними викликами та CompletableFuture; код стає простішим — можна писати звичний блокувальний код, але застосунок усе одно масштабується.

Масове тестування та імітація навантаження

Віртуальні потоки чудово підходять для тестів, де потрібно імітувати тисячі одночасних користувачів, запитів або операцій. Наприклад, тест, який надсилає 10_000 паралельних запитів до сервера, кожен у своєму віртуальному потоці.

Паралельна обробка файлів і мережевих з’єднань

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

7. Типові помилки під час роботи з віртуальними Executor’ами

Помилка № 1: забули викликати shutdown(). Якщо не закрити Executor, програма не завершиться — віртуальні потоки й далі очікуватимуть нових завдань. За потреби додавайте awaitTermination(...).

Помилка № 2: використовуємо віртуальні потоки для важких обчислень. Віртуальні потоки не пришвидшують завдання, які повністю навантажують CPU. Для CPU-bound краще використовувати фіксований пул (Executors.newFixedThreadPool) і ретельно підбирати розмір.

Помилка № 3: ігноруємо винятки всередині завдань. Якщо завдання кинуло виняток, він не потрапить в основний потік — обробляйте через Future (метод get()) або через try/catch усередині лямбди.

Помилка № 4: плутаємо старий і новий синтаксис/версію JDK. Переконайтеся, що використовуєте коректну версію JDK (Java 21+) і що IDE налаштована на підтримку віртуальних потоків. Конкретний метод — Executors.newVirtualThreadPerTaskExecutor().

Помилка № 5: покладаємося на ThreadLocal для передавання контексту. Віртуальні потоки часто створюються і знищуються; ThreadLocal може поводитися не так, як очікується. Для передавання контексту використовуйте ScopedValue (Scoped Values; докладніше — у наступній лекції).

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ