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; подробнее — в следующей лекции).
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ