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 (наприклад, у вебсервері, обробнику завдань тощо).
Порада:
Якщо не впевнені — починайте з 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-синхронізацію, що знижує ефективність віртуальних потоків. Завжди перевіряйте сумісність.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ