1. Масштабованість
Чому звичайні потоки погано масштабуються?
Кожен класичний потік (Thread) — це сутність операційної системи з власним стеком (зазвичай 1–2 МБ) і структурою стану. Спроба створити, скажімо, 10 000 звичайних потоків нерідко призводить до помилки OutOfMemoryError. Тому на традиційних серверах використовують обмежені пули потоків.
Віртуальні потоки: магія масштабованості
Віртуальні потоки (Java 21+) — «легкі» потоки, керовані JVM. Їхній стек зберігається в купі та може динамічно зростати й стискатися. Коли потік блокується на I/O, JVM «заморожує» його й продовжує виконувати інші завдання.
Усередині JVM працює невеликий пул «носіїв» (carrier threads) — платформених потоків ОС, на яких по черзі виконуються віртуальні потоки. Це дозволяє створювати 100 000+ завдань без проблем зі споживанням пам’яті. JVM сама планує, які віртуальні потоки й коли виконувати.
Демонстрація: 100_000 віртуальних потоків проти 1_000 платформених
Приклад: створення 1000 звичайних потоків
// Спроба створити 1000 звичайних потоків
List<Thread> threads = new ArrayList<>();
for (int i = 0; i < 1000; i++) {
Thread t = new Thread(() -> {
try {
Thread.sleep(10_000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
threads.add(t);
t.start();
}
System.out.println("Створено потоків: " + threads.size());
Результат: На більшості систем вдасться створити 1 000–2 000 потоків; за більших значень почнуться проблеми з пам’яттю та сповільнення.
Приклад: створення 100 000 віртуальних потоків
// Створюємо 100_000 віртуальних потоків
List<Thread> vThreads = new ArrayList<>();
for (int i = 0; i < 100_000; i++) {
Thread t = Thread.ofVirtual().start(() -> {
try {
Thread.sleep(10_000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
vThreads.add(t);
}
System.out.println("Створено віртуальних потоків: " + vThreads.size());
Результат: Програма спокійно створює 100 000 віртуальних потоків без падінь і суттєвих сповільнень. Пам’яті потрібно у рази менше.
Візуальне порівняння
| Тип потоку | Максимальна кількість потоків (приблизно) | Використання пам’яті | Час запуску |
|---|---|---|---|
| Звичайні (Thread) | 1 000 – 10 000 | Високе | Довго |
| Віртуальні | 100 000 – 1 000 000+ | Низьке | Миттєво |
Факт: Віртуальні потоки дозволяють писати код «один потік на завдання» без складних пулів і ризику перевантаження системи.
2. Продуктивність: де віртуальні потоки розкривають себе
Завдання, що «впираються» у введення-виведення (I/O-bound)
Віртуальні потоки чудово підходять для мережевих запитів, файлового I/O та роботи з БД. Коли операція блокується, віртуальний потік звільняє «носія», а JVM виконує інші завдання. Це підвищує пропускну здатність за великої кількості одночасних очікувань.
Приклад: імітація 10 000 одночасних HTTP-запитів
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
HttpClient client = HttpClient.newHttpClient();
List<Thread> threads = new ArrayList<>();
for (int i = 0; i < 10_000; i++) {
Thread t = Thread.ofVirtual().start(() -> {
try {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://example.com"))
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println("Відповідь: " + response.statusCode());
} catch (Exception e) {
System.out.println("Помилка: " + e.getMessage());
}
});
threads.add(t);
}
// Чекаємо завершення всіх потоків
for (Thread t : threads) {
t.join();
}
Результат: Усі 10 000 запитів виконуються паралельно; програма не падає, а код лишається простим.
CPU-bound: віртуальні потоки не пришвидшують обчислення
Якщо завдання навантажує CPU, віртуальні потоки не додадуть швидкодії: кількість ядер фіксована. Тут доречні фіксовані пули, рівні кількості ядер, щоб не створювати зайвої конкуренції.
// Кожне завдання рахує суму великого діапазону
Runnable cpuTask = () -> {
long sum = 0;
for (int i = 0; i < 100_000_000; i++) {
sum += i;
}
System.out.println("Сума: " + sum);
};
// Запускаємо 1000 віртуальних потоків з обчисленнями
for (int i = 0; i < 1000; i++) {
Thread.ofVirtual().start(cpuTask);
}
Результат: Потоки конкуруватимуть за CPU, але прискорення не буде — це не завдання для Virtual Threads.
3. Обмеження та особливості
Синхронізація та пастки віртуальних потоків
- Обережно з нативними блокуваннями. Використання synchronized може «приклеїти» віртуальний потік до носія, зменшуючи переваги. Надавайте перевагу ReentrantLock, Semaphore та іншим примітивам із java.util.concurrent, оптимізованим під віртуальні потоки.
- Старі бібліотеки. Деякі JDBC-драйвери та нативні бібліотеки ще не оптимізовані під Virtual Threads. Ретельно тестуйте блокувальні операції.
Не для довгоживучих завдань
Virtual Threads ідеальні для «коротких» одиниць роботи: обробка запиту, одна операція й завершення. Мільйон нескінченно тривалих завдань (наприклад, вічні обчислення) не принесуть користі — для них використовуйте платформні потоки.
4. Найкращі практики: де використовувати віртуальні потоки
- I/O-bound завдання: мережеві виклики, файли, БД — усе, де потік часто чекає.
- Вебсервери: обробляйте кожен HTTP-запит в окремому віртуальному потоці.
- Інтеграційні тести: швидко імітуйте тисячі клієнтів.
- Асинхронна обробка: пишіть звичний «блокуючий» код — JVM виконує розумне планування.
Не варто використовувати:
- Для завдань, що постійно навантажують CPU.
- Коли критична сумісність із низькорівневими бібліотеками (поки не всі адаптовані).
Під капотом зручно використовувати виконавець: Executors.newVirtualThreadPerTaskExecutor() — «один віртуальний потік на завдання», без фіксованого пулу.
5. Моніторинг і вимірювання: як побачити віртуальні потоки в дії
JVisualVM і Flight Recorder
JVisualVM покаже активні потоки, їхні стани та пам’ять; починаючи з Java 21, віртуальні потоки відображаються окремо. Java Flight Recorder (JFR) записує докладний «чорний ящик» виконання, включно зі статистикою щодо Virtual Threads — зручно для пошуку вузьких місць.
Як переглянути кількість потоків у коді
Простий спосіб побачити кількість потоків у JVM:
System.out.println("Усього потоків: " + Thread.activeCount());
Порахувати, скільки з них віртуальних:
long vCount = Thread.getAllStackTraces().keySet().stream()
.filter(Thread::isVirtual)
.count();
System.out.println("Віртуальних потоків: " + vCount);
6. Типові помилки під час роботи з віртуальними потоками
Помилка №1: Використання віртуальних потоків для важких обчислень. Запуск мільйонів віртуальних потоків із CPU-bound завданнями не пришвидшить роботу процесора. Virtual Threads — не «турбо» для обчислень.
Помилка №2: Сліпе копіювання старих патернів. Не створюйте фіксовані пули віртуальних потоків. Використовуйте Executors.newVirtualThreadPerTaskExecutor() і дозвольте JVM масштабуватися автоматично.
Помилка №3: Використання непідтримуваних бібліотек. Нативні блокування й бібліотеки, не адаптовані під Loom, можуть спричиняти зависання й просідання продуктивності. Перевіряйте сумісність заздалегідь.
Помилка №4: Передчасна оптимізація. Якщо у вас кілька потоків і звичайна багатопоточність — не поспішайте переносити все на Virtual Threads. Інструмент доречний там, де є масовий I/O та очікування.
Помилка №5: Ігнорування моніторингу. Створити мільйон завдань легко, але без моніторингу та обробки винятків можна отримати «красивий бенчмарк» замість надійної системи. Використовуйте JVisualVM і JFR.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ