Получить доступ

Виртуальные потоки (Project Loom)

Лёгкие потоки JVM, монтирование на carrier, когда их применять, почему не пулят и проблема pinning.

Проблема, которую решают

Платформенные потоки дороги, поэтому их число ограничено пулом. В модели thread-per-request на блокирующем IO это упирается в потолок: тысячи одновременных запросов требуют тысяч потоков, а столько ОС-потоков не потянуть.

Альтернатива раньше была одна — асинхронный/реактивный код, который сложнее читать и отлаживать. Виртуальные потоки (Java 21) возвращают простой блокирующий стиль, снимая ограничение на количество.

Как устроены

Виртуальный поток — это лёгкий поток, управляемый JVM, а не ОС. Их могут быть миллионы. Для выполнения виртуальный поток монтируется на платформенный поток-носитель (carrier); когда он встречает блокирующую операцию (IO, ожидание), JVM размонтирует его, освобождая carrier для других виртуальных потоков, а стек сохраняет в куче.

Так блокирующий вызов больше не держит дорогой ОС-поток — он лишь паркует дешёвый виртуальный.

try (var exec = Executors.newVirtualThreadPerTaskExecutor()) {
    for (var task : tasks) exec.submit(task); // поток на задачу — это норм
}
Множество виртуальных потоков монтируются на немногие несущие (carrier) потоки; при блокирующей операции виртуальный поток отцепляется, освобождая несущий.
Виртуальные потоки монтируются на несущие; при блокировке поток отцепляется (unmount) и освобождает несущий.

Когда применять

Виртуальные потоки — про высокую конкуренцию на блокирующем IO (вызовы БД, HTTP, очередей): thread-per-request снова масштабируется до десятков/сотен тысяч одновременных операций.

Они НЕ ускоряют CPU-bound работу: вычисления всё равно упираются в число ядер, и тут по-прежнему уместны платформенные потоки/ForkJoin.

Не пулить и не блокировать carrier

Виртуальные потоки не пулят: они дёшевы, поэтому создают по одному на задачу (newVirtualThreadPerTaskExecutor). Пул виртуальных потоков бессмыслен и вреден.

Главный подводный камень — pinning: виртуальный поток, который не может размонтироваться, «прибивает» carrier. В Java 21–23 это происходило при блокировке внутри synchronized-блока, поэтому для длинных критических секций советовали ReentrantLock. В JDK 24 (JEP 491) synchronized больше не прибивает поток, и совет актуален только для старых версий. Остаётся закрепление на нативных вызовах (JNI) — его находят по событию JFR jdk.VirtualThreadPinned. И не кладут тяжёлые объекты в ThreadLocal: виртуальных потоков миллионы.

Как отвечать на собеседовании

Объясните выгоду: блокирующий стиль кода + масштаб реактивного за счёт монтирования/размонтирования на carrier при блокировке. Подчеркните — для IO-bound, не для CPU-bound; не пулить (поток на задачу); и знайте про pinning на synchronized/нативных вызовах (лечится ReentrantLock). Бонус — упомяните structured concurrency (StructuredTaskScope) для управления группой подзадач.

Вопросы по теме

Читайте также

Закрытая бета

Знать тему — половина дела

На живом собеседовании sotto слышит вопрос интервьюера и показывает короткий ответ и план. Оставьте контакт — пришлём доступ и промокод.

  • Промокод на 2 часа — сверх пробных 30 минут
  • Программа для Windows — скачать сразу, войти — через Яндекс ID или VK ID
  • Прямая связь с разработчиком: что улучшить — сделаем