Привет, Хабр!
Недавно я написал серию статей про модели конкурентности в разных JVM-языках. И неожиданно именно первая статья, про Java, получила самый заметный положительный отклик у читателей Хабра. Поэтому я решил сделать отдельный подробный обзор актуального на май 2026 года состояния Project Loom.
В Project Loom есть virtual threads, которые стали финальной фичей еще в Java 21. Есть Scoped Values, которые дошли до final в Java 25. И есть Structured Concurrency, которая все еще остается preview API и продолжает меняться.

В этой части будет теория: что вообще меняет Loom в модели Java-конкурентности, зачем нужны virtual threads, почему рядом появились Scoped Values и какую дыру пытается закрыть Structured Concurrency. Код будет, но короткий, скорее для иллюстрации идеи.
Во второй части будет практика: как включить preview Structured Concurrency, какие флаги нужны для javac, Maven и Gradle, и как выглядят рабочие примеры со ScopedValue и StructuredTaskScope.
Погнали.
1. Что такое Project Loom
Project Loom - это большой проект OpenJDK про конкурентность. Его самая известная часть: virtual threads.
Но Loom не только про “миллион потоков”. Основная идея проще: серверный код на Java снова можно писать обычным последовательным способом:
User user = userClient.load(userId); List<Order> orders = orderClient.loadOrders(userId); return render(user, orders);
А не обязательно превращать обычную бизнес-операцию в цепочку callback’ов, CompletableFuture, reactive streams или в локальный филиал теории категорий.
Reactive-подход нужен не везде. Часто backend-код делает обычную работу: сходить в базу, вызвать пару внутренних сервисов, собрать DTO и вернуть ответ. Для такого сценария последовательный код обычно читается проще.
Историческая проблема Java была в том, что обычный Thread дорогой. Он связан с потоком операционной системы, стеком, планировщиком ОС и прочими не бесплатными вещами. Если на каждый запрос, каждое ожидание БД и каждый HTTP-вызов заводить platform thread, то масштабироваться можно, но до определенного момента.
Поэтому мы привыкли к пулам:
ExecutorService executor = Executors.newFixedThreadPool(32);
Пулы были хорошим инженерным компромиссом эпохи дорогих потоков. Но за компромисс приходится платить:
нужно выбирать размер пула;
нужно думать про очередь;
blocking I/O может съесть все worker threads;
один общий пул внезапно становится общей точкой боли;
Futureлегко уезжает жить своей жизнью.
Раньше поток был ресурсом, который надо беречь. После Loom поток снова становится удобной единицей моделирования задачи. Не всегда, не везде, но для типичного I/O-heavy backend - очень часто.
На момент написания актуальный GA-релиз - Java 26. Текущий LTS-релиз - Java 25. JDK 27 уже доступен как early access и несет очередную preview-итерацию Structured Concurrency.
Если смотреть именно на Loom, состояние такое:
virtual threads стали финальной фичей в Java 21;
Scoped Values стали финальной фичей в Java 25;
Structured Concurrency все еще остается preview API.
Эта хронология важна. Loom часто обсуждают как один большой пакет, но разные его части находятся в разном состоянии зрелости. Virtual threads и Scoped Values уже можно рассматривать как нормальные инструменты платформы. Structured Concurrency пока лучше держать в голове как близкое будущее, которое уже можно щупать руками, но осторожно тащить в production.
2. Virtual Threads: дешевые потоки без новой модели мышления
Virtual thread - это поток Java, которым управляет JVM, а не напрямую операционная система. Под капотом много virtual threads выполняются на меньшем количестве carrier threads, то есть обычных platform threads.
Грубая схема:
много virtual threads | v carrier/platform threads | v потоки ОС
Когда virtual thread доходит до блокирующего I/O, JVM во многих случаях может “отмонтировать” его от carrier thread. Carrier thread освобождается и идет выполнять другую работу. Когда I/O завершилось, virtual thread продолжает выполнение.
Для прикладного Java-разработчика код остается обычным:
Thread.startVirtualThread(() -> { User user = userClient.load(userId); List<Order> orders = orderClient.loadOrders(userId); render(user, orders); });
Или через executor:
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { Future<User> user = executor.submit(() -> userClient.load(userId)); Future<List<Order>> orders = executor.submit(() -> orderClient.loadOrders(userId)); return render(user.get(), orders.get()); }
newVirtualThreadPerTaskExecutor() звучит как ересь, если много лет жить с правилом “потоки надо переиспользовать”. Но для virtual threads это нормальная модель: один virtual thread на задачу.
Вот тут и меняется инженерное ощущение. Раньше поток был дорогим объектом инфраструктуры. Его надо было экономить, переиспользовать, прятать в пул и надеяться, что размер пула выбран не слишком плохо. После Loom поток снова становится почти буквальным отражением задачи.
Почти.
Virtual threads не ускоряют CPU-bound код. Если вы считаете хэши, сжимаете видео или гоняете тяжелую математику, то миллион virtual threads не создаст миллион ядер. Скорее наоборот, можно сделать хуже.
Virtual threads не отменяют backpressure. Если база выдерживает 50 одновременных запросов, то запуск 5000 virtual threads просто упрется в тот же лимит, только быстрее. Раньше thread pool случайно был еще и ограничителем. Теперь ограничитель часто надо делать явно: semaphore, rate limiter, connection pool, bulkhead.
Старые инженерные вопросы никуда не делись, просто переехали в более честное место.
3. Что virtual threads не решают
Virtual threads отвечают на вопрос: “как дешево запустить много задач?”
Но они не отвечают на другой вопрос: “как не потерять эти задачи?”
Допустим, у нас есть endpoint, который собирает страницу пользователя:
профиль из одного сервиса;
заказы из второго;
рекомендации из третьего.
Классический код на ExecutorService легко написать так:
Future<User> user = executor.submit(() -> userClient.load(userId)); Future<List<Order>> orders = executor.submit(() -> orderClient.loadOrders(userId)); Future<List<Recommendation>> recommendations = executor.submit(() -> recommendationClient.load(userId)); return new UserPage(user.get(), orders.get(), recommendations.get());
В учебном примере все красиво. В жизни сразу появляются вопросы.
Что будет, если userClient.load() упал быстро, а recommendationClient.load() завис на 5 секунд? Кто отменит остальные задачи? Что увидит thread dump? Что будет, если родительский HTTP-запрос уже отменен клиентом? А если мы забыли вызвать cancel()? А если get() вызвали только для первой future, получили exception и вышли из метода?
Получаются фоновые хвосты. Задача логически принадлежала одному запросу, но технически продолжает жить отдельно.
Это классическая болезнь неструктурированной конкурентности: родительская операция умерла, а ее дети еще бегают где-то по runtime. Потом ты смотришь на графики внешнего API и думаешь: “А кто вообще сейчас туда ходит?”
Именно здесь появляется Structured Concurrency. Но перед ней надо разобрать еще одну часть Loom: как передавать контекст.
4. Scoped Values: контекст с ограниченной областью действия
В backend-коде контекст есть почти всегда:
request id;
tenant id;
текущий пользователь;
trace/span;
security context;
настройки локали.
Классический вариант в Java: ThreadLocal.
private static final ThreadLocal<RequestContext> REQUEST_CONTEXT = new ThreadLocal<>();
Работало? Да. И сейчас работает.
Но у ThreadLocal есть неприятный характер. Значение привязано к потоку, а не к логической операции. Если поток переиспользуется в пуле, забытый remove() превращается в классический источник странностей. В тестах все зеленое, а потом в проде один запрос внезапно видит кусочек контекста другого запроса. Не каждый день, конечно. По праздникам.
С virtual threads ситуация меняется. Потоки становятся дешевыми, их может быть очень много, и использовать ThreadLocal как универсальный контейнер для контекста становится менее приятно. Особенно если в него кладут тяжелые объекты, mutable-состояние или целые графы зависимостей.
ScopedValue предлагает другую модель: значение не “приклеивается к потоку”, а привязывается к ограниченной области выполнения.
public static final ScopedValue<RequestContext> REQUEST = ScopedValue.newInstance();
Использование выглядит так:
return ScopedValue .where(REQUEST, context) .call(() -> service.handle(request));
Внутри service.handle(request) и глубже по стеку можно получить текущий контекст:
RequestContext context = REQUEST.get();
После выхода из call() binding исчезает. Не надо делать remove(). Нельзя случайно переприсвоить значение где-то в середине бизнес-операции. Если REQUEST.get() вызвать вне области, где значение связано, будет ошибка. И это хорошо: баг падает рядом с причиной, а не размазывается по логам.
Есть важное ограничение: scoped value лучше использовать для небольших неизменяемых данных. Не надо класть туда mutable map “на все случаи жизни” или сервис-локатор. Иначе получится тот же глобальный контекст, только в новой упаковке.
Хороший кандидат:
public record RequestContext( String requestId, String tenantId, String userId ) { }
Плохой кандидат:
public final class RequestBag { public final Map<String, Object> everything = new HashMap<>(); }
Второй вариант рано или поздно превращается в помойку. Я такие видел. Сначала туда кладут request id. Потом текущего пользователя. Потом “временно” feature flags. Потом кто-то добавляет EntityManager, потому что очень надо. Через год такой объект уже сложно назвать контекстом: это общий контейнер для всего подряд.
5. Structured Concurrency: управление группой связанных задач
Structured Concurrency вводит простое правило:
Если несколько задач родились как часть одной операции, они должны жить и умирать вместе с этой операцией.
Не глобальный submit в никуда. А scope, внутри которого задачи стартуют, завершаются, отменяются и наблюдаются как единое целое.
В Java эта идея строится вокруг StructuredTaskScope.
Короткая форма:
try (var scope = StructuredTaskScope.open()) { var user = scope.fork(() -> userClient.load(userId)); var orders = scope.fork(() -> orderClient.loadOrders(userId)); scope.join(); return render(user.get(), orders.get()); }
У scope есть владелец: поток, который открыл scope. Только он управляет жизненным циклом. Дочерние задачи запускаются через fork(). Потом владелец вызывает join() и ждет результат по выбранной политике.
Когда scope закрывается, структура не должна оставлять мусор после себя. Если задача не завершилась штатно, scope может ее отменить. Если одна задача упала, политика join’а может отменить остальные. Если сработал timeout, scope опять же знает, кого надо остановить.
Это почти как try-with-resources, только ресурсом становится группа связанных задач.
Вот это мне и нравится в концепции. Она не пытается сделать конкурентность “невидимой”. В коде появляется явная граница: тут создали дочерние задачи, тут дождались результата, тут они больше не должны жить.
6. Joiner: политика ожидания становится явной
В старом коде политика ожидания часто размазана по коду. Где-то get(), где-то cancel(), где-то anyOf(), где-то exceptionally(), где-то забыли обработать один из сценариев.
В текущей preview-линии Structured Concurrency эта политика выносится в Joiner.
Например, “успешны должны быть все”:
try (var scope = StructuredTaskScope.open( Joiner.<String>allSuccessfulOrThrow())) { scope.fork(() -> loadPart("header")); scope.fork(() -> loadPart("body")); scope.fork(() -> loadPart("footer")); return scope.join(); }
Или “достаточно первого успешного ответа”:
try (var scope = StructuredTaskScope.open( Joiner.<Price>anySuccessfulOrThrow())) { scope.fork(() -> priceClientA.load(sku)); scope.fork(() -> priceClientB.load(sku)); scope.fork(() -> priceClientC.load(sku)); return scope.join(); }
Это не про красоту синтаксиса. Это про то, что жизненный цикл группы задач становится частью конструкции, а не набором договоренностей между программистами.
7. Почему Structured Concurrency все еще preview
Virtual threads прошли путь:
JEP 425: preview в JDK 19;
JEP 436: second preview в JDK 20;
JEP 444: final в JDK 21.
Structured Concurrency идет дольше:
JEP 428: incubator в JDK 19;
JEP 437: second incubator в JDK 20;
JEP 453: preview в JDK 21;
JEP 462: second preview в JDK 22;
JEP 480: third preview в JDK 23;
JEP 499: fourth preview в JDK 24;
JEP 505: fifth preview в JDK 25;
JEP 525: sixth preview в JDK 26;
JEP 533: seventh preview для JDK 27.
Почему так долго?
Потому что тут сложнее, чем просто “сделать легкий поток”. Virtual threads можно встроить в существующую модель Thread. Да, внутри JVM там огромная работа, но снаружи программист видит знакомый API.
Structured Concurrency трогает дизайн программирования. Надо ответить на вопросы:
как выражать политику ожидания;
что возвращает
join();как передавать результаты разных типов;
что делать с исключениями;
как должны работать timeouts;
как это будет выглядеть в диагностике;
как не закрыть API слишком рано и не тащить неудобную форму следующие 20 лет.
И API реально менялся.
Ранние preview-версии были ближе к наследникам StructuredTaskScope, например ShutdownOnFailure и ShutdownOnSuccess. Потом в JDK 25 публичные конструкторы заменили на static factory methods вроде StructuredTaskScope.open(). Появилась модель Joiner, где политика ожидания и результат join() становятся явной частью открытия scope.
В JDK 26 шлифовали joiner’ы и timeout’ы. Например, allSuccessfulOrThrow() стал возвращать список результатов, а не поток subtasks. anySuccessfulResultOrThrow() переименовали в более короткий anySuccessfulOrThrow().
В JEP 533 основная правка вокруг исключений. Стандартные joiner’ы теперь идут в сторону ExecutionException, а не preview-специфичного FailedException. Еще добавили типизацию исключения, которое может бросить join(). Звучит мелко, но для Java это не мелочь: checked exceptions, миграция последовательного кода в конкурентный и совместимость с привычной моделью Future.get() очень быстро становятся практическими вопросами.
Занудно? Немного. Но лучше занудно в preview, чем больно после final.
Вывод
Project Loom уже изменил Java через virtual threads. Это финальная фича с Java 21, и она возвращает backend-разработчику право писать нормальный блокирующий код там, где он естественен.
Scoped Values закрывают соседнюю боль: передачу request context без вечного ThreadLocal и без протаскивания служебных параметров через половину приложения.
Structured Concurrency закрывает следующий слой проблемы: жизненный цикл связанных задач. Не просто запустить много virtual threads, а не потерять их, не оставить фоновые хвосты, нормально обработать ошибку, timeout и отмену.
Virtual threads и Scoped Values уже можно брать как рабочие инструменты. Structured Concurrency - ближайшее будущее Java-конкурентности: изучать стоит уже сейчас, а внедрять с учетом preview-статуса.
В следующей части будет меньше теории и больше кода: как включить preview, как собрать примеры, как выглядит ScopedValue в сервисном коде и как использовать StructuredTaskScope, когда нужно параллельно сходить в несколько источников и собрать общий результат.
Если вам близки темы разработки, рефакторинга, архитектуры и стартапов, буду рад видеть вас в моём блоге.
Легких Вам Релизов!