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. Best practices: где использовать виртуальные потоки
- I/O-bound задачи: сетевые вызовы, файлы, БД — всё, где поток часто ждёт.
- Web-серверы: обрабатывайте каждый 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.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ