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 vs классический пул потоков

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

Интеграция с web-серверами

Современные web-серверы (например, Tomcat, Jetty, Undertow) уже начинают поддерживать виртуальные потоки. Это значит, что можно обрабатывать каждый HTTP-запрос в отдельном виртуальном потоке, не боясь «захлебнуться» при наплыве пользователей.

Преимущество: не нужно придумывать сложные асинхронные схемы с callback-ами и 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; подробнее — в следующей лекции).

1
Задача
JAVA 25 SELF, 57 уровень, 3 лекция
Недоступна
Передача данных с межпланетного зонда 🛰️
Передача данных с межпланетного зонда 🛰️
1
Задача
JAVA 25 SELF, 57 уровень, 3 лекция
Недоступна
Мониторинг сборочной линии роботов-манипуляторов 🦾
Мониторинг сборочной линии роботов-манипуляторов 🦾
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ