Виртуальные потоки в Project Loom функционируют как продолжения, смонтированные на потоках-переносчиках, извлеченных из ForkJoinPool. Когда виртуальный поток сталкивается с синхронизированным блоком или выполняет нативный код, он блокирует свой основной поток, предотвращая планировщик от размонтирования виртуального потока во время блокирующих операций ввода/вывода. Это эффективно уменьшает степень параллелизма до размера пула потоков-переносчиков (обычно равного количеству ядер ЦП), что может привести к коллапсу пропускной способности под нагрузкой, так как конкурирующие виртуальные потоки монополизируют фиксированный пул потоков-переносчиков.
Финансовая компания мигрировала свой устаревший шлюз обработки заказов с традиционной модели Tomcat поток на запрос (ограниченной 500 платформенными потоками) на Jetty с виртуальными потоками, ожидая справляться с 50,000 одновременными соединениями WebSocket. Сразу после развертывания, несмотря на использование виртуальных потоков, задержка возросла до секунд, а пропускная способность стабилизировалась на уровне всего 800 TPS во время волатильности на открытии рынка. Дамп потоков показал, что все 24 потока-переносчика были застрявшими в состоянии BLOCKED внутри синхронизированных блоков, в то время как тысячи виртуальных потоков, ожидающих ввода/вывода, не могли продолжить.
Первым рассматриваемым решением было увеличение параллелизма ForkJoinPool через -Djdk.virtualThreadScheduler.parallelism до 1000. Это обеспечивало бы больше потоков-переносчиков для поглощения заблокированной нагрузки, эффективно возвращая поведение к большому пулу платформенных потоков. Однако этот подход только маскирует основную архитектурную проблему, потребляя чрезмерные ресурсы ОС и аннулируя преимущества эффективного использования памяти, обещанные виртуализацией потоков.
Второе решение заключалось в рефакторинге всех синхронизированных блоков, защищающих общие кэши ограничения частоты, с использованием ReentrantLock вместо этого. В отличие от внутренних мониторов, ReentrantLock интегрируется с планировщиком виртуальных потоков, позволяя размонтирование во время конкуренции или блокирующих операций без фиксации потока-переносчика. Этот подход сохраняет легковесный характер виртуальных потоков, но требует систематической проверки кода и осторожного обращения с семантикой прерывания блокировок.
Третье решение предлагало заменить кэши конструкторов на чисто неблокирующие структуры данных, такие как методы расчета ConcurrentHashMap или StampedLock для оптимистичных чтений. Хотя это устраняет блокировку для многих путей чтения, оно не решает сценарии, требующие эксклюзивного доступа к состоянию внешних ресурсов, таких как последовательности проверки соединений с базой данных, которые по своей сути требуют взаимного исключения.
Команда выбрала второе решение, приоритетно мигрировав пятьдесят критических синхронизированных секций на ReentrantLock после профилирования, которое определило их как горячие точки фиксации. Этот выбор прямо адресовал коренную причину, позволяя планировщику размонтировать виртуальные потоки во время конкуренции, не изменяя основную бизнес-логику приложения или увеличивая объем памяти.
После рефакторинга и повторного развертывания система достигла целевых 50,000 одновременных соединений с стабильной задержкой ниже 100 мс на 99-percentile. Пул потоков-переносчиков остался на стандартном размере 24 (соответствующем ядрам ЦП), демонстрируя, что виртуальные потоки обеспечивают настоящую масштабируемость только когда код избегает фиксации потоков через внутреннюю синхронизацию.
// До: фиксация потока-переносчика synchronized (rateLimiter) { // Виртуальный поток не может размонтироваться, если заблокирован здесь externalApi.call(); } // После: разрешает размонтирование rateLimiter.lock(); try { // Виртуальный поток размонтируется, освобождая поток-переносчик externalApi.call(); } finally { rateLimiter.unlock(); }
Почему фиксация происходит именно с синхронизированными блоками и нативными методами, тогда как ReentrantLock позволяет размонтирование?
Фиксация возникает, потому что JVM реализует внутренние мониторы (синхронизированные) с использованием записей мониторинга на основе стека потоков и внутренних структур VM уровня C++, которые по своей сути привязаны к контексту выполнения физического потока ОС. Когда виртуальный поток входит в синхронизированный блок, JVM не может безопасно переместить продолжение на другой поток-переносчик без повреждения состояния монитора или нарушения гарантии happens-before на нативном уровне. Напротив, ReentrantLock реализован исключительно на Java на основе AbstractQueuedSynchronizer, который использует примитивы VarHandle и LockSupport.park, на которые планировщик виртуальных потоков воздействует, что позволяет безопасное размонтирование и повторное монтирование между потоками без зависимости от состояния нативного потока.
Как взаимодействие фиксации потоков-переносчиков с механикой воришества работы ForkJoinPool может создать потенциально катастрофические ситуации?
В нормальном режиме работы ForkJoinPool предполагает, что задачи являются ограниченными по ЦП или неблокирующими; когда рабочий поток блокируется, он компенсирует, создавая или активируя дополнительные рабочие потоки до предела параллелизма. Однако зафиксированный виртуальный поток блокирует свой поток-переносчик, не сигнализируя о механизме компенсации пула эффективно. Следовательно, если двадцать виртуальных потоков одновременно фиксируют двадцать переносчиков (например, входя в синхронизированные блоки), не остается переносчиков для выполнения тысяч готовых виртуальных потоков, ожидающих в планировщике. Это создает инверсию приоритета, когда разблокированная работа не может прогрессировать, несмотря на доступные задачи, эффективно уменьшая динамически и катастрофически размер используемого пула.
Может ли агрессивное использование переменных ThreadLocal вызвать фиксацию потоков-переносчиков в средах виртуальных потоков?
ThreadLocal переменные не вызывают фиксации, потому что реализация виртуального потока перемещает локальную карту между переносчиками во время операций монтирования и размонтирования. Тем не менее, кандидаты часто упускают из виду, что ThreadLocal представляет собой отдельную катастрофу управления памятью: при наличии миллионов недолговечных виртуальных потоков, касающихся локальных потоков, каждый поток-переносчик накапливает записи в своем ThreadLocalMap для каждого виртуального потока, который он когда-либо хранил. Поскольку эти карты очищаются только при явном удалении или сбором мусора ключа (виртуального потока), это приводит к неограниченному росту памяти в долго работающих потоках-переносчиках. Это фактически является утечкой памяти, не связанной с фиксацией, но столь же фатальной для развертываний виртуальных потоков крупного масштаба, требуя миграции на ScopedValue (JEP 446) для правильной очистки.