JavaRush /Курси /JAVA 25 SELF /Масштабованість і продуктивність Virtual Threads

Масштабованість і продуктивність Virtual Threads

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

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

Чому звичайні потоки погано масштабуються?

Кожен класичний потік (Thread) — це сутність операційної системи з власним стеком (зазвичай 12 МБ) і структурою стану. Спроба створити, скажімо, 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 0002 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 00010 000 Високе Довго
Віртуальні 100 0001 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.

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