Виртуальные потоки (Loom)
Виртуальные потоки против платформенных, потоки-носители и pinning, Scoped Values как финальная замена ThreadLocal и Structured Concurrency, пока это ещё preview-API.
7 вопросов
JuniorТеорияОчень частоЧто такое виртуальный поток и чем он отличается от платформенного?
Что такое виртуальный поток и чем он отличается от платформенного?
Виртуальный поток — лёгкий поток, которым управляет JVM, а не ОС. Множество таких потоков мультиплексируется на небольшой пул платформенных (несущих) потоков; блокирующий вызов снимает виртуальный поток с несущего и освобождает его, поэтому миллионы потоков стоят дёшево. Платформенный поток — обёртка над одним потоком ОС и дорог. Виртуальные потоки финальны с Java 21.
Типичные ошибки
- ✗Думать, что виртуальный поток владеет потоком ОС всю жизнь, как платформенный
- ✗Полагать, что блокирующий код надо переписать в неблокирующий ради выгоды от виртуальных потоков
- ✗Ждать, что виртуальные потоки ускорят CPU-bound работу, а не только I/O-bound конкурентность
Уточняющие вопросы
- →Почему блокирующий I/O на виртуальном потоке не занимает его несущий поток?
- →Когда создание миллионов виртуальных потоков всё равно не повышает пропускную способность?
MiddleТеорияИногдаЧто такое pinning несущего потока и как его изменила Java 24?
Что такое pinning несущего потока и как его изменила Java 24?
Pinning — это ситуация, когда виртуальный поток не может сняться с несущего во время блокировки, поэтому несущий поток остаётся занят и пропускная способность падает до размера пула. До Java 24 блокировка внутри synchronized-блока приводила к pinning, потому что монитор принадлежал несущему потоку. JEP 491 в Java 24 сделал так, что монитор переезжает вместе с виртуальным потоком, и synchronized больше не вызывает pinning; закрепляют только нативные кадры, например вызов JNI.
Типичные ошибки
- ✗Всё ещё переписывать каждый
synchronized-блок наReentrantLockна Java 24, где мониторы больше не закрепляют поток - ✗Путать pinning с просто занятым виртуальным потоком: CPU-bound цикл занимает несущий, но не закрепляет его
- ✗Полагать, что больший пул несущих потоков лечит pinning, тогда как он лишь маскирует потерю пропускной способности
Уточняющие вопросы
- →Как обнаружить pinning в проде на той JDK, где
synchronizedещё закреплял поток? - →Какие кадры стека всё ещё закрепляют виртуальный поток на несущем после JEP 491?
MiddleТеорияИногдаЧем ScopedValue отличается от ThreadLocal при виртуальных потоках?
Чем ScopedValue отличается от ThreadLocal при виртуальных потоках?
ScopedValue привязывается неизменяемо на время выполнения вызова ScopedValue.where(...).run(...) и отвязывается сам, когда вызов возвращается; код внутри может его читать, но не может задать. ThreadLocal изменяем, живёт столько же, сколько его поток, и требует ручной очистки — а при миллионах виртуальных потоков каждая запись на поток стоит памяти. Scoped values передаются потокам, порождённым внутри области, по ссылке и финальны в Java 25 (JEP 506).
Типичные ошибки
- ✗Называть
ScopedValueизменяемым — методаset()у него нет; другое значение требует новой привязкиwhere(...) - ✗Держать контекст запроса в
ThreadLocalв сервисе с миллионами виртуальных потоков и считать записи бесплатными - ✗Забывать, что
ThreadLocal, выставленный на виртуальном потоке, всё равно требуетremove(), иначе живёт весь поток
Уточняющие вопросы
- →Как поток, порождённый внутри привязки
ScopedValue, видит значение вызывающего? - →Когда
ThreadLocalвсё ещё уместнее, чемScopedValue?
MiddleДизайнИногдаОбработчик страницы товара веерно обращается к трём сервисам — склад, цены и отзывы. Сейчас он отправляет три задачи в общий ExecutorService и затем по очереди вызывает get() на каждом Future; таймаут навязывает вызывающий, а отмена работает как получится. На ревью коллега предлагает переписать веер на StructuredTaskScope — API структурной конкурентности (JEP 505, в Java 25 всё ещё preview), — порождая каждую подзадачу на виртуальном потоке. Займите позицию: что модель структурной области задач даёт этому обработчику такого, чего не даёт ручной веер из Future, и что вы противопоставите переходу на preview-API?
Обработчик страницы товара веерно обращается к трём сервисам — склад, цены и отзывы. Сейчас он отправляет три задачи в общий ExecutorService и затем по очереди вызывает get() на каждом Future; таймаут навязывает вызывающий, а отмена работает как получится. На ревью коллега предлагает переписать веер на StructuredTaskScope — API структурной конкурентности (JEP 505, в Java 25 всё ещё preview), — порождая каждую подзадачу на виртуальном потоке. Займите позицию: что модель структурной области задач даёт этому обработчику такого, чего не даёт ручной веер из Future, и что вы противопоставите переходу на preview-API?
StructuredTaskScope привязывает время жизни подзадач к области: подзадачи порождаются на виртуальных потоках, и выйти из области нельзя, пока каждая из них не завершилась или не была отменена, поэтому ничего не переживает запрос. Сбой сразу отменяет соседние подзадачи, вместо того чтобы держать вызывающего в get(), а связь родитель–потомок остаётся видна в thread dump. Цена: в Java 25 это всё ещё preview-API — нужен --enable-preview, и форма API может измениться между релизами.
Типичные ошибки
- ✗Считать
StructuredTaskScopeфинальным API и внедрять его, не взвесив риск preview - ✗Полагать, что ручной веер из
Futureуже отменяет соседние задачи при сбое одного вызова - ✗Продавать переписывание как выигрыш в пропускной способности, а не как гарантию времени жизни и отмены
Уточняющие вопросы
- →Что происходит с соседними подзадачами, когда одна из них бросает исключение внутри области?
- →Как ограничить риск от вывода preview-API в продакшен?
SeniorДизайнИногдаШлюз проставляет каждому запросу correlation id и кладёт его в MDC — контекстную карту логгера на основе ThreadLocal, — чтобы id попадал в каждую строку лога. После перевода обработки на один виртуальный поток на запрос сломались две вещи: строки лога от подзадач, порождённых обработчиком, больше не несут id, а нагрузочный тест показывает, что карты MDC живут заметно дольше самого запроса. Запрос может веерно порождать несколько подзадач, а сторонняя HTTP-библиотека логирует через тот же MDC. Спроектируйте на Java 25, как переносить correlation id по этому сервису, и прямо назовите, чем этот дизайн жертвует.
Шлюз проставляет каждому запросу correlation id и кладёт его в MDC — контекстную карту логгера на основе ThreadLocal, — чтобы id попадал в каждую строку лога. После перевода обработки на один виртуальный поток на запрос сломались две вещи: строки лога от подзадач, порождённых обработчиком, больше не несут id, а нагрузочный тест показывает, что карты MDC живут заметно дольше самого запроса. Запрос может веерно порождать несколько подзадач, а сторонняя HTTP-библиотека логирует через тот же MDC. Спроектируйте на Java 25, как переносить correlation id по этому сервису, и прямо назовите, чем этот дизайн жертвует.
Привяжите id как ScopedValue (финален в Java 25) вокруг всего запроса — ScopedValue.where(REQUEST_ID, id).run(handler). Он неизменяем, отвязывается при возврате из этого вызова, поэтому после запроса ничего не удерживается, а потоки, порождённые внутри привязки, видят значение по ссылке, так что строки лога из веера сохраняют id без копии на каждый поток. Поскольку сторонний клиент логирует через MDC, оставьте MDC мостом и заполняйте его из scoped value на каждом потоке, который пишет логи. Жертвуем возможностью менять id посреди запроса.
Типичные ошибки
- ✗Считать, что MDC продолжит работать как раньше, когда каждый запрос идёт на своём виртуальном потоке и порождает подзадачи
- ✗Полагаться на копируемый контекст
InheritableThreadLocalпри масштабе виртуальных потоков, где каждая копия на поток стоит памяти - ✗Забывать, что логгер по-прежнему читает MDC, поэтому scoped value нужно в него пробросить
Уточняющие вопросы
- →Как подзадача, порождённая внутри привязки
ScopedValue, получает значение вызывающего? - →Что должен писать логгер, когда correlation id читают вне какой-либо привязки?
SeniorДизайнИногдаСервис заказов работает на Tomcat с фиксированным пулом из 200 потоков на запросы. Под нагрузкой пул насыщается и запросы встают в очередь, при этом CPU загружен на 20%, потому что каждый запрос делает три блокирующих вызова JDBC и HTTP. Вас просят перевести его на виртуальные потоки на Java 24. В коде есть synchronized-блок вокруг горячего кэша в памяти, один эндпоинт, вызывающий кодек изображений через JNI, контекст арендатора в ThreadLocal и общий пул из 50 соединений JDBC. Опишите, как вы проведёте миграцию, что проверите, прежде чем считать проблему пропускной способности решённой, и где виртуальные потоки не помогут.
Сервис заказов работает на Tomcat с фиксированным пулом из 200 потоков на запросы. Под нагрузкой пул насыщается и запросы встают в очередь, при этом CPU загружен на 20%, потому что каждый запрос делает три блокирующих вызова JDBC и HTTP. Вас просят перевести его на виртуальные потоки на Java 24. В коде есть synchronized-блок вокруг горячего кэша в памяти, один эндпоинт, вызывающий кодек изображений через JNI, контекст арендатора в ThreadLocal и общий пул из 50 соединений JDBC. Опишите, как вы проведёте миграцию, что проверите, прежде чем считать проблему пропускной способности решённой, и где виртуальные потоки не помогут.
Замените фиксированный пул на один виртуальный поток на запрос и оставьте блокирующие вызовы ровно как есть — в этом и смысл миграции. На Java 24 synchronized вокруг кэша больше не закрепляет несущий поток (JEP 491) и может остаться; кодек через JNI по-прежнему закрепляет несущий, поэтому этот эндпоинт стоит держать на ограниченном пуле платформенных потоков. Контекст в ThreadLocal продолжит работать. Настоящим пределом теперь становится пул из 50 соединений: ограничьте конкурентность явно, иначе вы лишь передвинули очередь. CPU-bound работа не выиграет ничего.
Типичные ошибки
- ✗Полагать, что неограниченные виртуальные потоки снимают необходимость ограничивать доступ к пулу соединений фиксированного размера
- ✗Переписывать
synchronized-блоки наReentrantLockна Java 24, где мониторы больше не закрепляют несущий поток - ✗Ждать роста пропускной способности от виртуальных потоков на CPU-bound эндпоинте
Уточняющие вопросы
- →Как ограничить конкурентность против пула из 50 соединений, когда запросы идут на виртуальных потоках?
- →Какой эндпоинт вы оставите на пуле платформенных потоков после миграции и почему?
SeniorДизайнИногдаВы начинаете новый эндпоинт сводки заказа: он должен параллельно вызвать четыре внутренних сервиса, вернуть результат, когда все четыре успешны, бросить остальные, как только хоть один упал, и уложиться в дедлайн 300 мс. Команда уже пишет цепочки CompletableFuture в других местах и мучается с ними в эксплуатации — стек-трейсы теряют место вызова, а отмена стадии не гарантирует остановку уже идущей за ней работы. Коллега предлагает вместо этого StructuredTaskScope на виртуальных потоках, но в Java 25 это всё ещё preview-API (JEP 505, пятое preview), а сервис уходит в прод в следующем квартале. Обоснуйте решение: что модель области задач даёт по сравнению с CompletableFuture и как вы взвесите это против запуска боевой фичи на preview-API.
Вы начинаете новый эндпоинт сводки заказа: он должен параллельно вызвать четыре внутренних сервиса, вернуть результат, когда все четыре успешны, бросить остальные, как только хоть один упал, и уложиться в дедлайн 300 мс. Команда уже пишет цепочки CompletableFuture в других местах и мучается с ними в эксплуатации — стек-трейсы теряют место вызова, а отмена стадии не гарантирует остановку уже идущей за ней работы. Коллега предлагает вместо этого StructuredTaskScope на виртуальных потоках, но в Java 25 это всё ещё preview-API (JEP 505, пятое preview), а сервис уходит в прод в следующем квартале. Обоснуйте решение: что модель области задач даёт по сравнению с CompletableFuture и как вы взвесите это против запуска боевой фичи на preview-API.
Модель области даёт удержание времени жизни: подзадачи идут на виртуальных потоках, первый сбой или дедлайн отменяет соседние, и выйти из области нельзя, пока жива хоть одна подзадача, — ровно там цепочки CompletableFuture и подводят. Против: в Java 25 StructuredTaskScope всё ещё preview, требует --enable-preview и при компиляции, и в рантайме, и не даёт обещаний совместимости. Спрячьте его за интерфейсом или выпускайте релиз на обычных виртуальных потоках, пока API не станет финальным.
Типичные ошибки
- ✗Полагать, что
CompletableFuture.allOf(...).orTimeout(...)действительно останавливает работу за этими future - ✗Считать
--enable-previewфлагом только для компиляции, тогда как JVM требует его и в рантайме - ✗Продавать структурную конкурентность как выигрыш в пропускной способности, а не как гарантию отмены и времени жизни
Уточняющие вопросы
- →Что
CompletableFuture.cancel(true)на самом деле делает с задачей, уже выполняющейся за этим future? - →Как удержать зависимость от preview-API от расползания по кодовой базе?