Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 4

ForkJoinPool и виртуальные потоки: анализ поведения

Recommended resources

Здравствуйте!

В предыдущих уроках мы рассмотрели, как важно переосмыслить устоявшиеся подходы к синхронизации (synchronized) и передаче контекста (ThreadLocal) для эффективной работы с виртуальными потоками. Мы увидели, что новая модель конкурентности требует и новых инструментов. Сегодня мы завершаем наше глубокое погружение в механику виртуальных потоков, заглянув в их "машинное отделение".

Цель этого урока — проанализировать и объяснить, как работает планировщик, который управляет миллионами виртуальных потоков. По умолчанию эту роль выполняет ForkJoinPool. Мы разберемся, почему был выбран именно он, как он распределяет задачи между реальными системными потоками, и какие тонкости и проблемы могут возникнуть в его работе. Понимание этого механизма — ключ к диагностике сложных проблем производительности и созданию по-настоящему масштабируемых приложений.

Модель планирования M:N и роль ForkJoinPool

В основе виртуальных потоков лежит так называемая модель планирования M:N. Это означает, что M виртуальных потоков выполняются на N системных потоках операционной системы (ОС). Как правило, M может достигать миллионов, в то время как N — это небольшое число, по умолчанию равное количеству ядер процессора.

Системные потоки, на которых исполняются виртуальные потоки, называются потоками-носителями (carrier threads). А оркестром, управляющим этим сложным процессом — какой виртуальный поток на каком носителе выполнить — по умолчанию выступает специальный ForkJoinPool, созданный для этих целей.

Виртуальные потоки в Java: эволюция, практика, подводные камни

Эта статья на Habr дает хороший обзор концепции виртуальных потоков. Сейчас нас интересует именно механизм планирования.

Прочтите раздел M:N-планирование. Обратите внимание на ключевую идею: виртуальный поток "открепляется" от носителя во время блокировки.

Визуально эту иерархию можно представить следующим образом:

На этой схеме показано, как виртуальные потоки (vt) монтируются на платформенные потоки (pt), которые являются частью ForkJoinPool и, в свою очередь, отображаются на потоки ОС (ost). Обратите внимание на термины "carrier" (носитель) и "pinned" (закрепленный), к которым мы вернемся позже.

Почему ForkJoinPool, а не ThreadPoolExecutor?

Вы хорошо знакомы с java.util.concurrent и, вероятно, задаетесь вопросом: почему для планировщика был выбран именно ForkJoinPool, а не более привычный ThreadPoolExecutor? Ответ кроется в их фундаментально разной архитектуре и механизме распределения задач.

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

Этот подход идеально подходит для виртуальных потоков. Когда виртуальный поток блокируется и затем снова становится готов к выполнению, он помещается в очередь. Механизм work-stealing гарантирует, что свободные потоки-носители будут максимально быстро подхватывать готовую работу, обеспечивая высокую утилизацию процессорных ядер.

Это изображение наглядно иллюстрирует различия между `ForkJoinPool` (слева) и `ThreadPoolExecutor` (справа). Обратите внимание на наличие локальных очередей и механизма "воровства" у `ForkJoinPool` в противовес единой очереди у `ThreadPoolExecutor`.

Жизненный цикл виртуального потока: монтирование и размонтирование

Теперь давайте разберем сам процесс в деталях. Ключевая идея, позволяющая M потокам работать на N носителях, — это циклы "монтирования" (mounting) и "размонтирования" (unmounting).

  1. Создание и отправка: Вы создаете виртуальный поток. Он представляет собой задачу (Runnable) и отправляется в ForkJoinPool.
  2. Монтирование: Планировщик ForkJoinPool выбирает свободный поток-носитель и "монтирует" на него виртуальный поток. Код виртуального потока начинает выполняться на этом носителе.
  3. Блокировка и размонтирование: Как только в коде виртуального потока встречается блокирующая операция (например, чтение из сокета, Thread.sleep()), происходит самое интересное. JVM это обнаруживает и:
    • Размонтирует виртуальный поток с носителя.
    • Сохраняет его состояние (весь стек вызовов) в виде специального объекта-продолжения (continuation) в куче (heap).
    • Поток-носитель освобождается и немедленно готов взять другую задачу (другой виртуальный поток) из своей очереди или украсть у соседа.
  4. Пробуждение и возвращение в очередь: Когда блокирующая операция завершается (например, пришли данные из сети), виртуальный поток снова становится готов к выполнению. Его объект-продолжение помещается обратно в очередь ForkJoinPool.
  5. Повторное монтирование: Рано или поздно планировщик снова выберет свободный поток-носитель (возможно, уже другой!) и смонтирует на него наш виртуальный поток, восстановив его состояние из кучи. Выполнение продолжится с того места, где оно было прервано.

Этот механизм "пассивации" в кучу и есть то самое "волшебство", которое позволяет обойтись малым числом системных потоков и не тратить их ресурсы на ожидание.

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) — современный подход к управлению жизненным циклом и результатами групп параллельных задач, который делает конкурентный код значительно более безопасным, читаемым и надежным.