Здравствуйте!
В предыдущем уроке мы разобрали, как классический механизм синхронизации Java, synchronized, может стать "бутылочным горлышком" при работе с виртуальными потоками из-за "pinning". Мы увидели, что для эффективного использования виртуальных потоков необходимо пересматривать устоявшиеся практики. Сегодня мы продолжим эту тему и обратим внимание на другой, казалось бы, безобидный инструмент из арсенала Java-разработчика — ThreadLocal.
Цель этого урока — объяснить, почему ThreadLocal, несмотря на свою полезность в прошлом, создает серьезные проблемы производительности и безопасности при использовании с виртуальными потоками. Затем мы изучим современную альтернативу — ScopedValue (JEP 506), которая была разработана специально для новой модели конкурентности в Java и решает все фундаментальные недостатки своего предшественника.
Наследие ThreadLocal: зачем он был нужен?
ThreadLocal появился в Java 1.2 и решал важную задачу: передачу контекста (ID пользователя, ID транзакции, данные для логирования) по стеку вызовов без необходимости "загрязнять" сигнатуры всех методов. В модели "один системный поток на запрос" это работало приемлемо. У нас было небольшое, фиксированное количество потоков в пуле, и накладные расходы на хранение небольшого Map для каждого потока были незначительны.
Однако, как и в случае с synchronized, то, что работало для сотен системных потоков, становится проблемой для миллионов виртуальных.
Java 20 - From ThreadLocal to ScopedValue with Loom Full Tutorial - JEP Café #16
Давайте начнем с краткого обзора от команды Java. В этом видео объясняется, что такое ThreadLocal, для чего он был создан, и какие фундаментальные проблемы в его дизайне существовали с самого начала.
Посмотрите фрагмент, где рассказывается о назначении ThreadLocal (что это такое), а затем сразу переходите к разбору его ключевых недостатков (в чем его недостатки). Обратите внимание на три аспекта: изменяемость, время жизни и стоимость наследования.
Три фундаментальные проблемы ThreadLocal в эпоху виртуальных потоков
Как вы только что услышали, у ThreadLocal есть три основных недостатка. С появлением виртуальных потоков эти недостатки превращаются из теоретических неудобств в критические проблемы, способные обрушить ваше приложение.
-
Неограниченная изменяемость (Unconstrained Mutability): Любой код, имеющий доступ к
ThreadLocal, может вызвать методset()в любое время. Это создает неявные потоки данных, которые сложно отслеживать и отлаживать. Данные могут быть изменены в глубине стека вызовов, и код "выше" по стеку получит неожиданное значение. -
Неограниченное время жизни (Unbounded Lifetime): Значение в
ThreadLocalхранится до тех пор, пока поток не умрет или пока вы явно не вызоветеremove(). Забытый вызовremove()вfinallyблоке — классический рецепт утечки памяти. В мире виртуальных потоков, которые создаются и умирают для каждой задачи, это может привести к тому, что контекст одного запроса "протечет" в другой, если по ошибке используются пулы системных потоков. -
Дорогое наследование (Expensive Inheritance): Это самая катастрофическая проблема для виртуальных потоков.
InheritableThreadLocalпозволяет дочерним потокам наследовать значения от родительского. Реализовано это пессимистично: полным копированием всехThreadLocalпеременных. Когда вы создаете тысячи или миллионы виртуальных потоков, каждый из них создает собственную копию данных родителя. Это приводит к взрывному росту потребления памяти.
Следующая статья блестяще резюмирует эти проблемы в удобной таблице.
ThreadLocal vs. Scoped Values:The Virtual Thread Migration No ...
Эта статья от Java Code Geeks очень четко описывает, как именно недостатки ThreadLocal проявляются в контексте виртуальных потоков.
Прочтите раздел, озаглавленный "The Three Fundamental Flaws". Особое внимание уделите таблице, которая сопоставляет каждый недостаток с его последствиями для виртуальных потоков.
Визуально проблему с наследованием можно представить так:

Более того, InheritableThreadLocal просто не работает с StructuredTaskScope.fork(), который является предпочтительным способом запуска дочерних задач в новой модели конкурентности. Контекст не будет унаследован, что приведет к молчаливым багам (NullPointerException в лучшем случае).
ScopedValue: безопасная и производительная альтернатива
Для решения этих проблем в рамках Project Loom был разработан ScopedValue (финализирован в Java 25, доступен в preview с JDK 21). Это не просто "улучшенный ThreadLocal", а принципиально иной подход, основанный на трех китах:
- Неизменяемость (Immutability): У
ScopedValueнет методаset(). Значение привязывается один раз на определенную "область видимости" (scope) и не может быть изменено. Это гарантирует однонаправленный поток данных: от вызывающего к вызываемому. - Ограниченное время жизни (Bounded Lifetime): Значение существует только во время выполнения кода, переданного в методы
run()илиcall(). Никаких ручныхremove()и никаких утечек — очистка происходит автоматически по завершении scope. - Дешевое наследование (Cheap Inheritance): Поскольку значения неизменяемы, дочерние виртуальные потоки (особенно запущенные через
StructuredTaskScope) могут безопасно разделять (share) привязку родителя, а не копировать ее. Затраты на наследование — O(1), а не O(N), где N — количество переменных.
Давайте посмотрим, как это работает на практике.
В этом видео автор демонстрирует миграцию с ThreadLocal на ScopedValue и наглядно показывает ключевые отличия в API и поведении.
Начните с краткого обзора проблем ThreadLocal (три проблемы). Затем посмотрите, как ScopedValue решает проблему неограниченной изменяемости с помощью where().run() (решение через scope), как он решает проблему неограниченного времени жизни (решение проблемы lifetime) и, наконец, как он элегантно работает с наследованием в StructuredTaskScope (решение проблемы наследования).
Практическое применение: от ThreadLocal к ScopedValue
Давайте рассмотрим канонический пример — передача контекста запроса в веб-фреймворке.
Стиль ThreadLocal (старый подход):
public class Framework_ThreadLocal {
// Поле обычно private, чтобы ограничить мутации, но это не всегда возможно
public static final ThreadLocal<RequestContext> CONTEXT = new InheritableThreadLocal<>();
public void serve(Request request) {
RequestContext ctx = createContext(request);
CONTEXT.set(ctx);
try {
// Вызов бизнес-логики приложения
Application.handle(request);
} finally {
// КРИТИЧЕСКИ ВАЖНО: не забыть очистить, иначе утечка!
CONTEXT.remove();
}
}
}
// Где-то в глубине бизнес-логики
public class SomeService {
public void process() {
RequestContext ctx = Framework_ThreadLocal.CONTEXT.get();
// ... используем ctx ...
// Любой код здесь может вызвать CONTEXT.set(newCtx), "сломав" состояние для всех.
}
}
Стиль ScopedValue (современный подход):
public class Framework_ScopedValue {
// Поле public final, так как оно неизменяемо и безопасно для доступа
public static final ScopedValue<RequestContext> CONTEXT = ScopedValue.newInstance();
public void serve(Request request) {
RequestContext ctx = createContext(request);
// Привязываем значение на время выполнения лямбды. Очистка не нужна.
ScopedValue.where(CONTEXT, ctx)
.run(() -> Application.handle(request));
}
}
// Где-то в глубине бизнес-логики
public class SomeService {
public void process() {
RequestContext ctx = Framework_ScopedValue.CONTEXT.get();
// ... используем ctx ...
// Вызвать CONTEXT.set(...) невозможно, это ошибка компиляции.
}
}
Разница очевидна: код с ScopedValue проще, безопаснее и яснее выражает намерение — "выполнить этот блок кода с вот этим контекстом".
Вот наглядное сравнение ключевых характеристик:

Полный пример с StructuredTaskScope
ScopedValue особенно хорошо раскрывается в связке со структурным параллелизмом. Все дочерние задачи, запущенные через scope.fork(), автоматически и эффективно наследуют все привязки.
ThreadLocal vs. Scoped Values:The Virtual Thread Migration No ...
В этой же статье есть отличный, готовый к запуску пример, который демонстрирует все аспекты ScopedValue: объявление, привязку, автоматическое наследование в дочерних потоках и локальное переопределение (rebinding).
Найдите раздел "A Complete Working Example" и изучите приведенный код. Попробуйте мысленно проследить за значениями TENANT и USER_ID в основном потоке и в двух дочерних задачах. Обратите внимание, как переопределение TENANT в "Child B" не влияет на другие потоки.
Когда ThreadLocal все еще может быть нужен?
Важно понимать, что ScopedValue не является полной заменой ThreadLocal во всех случаях, хотя и покрывает 99% распространенных сценариев. ThreadLocal может оставаться осмысленным выбором в очень специфических ситуациях:
- Кэширование изменяемых, не потокобезопасных объектов: Классический пример —
SimpleDateFormat. Если вы используете пул системных потоков,ThreadLocalможет использоваться для создания по одному экземпляру на поток. Однако с виртуальными потоками этот паттерн не работает, так как они не пулятся. - Двунаправленная передача данных: Если вызываемый код должен вернуть результат вызывающему через
ThreadLocal.set(). Это считается анти-паттерном, и лучше переписать такой код на использованиеreturn, но в легаси-системах это может встречаться. - Управление состоянием, которое по своей природе изменяемо: Например, состояние транзакции, которое может меняться (
begin,commit,rollback) в рамках одного потока.
ThreadLocal vs. Scoped Values:The Virtual Thread Migration No ...
Чтобы принимать взвешенные решения, важно знать не только преимущества нового инструмента, но и его ограничения.
Прочтите короткий раздел "When Should You NOT Migrate to ScopedValue?" и изучите таблицу, которая сопоставляет сценарии использования с подходящим инструментом.
Заключение
Сегодня мы рассмотрели еще один важный сдвиг в парадигме конкурентного программирования в Java, вызванный появлением виртуальных потоков. ThreadLocal — мощный инструмент своего времени, но его дизайн, ориентированный на изменяемость и ручное управление жизненным циклом, плохо масштабируется на миллионы потоков.
Ключевые выводы:
ThreadLocalс виртуальными потоками создает проблемы: неограниченная изменяемость (риск ошибок), неограниченное время жизни (риск утечек) и дорогое наследование (взрывное потребление памяти).ScopedValue— это современная, безопасная и производительная альтернатива, разработанная для виртуальных потоков и структурированного параллелизма.- Ключевые преимущества
ScopedValue: неизменяемость (immutability), ограниченное время жизни (bounded lifetime) и эффективное наследование без копирования. - Миграция на
ScopedValueупрощает код, устраняет целые классы ошибок и значительно повышает производительность в I/O-интенсивных приложениях на виртуальных потоках. - Для диагностики использования
ThreadLocalв вашем приложении (включая сторонние библиотеки) можно использовать флаг-Djdk.traceVirtualThreadLocals=true.
В следующем уроке мы спустимся еще на один уровень ниже и разберемся, как именно происходит планирование виртуальных потоков. Мы проанализируем поведение планировщика ForkJoinPool, который по умолчанию является "сердцем" всей системы виртуальных потоков в JDK.