1. Как создавать виртуальные потоки на практике
Пора переходить от теории к практике! Вы уже знаете, как создаётся поток в классике:
Thread t = new Thread(() -> System.out.println("Hello from thread!"));
t.start();
Или чуть короче:
new Thread(() -> System.out.println("Hi!")).start();
Теперь с Java 21 у нас появился новый способ:
Thread.startVirtualThread(() -> System.out.println("Hello from virtual thread!"));
или более явно:
Thread t = Thread.ofVirtual().start(() -> System.out.println("Hello from virtual thread!"));
В чём разница?
- Thread.ofVirtual().start(...) создаёт виртуальный поток (Virtual Thread), управляемый JVM, а не ОС.
- Thread.ofPlatform().start(...) (или new Thread(...)) — классический поток, как раньше.
Почему это важно?
Виртуальные потоки можно создавать десятками тысяч, не опасаясь OutOfMemoryError. Теперь, если вы вдруг решите обработать миллион запросов — Java скажет: «Без проблем, давай ещё!»
2. Синтаксис создания виртуального потока
Базовый пример:
public class VirtualThreadDemo {
public static void main(String[] args) {
Thread thread = Thread.ofVirtual().start(() -> {
System.out.println("Привет из виртуального потока! Поток: " + Thread.currentThread());
});
// Ждём завершения потока (чтобы main не завершился раньше)
try {
thread.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
Что происходит?
- Мы создаём виртуальный поток через Thread.ofVirtual().start(...).
- Внутри потока — простое действие: печать сообщения.
- В конце вызываем thread.join(), чтобы основной поток подождал завершения виртуального (иначе программа может завершиться раньше, чем поток успеет что-то вывести).
Обратите внимание:
Виртуальный поток выглядит и ведёт себя почти как обычный, но внутри — магия JVM!
3. Массовое создание виртуальных потоков: сила Loom на практике
А теперь попробуем то, что с обычными потоками было бы рискованно (или просто невозможно): создадим 10_000 виртуальных потоков, каждый из которых напишет свой номер.
public class VirtualThreadMassive {
public static void main(String[] args) throws InterruptedException {
int N = 10_000;
Thread[] threads = new Thread[N];
for (int i = 0; i < N; i++) {
int threadNum = i;
threads[i] = Thread.ofVirtual().start(() -> {
System.out.println("Виртуальный поток #" + threadNum + " работает!");
});
}
// Ждём завершения всех потоков
for (Thread t : threads) {
t.join();
}
System.out.println("Все виртуальные потоки завершены!");
}
}
- Для обычных потоков (new Thread(...)) такой код почти гарантированно «уронит» вашу программу с OutOfMemoryError.
- Для виртуальных потоков — это штатный режим! JVM с лёгкостью обработает тысячи и десятки тысяч потоков.
К слову, если вам кажется, что 10_000 — это много, попробуйте 100_000 или даже 1_000_000. На современной машине JVM справится, если ваши потоки выполняют простую работу или ожидают ввода-вывода.
4. Runnable и лямбда-выражения: как передавать код виртуальному потоку
Виртуальные потоки принимают задачи так же, как обычные потоки: через интерфейс Runnable. Это значит, что вы можете передавать и лямбда-выражения, и ссылку на метод, и любой объект, реализующий Runnable.
Пример с лямбдой:
Thread.ofVirtual().start(() -> System.out.println("Лямбда в виртуальном потоке!"));
Пример с методом:
public class TaskRunner {
public static void main(String[] args) {
Thread.ofVirtual().start(TaskRunner::doWork);
}
static void doWork() {
System.out.println("Работаем в виртуальном потоке: " + Thread.currentThread());
}
}
Пример с анонимным классом:
Thread.ofVirtual().start(new Runnable() {
@Override
public void run() {
System.out.println("Анонимный класс в виртуальном потоке!");
}
});
Вывод:
Всё, что работало с обычными потоками, работает и с виртуальными — только теперь это «легко и быстро».
5. Сравнение с ExecutorService: старый и новый подход
Классический ExecutorService
ExecutorService executor = Executors.newFixedThreadPool(10);
for (int i = 0; i < 100; i++) {
int taskNum = i;
executor.submit(() -> {
System.out.println("Задача #" + taskNum + " выполняется");
});
}
executor.shutdown();
Проблема:
Если задач слишком много, а потоков мало — задачи ждут в очереди. Если потоков слишком много — программа «захлебнётся» от нехватки ресурсов.
Новый способ: Executor на виртуальных потоках
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
for (int i = 0; i < 100_000; i++) {
int taskNum = i;
executor.submit(() -> {
System.out.println("Виртуальная задача #" + taskNum);
});
}
executor.shutdown();
Что происходит?
- На каждую задачу создаётся отдельный виртуальный поток.
- JVM сама управляет их планированием, не перегружая систему.
- Нет нужды ограничивать размер пула — виртуальные потоки «почти бесплатны».
Когда использовать Executor на виртуальных потоках?
- Когда у вас есть большой поток задач, и вы хотите не думать о размере пула.
- Когда задачи независимы и могут выполняться параллельно.
- Когда хочется простоты: не нужно вручную управлять потоками.
6. Практические советы: когда что использовать
Когда использовать Thread.ofVirtual().start() напрямую?
- Когда нужно создать отдельный поток для уникальной задачи (например, для теста, демонстрации или простого эксперимента).
- Когда количество потоков невелико, и вы хотите управлять ими вручную.
Когда использовать Executors.newVirtualThreadPerTaskExecutor()?
- Когда нужно массово запускать задачи (например, обработка большого количества запросов, файлов, сетевых соединений).
- Когда задачи независимы и не требуют координации друг с другом.
- Когда хотите интегрировать виртуальные потоки в существующую архитектуру, где уже используется ExecutorService (например, в web-сервере, обработчике задач и т.д.).
Совет:
Если не уверены — начинайте с Executor на виртуальных потоках. Это наиболее универсальный и современный способ.
7. Обработка исключений в виртуальных потоках
Виртуальные потоки — это обычные потоки с точки зрения try-catch. Если внутри вашего Runnable произойдёт исключение, оно не «сломает» всю JVM, а просто завершит данный поток с ошибкой.
Пример:
Thread t = Thread.ofVirtual().start(() -> {
throw new RuntimeException("Что-то пошло не так!");
});
try {
t.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Основной поток продолжил работу.");
В ExecutorService:
Если отправляете задачу через submit, то результат можно получить через Future, и исключение будет проброшено при вызове get():
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
Future<?> f = executor.submit(() -> {
throw new RuntimeException("Ошибка в виртуальной задаче");
});
try {
f.get();
} catch (ExecutionException e) {
System.out.println("Поймали ошибку из виртуального потока: " + e.getCause());
}
executor.shutdown();
8. Типичные ошибки при создании виртуальных потоков
Ошибка №1: Путать виртуальные и платформенные потоки. Если вы создаёте потоки через new Thread(...) или Thread.ofPlatform(), это не виртуальные потоки. Только Thread.ofVirtual().start(...) или методы из Executors дают вам настоящие Virtual Threads.
Ошибка №2: Ожидать ускорения для тяжёлых вычислений. Виртуальные потоки не ускоряют задачи, сильно нагружающие процессор (CPU-bound). Если у вас миллион потоков, каждый из которых считает число Пи до миллионного знака — JVM не сможет «ускорить» вычисления, просто будет переключать потоки.
Ошибка №3: Держать ресурсы (например, базы данных) на каждый поток. Если вы создаёте миллион виртуальных потоков, но каждому нужно отдельное подключение к базе — база не выдержит. Виртуальные потоки хороши для задач, где основная часть времени — ожидание (I/O), а не работа с ограниченными внешними ресурсами.
Ошибка №4: Не дожидаться завершения потоков, если это важно. Если основной поток завершился раньше, чем виртуальные потоки — программа может завершиться, не дождавшись результатов. Используйте join() или ExecutorService с shutdown() и awaitTermination().
Ошибка №5: Использовать устаревшие библиотеки, не совместимые с виртуальными потоками. Некоторые сторонние библиотеки могут блокировать потоки на уровне ОС или использовать native-синхронизацию, что снижает эффективность виртуальных потоков. Всегда проверяйте совместимость.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ