Виртуальные потоки Java меняют поведение I/O‑сервисов, но переносят узкие места в другие слои. Разбираем, где именно система выигрывает и где начинает ломаться.
Проблема проявляется сразу после «однострочного» включения виртуальных потоков в Spring Boot 3. На синтетике всё выглядит лучше: больше throughput, ниже latency. Но под нагрузкой вскрываются скрытые зависимости. Синхронизация (synchronized), использование ThreadLocal и размеры пулов подключений начинают вести себя иначе. В JDK 21 ключевой отказ — истощение carrier‑потоков из‑за pinning внутри synchronized. Виртуальный поток блокируется на I/O, но не освобождает carrier. При всплеске контеншена все carrier‑потоки оказываются заняты, и планировщик не может продвинуть работу. Симптом выглядит как «тихий стоп»: JVM жива, но трафик не обслуживается, часть сокетов зависает в CLOSE_WAIT, стандартные thread dump не показывают проблему.
Решение в JDK 24 (JEP 491) — убрать pinning на уровне мониторов. Виртуальные потоки теперь могут «размонтироваться» внутри synchronized и корректно возвращаться к монитору после ожидания. Это снимает основной класс отказов и делает переход на виртуальные потоки прагматичным выбором для I/O‑нагруженных сервисов. Но это не бесплатный апгрейд. Остались остаточные случаи pinning: нативные вызовы и файловый I/O на Linux. Для них нет штатной интеграции с io_uring, а значит блокировки могут сохраняться. Компромисс очевиден: мы выигрываем в масштабировании и изоляции, но должны принять сетевые и системные ограничения, а также внимательнее смотреть на поведение библиотек.
Реализация требует двух параллельных треков. Первый — наблюдаемость (observability) pinning. JFR должен фиксировать события jdk.VirtualThreadPinned, так как старый флаг трассировки убран. Это позволяет локализовать редкие, но критичные участки. Второй — аудит кода и зависимостей. Главный «тихий» сбой связан с ThreadLocal. Исторически он рассчитан на долгоживущие платформенные потоки и повторное использование. В виртуальной модели поток короткоживущий, и ThreadLocalMap создаётся заново на каждый запрос. Кэш «работает», но перестаёт кэшировать: значение пересоздаётся каждый раз без ошибок и предупреждений. Побочный эффект — рост allocation rate и более частые GC. В наблюдениях: виртуальные потоки показывают более «зубчатый» профиль heap и дополнительные паузы GC, но при этом остаются быстрее по throughput и p99 latency. GC не становится бутылочным горлышком, но его активность растёт пропорционально аллокациям.
Практический приём — инструментировать ThreadLocal. Переопределить initialValue() и считать обращения. Это быстро выявляет «кэш, который не кэширует». Параллельно нужно пересмотреть паттерны: заменить ThreadLocal на Scoped Values (где применимо), вынести тяжёлые объекты в явные пулы или сделать их статeless. Отдельная зона — connection pool. Когда ограничение потоков исчезает, узкое место смещается в downstream: БД и внешние API. Увеличение числа конкурентных запросов без пересмотра лимитов приводит к росту очередей и ухудшению tail latency. Здесь нет магии JDK: требуется согласовать pool size, rate limiting и пропускную способность зависимостей (throughput).
Результаты контролируемого бенчмарка подтверждают двойственную картину. Для I/O‑путей с искусственной задержкой downstream виртуальные потоки достигают примерно в 2–3 раза большего throughput при сопоставимом числе ресурсов. p99 latency стабильно ниже, чем у платформенных потоков. Одновременно растёт объём аллокаций и частота GC‑пауз (в пределах нескольких миллисекунд), что отражает отказ от повторного использования ThreadLocal‑кэшей. Метрики показывают смещение узкого места: от управления пулом потоков к управлению внешними ресурсами и аллокациями. Важно, что без изменений в коде можно получить «скрытые» деградации: пропавшую передачу контекста (InheritableThreadLocal) и неочевидные кэш‑промахи.
Итог выглядит как эволюционное улучшение, а не универсальное ускорение. Виртуальные потоки Java хорошо подходят для I/O‑интенсивных сервисов и упрощают модель конкурентности. Но успешное внедрение — это не только флаг в конфигурации. Нужны проверки pinning через JFR, аудит ThreadLocal, пересмотр пулов подключений и ожиданий от downstream. Иначе система действительно станет быстрее — но в другом месте начнёт терять устойчивость.