JavaRush /Курси /JAVA 25 SELF /Створення віртуальних потоків: Thread.ofVirtual().start()...

Створення віртуальних потоків: Thread.ofVirtual().start()

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

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-синхронізацію, що знижує ефективність віртуальних потоків. Завжди перевіряйте сумісність.

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