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.
Улучшение читаемости и поддержки кода
Вместо сложных схем с callback-ами и 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. Ограничения и особенности виртуальных потоков
Не всё золото, что виртуально
Не для долгих вычислений: Если у вас есть задача, которая постоянно занимает процессор (heavy 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 задач.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ