JavaRush /Курси /JAVA 25 SELF /Потоки проти віртуальних потоків: відмінності, переваги

Потоки проти віртуальних потоків: відмінності, переваги

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

1. Класичні потоки: як це працює і де болить

Давайте спочатку згадаємо, як працюють звичайні потоки в Java (їх ще називають платформеними або native threads). Ті самі, що створюються через new Thread(...).

Коли ви викликаєте new Thread(() -> { ... }).start();, JVM не просто запускає фрагмент коду. Вона просить операційну систему створити справжній потік виконання. ОС виділяє під нього окремий стек (зазвичай кілька мегабайт) і резервує інші службові ресурси.

Такий потік живе, доки виконується його завдання, і весь цей час займає місце в таблиці потоків операційної системи. Чим більше таких потоків, тим більше пам’яті йде на їхні стеки й тим вище навантаження на ОС. Саме тому за великої кількості потоків, що одночасно працюють, застосунок може почати «задихатися» — система витрачає надто багато часу на їхнє перемикання.

Приклад: класичний потік

Thread thread = new Thread(() -> {
    System.out.println("Привіт із потоку!");
});
thread.start();

Виглядає просто, правда? Але спробуйте створити не один-два, а, скажімо, десять тисяч таких потоків — і ваша програма швидко почне задихатися. То закінчиться пам’ять, то система скаже, що ліміт потоків вичерпано. Це не помилка Java, а природний наслідок архітектури: потоки — річ важка й дорога.

Чому так відбувається? Кожен потік отримує власний стек (зазвичай 1–2 мегабайти), плюс цілий набір службових структур від операційної системи. А ще сама ОС не в захваті, коли їй підсовують десятки тисяч потоків — у неї є обмеження, і вони бувають доволі жорсткими.

І навіть якщо пам’ять не скінчилася, виникає інша біда — перемикання контексту. Коли потоків надто багато, система постійно стрибає між ними, зберігаючи та відновлюючи їхній стан. Усе це займає час і з’їдає продуктивність, тож виграшу від «масового розпаралелювання» не виходить.

Проблема «один потік — один запит»

У старих серверних застосунках (наприклад, на Tomcat або Jetty) часто застосовували модель «thread‑per‑request»: на кожен вхідний користувацький запит виділявся окремий потік. Це зручно, але якщо у вас 10_000 користувачів, вам потрібно 10_000 потоків! Серверу стає важко, і починається гонитва не за швидкістю обробки запитів, а за пам’яттю.

Підсумок:
Класичні потоки добрі для невеликої кількості паралельних завдань, але не масштабуються до десятків і сотень тисяч.

2. Що таке віртуальні потоки (Virtual Threads)?

Саме тут і з’являється герой сьогоднішньої лекції — віртуальні потоки. Це не просто «ще один потік», а зовсім інша архітектурна ідея.

Віртуальні потоки — це потоки, якими керує не операційна система, а сама JVM. Вони реалізовані повністю всередині Java та можуть бути створені у величезних кількостях (десятки й сотні тисяч) без «роздування» пам’яті та гальм.

Коротко:

  • Platform Thread (платформенний потік): звичайний потік, який напряму відповідає потоку ОС.
  • Virtual Thread (віртуальний потік): легкий потік, яким керує JVM, а не ОС.

Як це влаштовано?

Віртуальні потоки — це такі «легкі» потоки, що живуть не в операційній системі, а всередині самої JVM. Вони працюють поверх невеликого пулу справжніх потоків, які називаються platform threads. Можна уявити, що JVM сама керує ними, як диригент оркестром: у нього є обмежена кількість музикантів (справжніх потоків), але він уміло розподіляє між ними партії (віртуальні потоки).

Архітектурна схема:

+-------------------+           +-------------------+
|  Virtual Thread 1 |---\       |  Platform Thread  |
|  Virtual Thread 2 |---->====> |  (Carrier Thread) |
|  Virtual Thread 3 |---/       +-------------------+
         ...                        (Операційна система)

Carrier Thread — це звичайний потік ОС, на якому JVM виконує безліч віртуальних потоків. Якщо якийсь віртуальний потік раптово блокується — наприклад, чекає даних із диска або з мережі — JVM просто «заморожує» його, звільняючи carrier thread для інших завдань.

Чому це революційно?

Бо тепер можна писати звичний, лінійний код — без нескінченних колбеків, CompletableFuture і «пекельних» ланцюжків thenApply — і при цьому масштабувати застосунок на тисячі одночасних операцій.

Віртуальні потоки займають лише десятки кілобайт (замість мегабайт у звичайних) і створюються практично миттєво. Тому їх можна запускати й знищувати тисячами, не побоюючись, що операційна система впаде під їхньою вагою. Це робить паралельне програмування в Java нарешті легким і природним.

3. Переваги віртуальних потоків

Масштабованість

З віртуальними потоками ви можете дозволити собі розкіш запускати десятки й сотні тисяч паралельних завдань. Наприклад, обробляти кожен мережевий запит в окремому потоці — і не перейматися, що сервер «лопне».

Демонстрація: 100 000 віртуальних потоків

for (int i = 0; i < 100_000; i++) {
    Thread.ofVirtual().start(() -> {
        // Тут може бути будь‑яка логіка
        try {
            Thread.sleep(1000); // Імітація роботи
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    });
}
System.out.println("Усі потоки запущено!");

Цей код спокійно виконується на звичайному ноутбуці!
Спробуйте зробити те саме зі звичайними потоками — і ви побачите OutOfMemoryError або отримаєте «цеглину» замість комп’ютера.

Простота програмування

Віртуальні потоки дозволяють писати звичний «блокувальний» код, не перетворюючи його на «локшину» з асинхронних викликів. Наприклад, ви можете спокійно використовувати Thread.sleep, InputStream.read, Socket.accept — JVM сама подбає про те, щоб не блокувати весь carrier thread.

Поліпшення читабельності та супроводу коду

Замість складних схем із колбеками та CompletableFuture ви пишете лінійний, зрозумілий код. Це знижує кількість багів і спрощує підтримку.

Не потрібно винаходити велосипед

Раніше, щоб обробляти тисячі запитів паралельно, доводилося використовувати асинхронні фреймворки, реактивні бібліотеки (Netty, Vert.x, Project Reactor), які вимагають особливого стилю програмування. Тепер можна обійтися без них — і все одно отримати масштабованість.

4. Архітектура: як віртуальні потоки працюють «під капотом»

Відображення на carrier threads

JVM створює невеликий пул справжніх потоків (carrier threads) — зазвичай їх стільки ж, скільки ядер у процесора. Усі віртуальні потоки «їздять» на цих carrier threads, як пасажири в автобусах.

  • Коли віртуальний потік блокується (наприклад, чекає відповіді з мережі), JVM «вивантажує» його з carrier thread і ставить у чергу.
  • Щойно потік може продовжити роботу, JVM знову «садить» його на вільний carrier thread.

Аналогія:
Уявіть, що у вас є 4 таксі (carrier threads), і ви обслуговуєте 10 000 клієнтів (virtual threads). Щойно один клієнт доїхав до місця і вийшов, таксі тут же підбирає наступного. Ніхто не простоює, і таксі не «ламаються» від напливу пасажирів.

Планування та перемикання

JVM самостійно вирішує, який віртуальний потік виконувати в даний момент. Якщо потік блокується на I/O, він не заважає іншим потокам працювати.

5. Обмеження та особливості віртуальних потоків

Не все золото, що віртуальне

Не для тривалих обчислень: Якщо у вас є завдання, яке постійно завантажує процесор (важкі CPU‑bound), віртуальний потік не дасть приросту продуктивності. Просто тому, що carrier threads усе одно обмежені кількістю ядер.

Деякі блокування неефективні: Застарілі механізми синхронізації (наприклад, синхронізація через synchronized на об’єктах із нативними м’ютексами) можуть не дозволяти JVM «заморозити» віртуальний потік. У таких випадках carrier thread чекатиме разом із віртуальним потоком, знижуючи масштабованість.

Не всі бібліотеки дружать із віртуальними потоками: Якщо бібліотека робить нативні виклики або використовує специфічні блокування, віртуальні потоки можуть поводитися не так, як очікується.

Приклад: коли не варто використовувати Virtual Threads

Якщо у вас є завдання, яке крутиться в нескінченному циклі й рахує числа, віртуальний потік не дасть жодного виграшу. Все одно впираємося в кількість ядер.

Thread.ofVirtual().start(() -> {
    while (true) {
        // Рахуємо до нескінченності
    }
});

Результат:
Один carrier thread буде зайнятий цим віртуальним потоком, а інші завдання чекатимуть своєї черги.

6. Порівняння: Platform Thread vs Virtual Thread

Характеристика Platform Thread (Звичайний) Virtual Thread (Віртуальний)
Керується Операційною системою JVM
Пам’ять на потік Мегабайти Десятки кілобайт
Кількість потоків Зазвичай < 10_000 Тисячі, сотні тисяч
Вартість створення Дорого Дешево
Масштабованість Обмежена Майже не обмежена
Підходить для Тривалих завдань, CPU‑bound Коротких, I/O‑bound завдань
Перемикання потоків ОС JVM
Сумісність 100% Майже завжди, але є нюанси

7. Приклад: як виглядав би сервер до та після Virtual Threads

До (Platform Threads)

ServerSocket serverSocket = new ServerSocket(8080);
while (true) {
    Socket client = serverSocket.accept();
    new Thread(() -> handleClient(client)).start();
}

Проблема:
Через 5 000 підключень сервер почне «захлинатися».

Після (Virtual Threads, Java 21+)

ServerSocket serverSocket = new ServerSocket(8080);
while (true) {
    Socket client = serverSocket.accept();
    Thread.ofVirtual().start(() -> handleClient(client));
}

Магія:
Тепер можна обробляти десятки тисяч з’єднань — і не думати про ліміти потоків!

9. Типові помилки під час переходу на віртуальні потоки

Помилка № 1: Очікування прискорення для обчислювальних завдань. Віртуальні потоки не пришвидшують завдання, які повністю завантажують процесор. Для таких завдань усе так само впираємося в кількість ядер.

Помилка № 2: Використання застарілих блокувальних синхронізацій. Якщо ви використовуєте застарілі блокування (наприклад, synchronized на об’єктах, які можуть бути «замкнені» нативно), віртуальні потоки можуть не вивантажуватися з carrier thread, втрачаючи всі переваги.

Помилка № 3: Необлік поведінки сторонніх бібліотек. Деякі сторонні бібліотеки можуть бути не готові до роботи з віртуальними потоками (наприклад, якщо використовують JNI або нативні блокування).

Помилка № 4: Очікування магічного зростання продуктивності. Віртуальні потоки — це не панацея. Вони не пришвидшують усе підряд, а лише роблять паралелізм дешевим і зручним для I/O‑bound завдань.

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