JavaRush /Курси /JAVA 25 SELF /Scoped values і нові механізми потоків (Java 21+)

Scoped values і нові механізми потоків (Java 21+)

JAVA 25 SELF
Рівень 57 , Лекція 4
Відкрита

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
Безпека Ризик витоків, плутанини Немає витоків, немає плутанини
Віртуальні потоки Неефективний і небезпечний Ідеально підходить
Використання
set/get
where(...).run(...), get
Вкладеність Не підтримує перевизначення Можна перекривати значення

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?

  • У поодиноких випадках, коли потік живе дуже довго і контекст має бути «постійним» протягом усього його часу життя (наприклад, під час роботи зі спадковим кодом).

Обмеження

  • 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 (якщо це виправдано).

1
Опитування
Virtual Threads, рівень 57, лекція 4
Недоступний
Virtual Threads
Virtual Threads
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ