1. Почему ThreadLocal теряет актуальность
Для чего вообще нужен ThreadLocal?
В классической многопоточности, где потоки живут долго (например, на сервере), иногда нужно хранить собственные данные для каждого потока — такие, что не должны пересекаться с другими. Например, имя пользователя, ID запроса или временный буфер.
Чтобы это сделать, в Java появился ThreadLocal<T> — некое «личное пространство» потока, где можно хранить данные, не мешая соседям:
ThreadLocal<String> user = new ThreadLocal<>();
user.set("Alice"); // значение сохраняется только для этого потока
String name = user.get(); // вернёт "Alice" именно здесь, в других потоках — null
Почему ThreadLocal не дружит с виртуальными потоками
Виртуальные потоки живут совсем иначе, чем старые «тяжёлые» потоки. Они появляются и исчезают тысячами — иногда за доли миллисекунды. А ThreadLocal привязывает данные к конкретному потоку, будто тот будет жить вечно.
Когда виртуальный поток завершает работу, его данные в ThreadLocal могут остаться висеть в памяти — даже если сам поток уже давно умер. Это приводит к утечкам, ведь JVM не всегда знает, что эти значения больше никому не нужны.
А если потоки переиспользуются (например, в пулах), возможна ещё более неприятная ситуация: «чужой» контекст может случайно перейти к новому запросу. Представьте, пользователь Петя получает данные Васи — и привет, баги и уязвимости.
ThreadLocal отлично чувствует себя там, где потоков немного и они живут долго. Но с виртуальными потоками — это как пытаться хранить вещи в шкафу, который исчезает каждую секунду.
2. Scoped Values: новый способ передавать контекст
Scoped Values — это свежий инструмент из Java 21, который решает старую проблему ThreadLocal, но делает это элегантно. Вместо того чтобы хранить данные внутри потока, как в ThreadLocal, он «прикрепляет» их к области выполнения — то есть к конкретному участку кода. Значение живёт только пока выполняется этот участок, а затем автоматически исчезает, не оставляя следов в памяти.
import java.lang.ScopedValue;
ScopedValue<String> USER = ScopedValue.newInstance();
ScopedValue.where(USER, "Alice").run(() -> {
System.out.println("Hello, " + USER.get()); // Выведет: Hello, Alice
});
Когда код выходит за пределы блока run, значение уже недоступно — попытка обратиться к нему вызовет исключение. Ничего очищать вручную не нужно.
Scoped Values не засоряют память, не путают контекст между потоками и позволяют создавать вложенные области, где внутренние значения временно перекрывают внешние. Это аккуратный, предсказуемый и безопасный способ передавать контекст, особенно в мире виртуальных потоков.
3. Примеры использования Scoped Values
Пример 1: Передача контекста пользователя
Допустим, у нас есть сервер, который обрабатывает запросы от разных пользователей. Для каждого запроса мы хотим знать, кто его инициировал.
import java.lang.ScopedValue;
public class ServerExample {
static final ScopedValue<String> USER = ScopedValue.newInstance();
public static void main(String[] args) {
processRequest("Alice");
processRequest("Bob");
}
static void processRequest(String userName) {
ScopedValue.where(USER, userName).run(() -> {
handleBusinessLogic();
});
}
static void handleBusinessLogic() {
System.out.println("Обрабатываем для пользователя: " + USER.get());
}
}
Что произойдёт:
- Для каждого запроса создаётся свой scope, в котором USER равен «Alice» или «Bob».
- Внутри handleBusinessLogic() мы всегда получаем корректное имя пользователя.
- Как только обработка запроса завершена, значение исчезает.
Пример 2: Логирование с контекстом
Допустим, мы хотим автоматически подставлять идентификатор запроса в логи:
import java.lang.ScopedValue;
public class LoggingExample {
static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();
public static void main(String[] args) {
for (int i = 1; i <= 3; i++) {
String reqId = "REQ-" + i;
ScopedValue.where(REQUEST_ID, reqId).run(() -> {
log("Начало обработки");
doWork();
log("Конец обработки");
});
}
}
static void log(String message) {
System.out.printf("[%s] %s%n", REQUEST_ID.get(), message);
}
static void doWork() {
log("Работаем...");
}
}
Результат (пример):
[REQ-1] Начало обработки
[REQ-1] Работаем...
[REQ-1] Конец обработки
[REQ-2] Начало обработки
[REQ-2] Работаем...
[REQ-2] Конец обработки
[REQ-3] Начало обработки
[REQ-3] Работаем...
[REQ-3] Конец обработки
Каждый scope хранит свой идентификатор запроса, и никакая путаница между потоками невозможна.
4. Scoped Values и виртуальные потоки: идеальная пара
Почему Scoped Values особенно полезны с виртуальными потоками
Виртуальные потоки живут недолго — они создаются и уничтожаются тысячами, иногда за доли секунды. Поэтому старый подход с ThreadLocal, где данные жёстко «привязаны» к самому потоку, здесь просто не работает: потоки слишком быстро исчезают, а контекст может случайно утечь или перепутаться.
ScopedValue, наоборот, привязывает данные к самой задаче — к её области выполнения. Это значит, что контекст (например, имя пользователя или ID запроса) следует за кодом, а не за потоком. Когда задача заканчивается, значение автоматически исчезает. Для виртуальных потоков это идеальное решение: безопасное, чистое и без сюрпризов.
Пример: Массовая обработка задач с виртуальными потоками
import java.lang.ScopedValue;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class VirtualThreadScopedValueDemo {
static final ScopedValue<Integer> TASK_ID = ScopedValue.newInstance();
public static void main(String[] args) {
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
for (int i = 1; i <= 10_000; i++) {
int taskId = i;
executor.submit(() -> ScopedValue.where(TASK_ID, taskId).run(() -> {
processTask();
}));
}
executor.shutdown();
}
static void processTask() {
// Для каждой задачи свой TASK_ID
System.out.println("Обрабатываем задачу #" + TASK_ID.get());
}
}
Ключевые моменты:
- Для каждой задачи создаётся свой scope значения TASK_ID.
- Даже если задачи выполняются параллельно, значения не путаются между потоками.
- Нет утечек памяти: scope «умирает» вместе с задачей.
5. Сравнение: ThreadLocal vs ScopedValue
| Критерий | ThreadLocal | ScopedValue |
|---|---|---|
| Привязка | К потоку | К области кода (scope) |
| Жизненный цикл | Пока жив поток | Пока выполняется scope |
| Безопасность | Риск утечек, путаницы | Нет утечек, нет путаницы |
| Виртуальные потоки | Неэффективен, опасен | Идеально подходит |
| Использование | |
|
| Вложенность | Не поддерживает override | Можно перекрывать значения |
6. Вложенные области (scopes): перекрытие значений
ScopedValue<String> INFO = ScopedValue.newInstance();
ScopedValue.where(INFO, "Внешний").run(() -> {
System.out.println(INFO.get()); // "Внешний"
ScopedValue.where(INFO, "Внутренний").run(() -> {
System.out.println(INFO.get()); // "Внутренний"
});
System.out.println(INFO.get()); // "Внешний"
});
Результат:
Внешний
Внутренний
Внешний
Это удобно, если, например, внутри одной задачи нужно временно переопределить значение контекста.
Scoped Values: типичные сценарии использования
- Передача идентификатора пользователя или запроса: чтобы логировать действия или проверять права.
- Логирование: автоматическая подстановка контекста в логи.
- Трассировка: для дебага и профилирования.
- Параметры транзакций: например, уровень изоляции или режим работы.
- Любой «контекст», видимый только в пределах одной задачи (или её подзадач).
7. Другие новые механики: Structured Concurrency
Structured Concurrency — это подход, при котором связанные задачи (например, подпроцессы одной операции) управляются как единое целое: если родительская задача завершилась или упала, все дочерние задачи автоматически отменяются. Это снижает риск «забытых» или «висящих» потоков.
Пример (очень схематично):
try (var scope = StructuredTaskScope.ShutdownOnFailure()) {
Future<String> result1 = scope.fork(() -> fetchData1());
Future<String> result2 = scope.fork(() -> fetchData2());
scope.join(); // ждем завершения обеих
scope.throwIfFailed(); // если хоть одна упала — бросаем исключение
String combined = result1.resultNow() + result2.resultNow();
System.out.println(combined);
}
Преимущества:
- Более чистое управление жизненным циклом задач.
- Нет «висящих» подпроцессов.
- Легче обрабатывать ошибки.
Structured Concurrency пока находится в preview-режиме, но уже активно развивается.
8. Практические советы и ограничения
Когда использовать Scoped Values?
- Всегда, когда нужно передавать контекст между задачами, особенно с виртуальными потоками.
- Если раньше вы использовали ThreadLocal — задумайтесь, не лучше ли перейти на ScopedValue.
Когда всё ещё нужен ThreadLocal?
- В редких случаях, когда поток живёт очень долго и контекст должен быть «постоянным» для всего его времени жизни (например, при работе с legacy-кодом).
Ограничения
- Scoped Values нельзя изменять после создания scope — они «только для чтения».
- Scoped Values нельзя использовать вне scope: попытка получить значение вне области вызовет исключение.
- Не используйте Scoped Values для хранения больших объектов — область должна быть лёгкой и быстрой.
9. Типичные ошибки при использовании Scoped Values
Ошибка №1: попытка получить значение вне scope. Если вызвать USER.get() вне блока ScopedValue.where(...), вы получите исключение NoSuchElementException. Проверьте, что обращение происходит только внутри области.
Ошибка №2: попытка изменить значение внутри scope. Scoped Values — это не изменяемый контейнер. Если нужно временно «переопределить» значение, создайте вложенный scope.
Ошибка №3: использование ThreadLocal и ScopedValue вместе. Не стоит смешивать эти механики без крайней необходимости — это может привести к путанице и ошибкам в контексте.
Ошибка №4: забыли вложить логику в блок run(). Если вы написали ScopedValue.where(USER, "Alice") без .run(() -> { ... }), никакой scope не создастся!
Ошибка №5: попытка использовать Scoped Value для долгоживущей глобальной информации. Для таких целей лучше использовать обычные переменные или ThreadLocal (если оправдано).
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ