Здравствуйте!
В предыдущих уроках мы рассмотрели, как важно переосмыслить устоявшиеся подходы к синхронизации (synchronized) и передаче контекста (ThreadLocal) для эффективной работы с виртуальными потоками. Мы увидели, что новая модель конкурентности требует и новых инструментов. Сегодня мы завершаем наше глубокое погружение в механику виртуальных потоков, заглянув в их "машинное отделение".
Цель этого урока — проанализировать и объяснить, как работает планировщик, который управляет миллионами виртуальных потоков. По умолчанию эту роль выполняет ForkJoinPool. Мы разберемся, почему был выбран именно он, как он распределяет задачи между реальными системными потоками, и какие тонкости и проблемы могут возникнуть в его работе. Понимание этого механизма — ключ к диагностике сложных проблем производительности и созданию по-настоящему масштабируемых приложений.
Модель планирования M:N и роль ForkJoinPool
В основе виртуальных потоков лежит так называемая модель планирования M:N. Это означает, что M виртуальных потоков выполняются на N системных потоках операционной системы (ОС). Как правило, M может достигать миллионов, в то время как N — это небольшое число, по умолчанию равное количеству ядер процессора.
Системные потоки, на которых исполняются виртуальные потоки, называются потоками-носителями (carrier threads). А оркестром, управляющим этим сложным процессом — какой виртуальный поток на каком носителе выполнить — по умолчанию выступает специальный ForkJoinPool, созданный для этих целей.
Виртуальные потоки в Java: эволюция, практика, подводные камни
Эта статья на Habr дает хороший обзор концепции виртуальных потоков. Сейчас нас интересует именно механизм планирования.
Прочтите раздел M:N-планирование. Обратите внимание на ключевую идею: виртуальный поток "открепляется" от носителя во время блокировки.
Визуально эту иерархию можно представить следующим образом:

Почему ForkJoinPool, а не ThreadPoolExecutor?
Вы хорошо знакомы с java.util.concurrent и, вероятно, задаетесь вопросом: почему для планировщика был выбран именно ForkJoinPool, а не более привычный ThreadPoolExecutor? Ответ кроется в их фундаментально разной архитектуре и механизме распределения задач.
ThreadPoolExecutorиспользует единую центральную очередь задач. Все рабочие потоки конкурируют за задачи из этой одной очереди. Это может создавать узкое место (contention) при большом количестве коротких задач.ForkJoinPoolреализует механизм "воровства работы" (work-stealing). У каждого потока-носителя есть своя собственная двусторонняя очередь (deque) задач. Поток в первую очередь берет задачи из "головы" своей очереди. Если его очередь пуста, он не простаивает, а пытается "украсть" задачу из "хвоста" очереди другого потока.
Этот подход идеально подходит для виртуальных потоков. Когда виртуальный поток блокируется и затем снова становится готов к выполнению, он помещается в очередь. Механизм work-stealing гарантирует, что свободные потоки-носители будут максимально быстро подхватывать готовую работу, обеспечивая высокую утилизацию процессорных ядер.

Жизненный цикл виртуального потока: монтирование и размонтирование
Теперь давайте разберем сам процесс в деталях. Ключевая идея, позволяющая M потокам работать на N носителях, — это циклы "монтирования" (mounting) и "размонтирования" (unmounting).
- Создание и отправка: Вы создаете виртуальный поток. Он представляет собой задачу (
Runnable) и отправляется вForkJoinPool. - Монтирование: Планировщик
ForkJoinPoolвыбирает свободный поток-носитель и "монтирует" на него виртуальный поток. Код виртуального потока начинает выполняться на этом носителе. - Блокировка и размонтирование: Как только в коде виртуального потока встречается блокирующая операция (например, чтение из сокета,
Thread.sleep()), происходит самое интересное. JVM это обнаруживает и:- Размонтирует виртуальный поток с носителя.
- Сохраняет его состояние (весь стек вызовов) в виде специального объекта-продолжения (continuation) в куче (heap).
- Поток-носитель освобождается и немедленно готов взять другую задачу (другой виртуальный поток) из своей очереди или украсть у соседа.
- Пробуждение и возвращение в очередь: Когда блокирующая операция завершается (например, пришли данные из сети), виртуальный поток снова становится готов к выполнению. Его объект-продолжение помещается обратно в очередь
ForkJoinPool. - Повторное монтирование: Рано или поздно планировщик снова выберет свободный поток-носитель (возможно, уже другой!) и смонтирует на него наш виртуальный поток, восстановив его состояние из кучи. Выполнение продолжится с того места, где оно было прервано.
Этот механизм "пассивации" в кучу и есть то самое "волшебство", которое позволяет обойтись малым числом системных потоков и не тратить их ресурсы на ожидание.
Virtual Threads and Structured Concurrency in Java 21 With Loom
В этом видео от JetBrains Хосе Пауматто очень наглядно демонстрирует и объясняет внутреннюю работу виртуальных потоков.
Посмотрите фрагмент, где он показывает "прыжки" виртуального потока между разными носителями: демонстрация. Затем посмотрите объяснение, как работает continuation.yield для сохранения состояния потока в кучу и освобождения носителя: объяснение.
Конфигурация и крайние случаи
Обычно вам не нужно настраивать планировщик виртуальных потоков — его параметры по умолчанию подобраны очень хорошо. Однако для диагностики и глубокого анализа полезно знать, что его поведением можно управлять через системные свойства, например:
jdk.virtualThreadScheduler.parallelism: количество потоков-носителей (по умолчанию равноRuntime.availableProcessors()).jdk.virtualThreadScheduler.maxPoolSize: максимальное количество потоков-носителей.jdk.virtualThreadScheduler.minRunnable: минимальное количество активных потоков.
Важнее понимание ситуаций, когда эта элегантная модель может давать сбой.
1. Закрепление (Pinning)
Мы уже касались этой темы. "Pinning" — это ситуация, когда виртуальный поток не может быть размонтирован с носителя во время блокировки. Это происходит внутри блоков synchronized (в Java до версии 25) или при вызове нативного (JNI) кода.
С точки зрения планировщика, это катастрофа. Поток-носитель оказывается заблокирован вместе с виртуальным потоком. Если таких "закрепленных" потоков станет много, они могут занять все потоки-носители в ForkJoinPool. Новые виртуальные потоки просто не смогут запуститься, так как для них не будет свободных носителей. Это приводит к резкому падению производительности или полной остановке системы.
2. CPU-интенсивные задачи
Планировщик виртуальных потоков — кооперативный. Он полагается на то, что виртуальные потоки будут добровольно "отдавать" управление, попадая на блокирующие операции. Если же виртуальный поток выполняет долгую, непрерывную вычиcлительную работу (CPU-bound), он "захватывает" поток-носитель и не отпускает его. Другие виртуальные потоки, ожидающие в очереди этого носителя, не получат шанса выполниться.
Для таких задач по-прежнему лучше использовать платформенные потоки и отдельные, специально настроенные пулы. Виртуальные потоки созданы для I/O-bound задач.
Java Virtual Threads - How Virtual Threads Work, Scheduling, Cooperation and More
В этом видео от Rock the JVM отлично демонстрируются проблемы CPU-bound задач и pinning.
Посмотрите демонстрацию того, как CPU-bound задача монополизирует поток-носитель и как Thread.yield() может помочь: CPU-bound и yield. Затем посмотрите пример с pinning внутри блока synchronized: pinning.
3. Взаимодействие с планировщиком ОС
Существует и более тонкая проблема, которая может проявиться в реальных системах. ForkJoinPool управляет потоками-носителями, но сами эти носители (являющиеся платформенными потоками) управляются планировщиком операционной системы. Иногда их цели могут входить в конфликт.
Недавние исследования показали, что в определенных сценариях (например, на машинах с малым числом ядер и при очень коротких задачах) взаимодействие между логикой ForkJoinPool и планировщиком ядра Linux может приводить к неожиданному падению пропускной способности.
Виртуальные потоки Java: разбор тестовых сценариев
Эта статья — результат глубокого исследования производительности, выявившего неочевидные проблемы.
Прочтите разделы Низкая пропускная способность и ForkJoinPool и планировщик Linux. Не обязательно вникать во все детали графиков, главное — уловить суть проблемы: ForkJoinPool пытается "разбудить" поток-носитель, но планировщик Linux не дает ему это сделать немедленно, что приводит к простоям.
Это знание важно не для того, чтобы вы боялись использовать виртуальные потоки, а для того, чтобы понимать, что при столкновении с необъяснимыми проблемами производительности корень может лежать в сложном взаимодействии нескольких уровней планирования.
Заключение
Сегодня мы заглянули "под капот" виртуальных потоков и разобрали работу их стандартного планировщика ForkJoinPool.
Ключевые выводы:
- Виртуальные потоки работают по модели M:N, где M виртуальных потоков исполняются на N потоках-носителях.
- Планировщиком по умолчанию является
ForkJoinPoolиз-за его эффективного механизма "воровства работы" (work-stealing), который идеально подходит для множества коротких, часто блокирующихся задач. - Основной механизм работы — монтирование/размонтирование. При блокировке виртуальный поток "снимается" с носителя, а его состояние сохраняется в куче, освобождая носитель для другой работы.
- Главные угрозы для производительности — закрепление (pinning), которое блокирует носитель, и долгие CPU-интенсивные задачи, которые его монополизируют.
- Анализ производительности может потребовать внимания к взаимодействию планировщика JVM и планировщика ОС.
Теперь, когда у нас есть полное представление о том, как работают и управляются отдельные виртуальные потоки, мы готовы перейти на следующий уровень абстракции. В следующем модуле мы начнем изучать Структурный параллелизм (Structured Concurrency) — современный подход к управлению жизненным циклом и результатами групп параллельных задач, который делает конкурентный код значительно более безопасным, читаемым и надежным.