← Заметки

JDK 25 ScopedValue: контекст запроса за пределами ThreadLocal

На этой странице

В JDK 25 ScopedValue — окончательный API для привязки данных, например контекста запроса, к ограниченной области выполнения и передачи их от вызывающего к вызываемому коду. Он получил окончательный статус в JEP 506, поэтому флаги предварительных возможностей не нужны. Привязка действует во время выполнения операции, переданной в run() или call(); нормальное завершение или исключение восстанавливает предыдущее состояние.

Чем он отличается от ThreadLocal?

Рассмотрите его, если передача идентификатора арендатора или трассировки в глубину сервисного кода иначе потребует добавлять параметр в каждый промежуточный метод. Обычные параметры могут оставаться понятнее, если значение является явным входом бизнес-логики.

ThreadLocal изменяет значение текущего потока через set() и удаляет его через remove(). Использование в пределах запроса требует аккуратной очистки, обычно в finally. Вместо того чтобы оставлять значение установленным, ScopedValue запускает нуждающуюся в нём операцию внутри привязки.

Критерий ThreadLocal ScopedValue
Доступ к данным Чтение и изменение значения, сохранённого для текущего потока Чтение привязки в текущей динамической области выполнения
Конец области Явное управление очисткой данных запроса Восстановление предыдущей привязки при завершении run() или call()
Другое значение в вызываемом коде set() может изменить существующее сохранённое значение Повторная привязка во вложенной области с последующим восстановлением внешнего значения
Подходящие применения В частности, изменяемое состояние на поток В частности, контекст запроса, передаваемый от вызывающего к вызываемому коду

ScopedValue не заменяет все применения ThreadLocal и не объявляет его устаревшим. Поле private static final хранит ключ доступа, а не само значение контекста. Один ключ может разрешаться в разные значения в зависимости от выполняющего потока и области.

Наблюдение области и восстановления в виртуальном потоке

Привязка внешнего значения в виртуальном потоке, восстановление после вложенного выполнения и ошибки, проверка отсутствия наследования обычным дочерним потоком и выход из области

Схема соответствует примеру ниже. Внешний арендатор — tenant-a; вложенная область читает tenant-b. Ошибка вложенной области перехватывается за её пределами перед проверкой восстановления. Пример демонстрирует семантику контекста запроса без HTTP-сервера или интеграции с фреймворком.

Запустите его в каталоге, где javac и java указывают на одну установку JDK 25. Средой локальной проверки была Eclipse Temurin 25.0.5+8 на macOS AArch64. ScopedValue относится к java.lang и не требует явного импорта.

ScopedContextExample.java

import java.util.NoSuchElementException;
import java.util.concurrent.Executors;

public class ScopedContextExample {
    private static final ScopedValue<RequestContext> CONTEXT = ScopedValue.newInstance();
    record RequestContext(String tenant, String traceId) {}

    public static void main(String[] args) throws Exception {
        try (var tasks = Executors.newVirtualThreadPerTaskExecutor()) {
            tasks.submit(ScopedContextExample::handleRequest).get();
        }
    }

    private static String handleRequest() throws InterruptedException {
        System.out.println("virtual=" + Thread.currentThread().isVirtual());
        System.out.println("before=" + CONTEXT.isBound());
        String result = ScopedValue.where(CONTEXT,
                new RequestContext("tenant-a", "trace-123")).call(() -> {
            System.out.println("outer=" + service());
            ScopedValue.where(CONTEXT,
                    new RequestContext("tenant-b", "trace-123")).run(() ->
                System.out.println("nested=" + service()));
            System.out.println("restored=" + service());

            try {
                ScopedValue.where(CONTEXT,
                        new RequestContext("tenant-fail", "trace-123")).run(() -> {
                    throw new IllegalStateException("demonstration");
                });
            } catch (IllegalStateException expected) {
                System.out.println("after-failure=" + service());
            }

            Thread child = Thread.ofVirtual().start(() ->
                System.out.println("plain-child-bound=" + CONTEXT.isBound()));
            child.join();
            return service();
        });
        System.out.println("result=" + result);
        System.out.println("after=" + CONTEXT.isBound());
        try {
            CONTEXT.get();
        } catch (NoSuchElementException expected) {
            System.out.println("outside=" + expected.getClass().getSimpleName());
        }
        return result;
    }

    private static String service() {
        RequestContext context = CONTEXT.get();
        return context.tenant() + "/" + context.traceId();
    }
}
javac --release 25 ScopedContextExample.java
java ScopedContextExample

Этот стандартный вывод наблюдался без параметров предварительных возможностей. Вызов get() у Future передаёт ошибки задачи вызывающему коду; join() дожидается вывода дочернего потока.

virtual=true
before=false
outer=tenant-a/trace-123
nested=tenant-b/trace-123
restored=tenant-a/trace-123
after-failure=tenant-a/trace-123
plain-child-bound=false
result=tenant-a/trace-123
after=false
outside=NoSuchElementException

service() читает текущую привязку без параметра контекста. Используйте run() для операции без результата и Carrier.call() для операции, возвращающей результат или передающей исключение. Само создание Carrier через where() не активирует привязку.

И restored, и after-failure показывают внешнее значение. После завершения внешнего call() метод isBound() возвращает false, а get() без привязки вызывает NoSuchElementException. Выход из привязки не гарантирует немедленной сборки мусора для объекта по ссылке и не закрывает автоматически ресурс AutoCloseable.

Наследует ли значение создаваемый виртуальный поток?

Обычный Thread.ofVirtual().start() не наследует автоматически привязку родителя. Пример показывает это через plain-child-bound=false. Не предполагайте также автоматической передачи контекста при передаче работы в ExecutorService или CompletableFuture. Первый виртуальный поток примера устанавливает привязку внутри задачи, а не наследует её.

Официальные правила наследования описывают захват привязок при создании StructuredTaskScope и их наследование дочерними задачами, запущенными через fork() этой области. Структурированная конкурентность — отдельный предварительный API в JDK 25, описанный в JEP 505; код этой статьи его не использует. Не считайте пример проверкой этого механизма наследования.

Границы применения контекста запроса

Неизменяемая привязка не делает объект по ссылке глубоко неизменяемым. В примере используется record с двумя строками; изменяемая коллекция потребовала бы отдельного проектирования конкурентного доступа. Ограничивайте также доступ к ключу.

Проверяйте API JDK 25, а не копируйте без изменений старые примеры предварительных версий JDK 21 и подобных выпусков. В частности, JDK 25 не допускает orElse(null). Привязка к null также отличается от отсутствия привязки.

Сначала отличите ThreadLocal, передающий данные запроса по цепочке вызовов, от ThreadLocal, поддерживающего изменяемое состояние во время выполнения. Для первого спроектируйте и привязку в точке входа, и асинхронные границы. Транзакции фреймворка, MDC и контекст безопасности автоматически не подключаются; этот пример не проверяет такие интеграции или производительность. О поведении самих виртуальных потоков читайте в статье о возможностях JDK 21.

Обсуждение

Посмотреть на GitHub ↗