Gymterview
senior

В чём разница между ScopedValue и ThreadLocal?

ScopedValue (JEP 464, preview в Java 21+) и ThreadLocal решают одну задачу — передача контекстных данных через стек вызовов без явной передачи параметров. Однако они фундаментально различаются в дизайне.

ThreadLocal — мутабельный контекст с ручным управлением:

Пример
private static final ThreadLocal<String> USER_ID = new ThreadLocal<>();

void handleRequest(String userId) {
    USER_ID.set(userId);
    try {
        processRequest(); // может читать USER_ID.get() на любом уровне вложенности
    } finally {
        USER_ID.remove(); // ОБЯЗАТЕЛЬНО очистить!
    }
}

ScopedValue — иммутабельный контекст с автоматическим временем жизни:

Пример
private static final ScopedValue<String> USER_ID = ScopedValue.newInstance();

void handleRequest(String userId) {
    ScopedValue.runWhere(USER_ID, userId, () -> {
        processRequest(); // может читать USER_ID.get()
    });
    // После выхода из runWhere значение автоматически недоступно
}

Сравнительная таблица:

Характеристика ThreadLocal ScopedValue
Изменяемость Мутабельный (set/get/remove) Иммутабельный (значение задаётся раз для scope)
Время жизни Пока жив поток или пока не вызван remove() Ограничено блоком runWhere/callWhere
Наследование дочерними потоками InheritableThreadLocal — копирование Автоматически в StructuredTaskScope.fork()
Производительность Хэш-таблица в каждом потоке Оптимизирован для чтения
Утечки памяти Частая проблема (забытый remove()) Невозможны — автоматическая очистка
Virtual Threads Проблематичен при миллионах потоков Создан для Virtual Threads
Код: ScopedValue с StructuredTaskScope
private static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();

String handleRequest(String requestId) throws Exception {
    return ScopedValue.callWhere(REQUEST_ID, requestId, () -> {
        try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
            Subtask<String> userData = scope.fork(() -> {
                String id = REQUEST_ID.get(); // доступно!
                return fetchUser(id);
            });
            Subtask<String> orderData = scope.fork(() -> {
                String id = REQUEST_ID.get(); // доступно!
                return fetchOrders(id);
            });
            scope.join();
            scope.throwIfFailed();
            return userData.get() + " : " + orderData.get();
        }
    });
}

Перебиндинг во вложенном scope:

ScopedValue можно «переопределить» во вложенном блоке — внутренний scope видит новое значение, внешний — старое:

Пример
ScopedValue.runWhere(USER_ID, "admin", () -> {
    System.out.println(USER_ID.get()); // "admin"
    ScopedValue.runWhere(USER_ID, "superadmin", () -> {
        System.out.println(USER_ID.get()); // "superadmin"
    });
    System.out.println(USER_ID.get()); // "admin" — восстановлено
});

Когда по-прежнему нужен ThreadLocal:

  • Значение должно изменяться в процессе выполнения (накопление данных).
  • Код работает на Java < 21.

Частые ошибки:

  • ThreadLocal с Virtual Threads без remove() — утечка памяти при миллионах потоков.
  • ScopedValue.get() вне runWhere/callWhere — бросит NoSuchElementException.
  • InheritableThreadLocal с пулом потоков — «чужое» значение при переиспользовании потока.

Аналогия: ThreadLocal — это доска с записями на рабочем столе каждого сотрудника: он может писать и стирать что угодно, и если забудет стереть — следующий за этим столом увидит чужие записи. ScopedValue — это бейджик, который надевается на время совещания и снимается автоматически при выходе.

На собеседовании ключевой вопрос: «Зачем нужен ScopedValue, если есть ThreadLocal?» Ответ: ScopedValue иммутабелен (нет race condition), не требует ручной очистки (нет утечек) и оптимизирован для Virtual Threads (нет хэш-таблицы на поток).