CodeGym /Kursy /JAVA 25 SELF /Scoped Values i nowe mechanizmy wątków (Java 21+)

Scoped Values i nowe mechanizmy wątków (Java 21+)

JAVA 25 SELF
Poziom 57, Lekcja 4
Dostępny

1. Dlaczego ThreadLocal traci na aktualności

Po co w ogóle jest ThreadLocal?

W klasycznej wielowątkowości, gdzie wątki żyją długo (np. na serwerze), czasem trzeba przechowywać własne dane dla każdego wątku — takie, które nie powinny przecinać się z danymi innych. Na przykład nazwę użytkownika, ID żądania albo tymczasowy bufor.

Aby to zrobić, w Javie pojawił się ThreadLocal<T> — coś w rodzaju „prywatnej przestrzeni” wątku, gdzie można trzymać dane, nie przeszkadzając sąsiadom:

ThreadLocal<String> user = new ThreadLocal<>();

user.set("Alice"); // wartość jest zachowana tylko dla tego wątku
String name = user.get(); // zwróci "Alice" tutaj; w innych wątkach — null

Dlaczego ThreadLocal nie współpracuje dobrze z wątkami wirtualnymi

Wątki wirtualne żyją zupełnie inaczej niż stare „ciężkie” wątki. Pojawiają się i znikają tysiącami — czasem w ułamkach milisekundy. A ThreadLocal przywiązuje dane do konkretnego wątku, jakby miał żyć wiecznie.

Gdy wątek wirtualny kończy pracę, jego dane w ThreadLocal mogą pozostać w pamięci — nawet jeśli sam wątek już dawno umarł. Prowadzi to do wycieków, bo JVM nie zawsze wie, że te wartości nie są już nikomu potrzebne.

A jeśli wątki są ponownie wykorzystywane (np. w pulach), możliwa jest jeszcze bardziej nieprzyjemna sytuacja: „cudzy” kontekst może przypadkowo przejść do nowego żądania. Wyobraź sobie, że użytkownik Peter dostaje dane Basil — i witaj, błędy i podatności.

ThreadLocal sprawdza się tam, gdzie wątków jest niewiele i żyją długo. Ale z wątkami wirtualnymi — to jak próba trzymania rzeczy w szafie, która znika co sekundę.

2. Scoped Values: nowy sposób przekazywania kontekstu

Scoped Values — to świeże narzędzie z Java 21, które rozwiązuje stary problem ThreadLocal, ale robi to elegancko. Zamiast przechowywać dane wewnątrz wątku, jak w ThreadLocal, „przypina” je do zakresu wykonania — czyli do konkretnego fragmentu kodu. Wartość żyje tylko tak długo, jak wykonuje się ten fragment, a potem automatycznie znika, nie zostawiając śladów w pamięci.

import java.lang.ScopedValue;

ScopedValue<String> USER = ScopedValue.newInstance();

ScopedValue.where(USER, "Alice").run(() -> {
    System.out.println("Hello, " + USER.get()); // Wypisze: Hello, Alice
});

Gdy kod wychodzi poza blok run, wartość jest już niedostępna — próba odwołania się do niej rzuci wyjątek. Nie trzeba niczego sprzątać ręcznie.

Scoped Values nie zaśmiecają pamięci, nie mieszają kontekstu między wątkami i pozwalają tworzyć zagnieżdżone zakresy, gdzie wewnętrzne wartości tymczasowo przesłaniają zewnętrzne. To schludny, przewidywalny i bezpieczny sposób przekazywania kontekstu, szczególnie w świecie wątków wirtualnych.

3. Przykłady użycia Scoped Values

Przykład 1: Przekazywanie kontekstu użytkownika

Załóżmy, że mamy serwer, który obsługuje żądania od różnych użytkowników. Dla każdego żądania chcemy wiedzieć, kto je zainicjował.

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("Przetwarzamy dla użytkownika: " + USER.get());
    }
}

Co się stanie:

  • Dla każdego żądania tworzony jest własny zakres, w którym USER ma wartość „Alice” lub „Bob”.
  • Wewnątrz handleBusinessLogic() zawsze otrzymujemy poprawną nazwę użytkownika.
  • Gdy tylko obsługa żądania się kończy, wartość znika.

Przykład 2: Logowanie z kontekstem

Załóżmy, że chcemy automatycznie wstawiać identyfikator żądania do logów:

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("Początek przetwarzania");
                doWork();
                log("Koniec przetwarzania");
            });
        }
    }

    static void log(String message) {
        System.out.printf("[%s] %s%n", REQUEST_ID.get(), message);
    }

    static void doWork() {
        log("Pracujemy...");
    }
}

Wynik (przykład):

[REQ-1] Początek przetwarzania
[REQ-1] Pracujemy...
[REQ-1] Koniec przetwarzania
[REQ-2] Początek przetwarzania
[REQ-2] Pracujemy...
[REQ-2] Koniec przetwarzania
[REQ-3] Początek przetwarzania
[REQ-3] Pracujemy...
[REQ-3] Koniec przetwarzania

Każdy zakres przechowuje swój identyfikator żądania i nie ma możliwości pomylenia wartości między wątkami.

4. Scoped Values i wątki wirtualne: idealna para

Dlaczego Scoped Values są szczególnie przydatne z wątkami wirtualnymi

Wątki wirtualne żyją krótko — są tworzone i niszczone tysiącami, czasem w ułamkach sekundy. Dlatego stary sposób z ThreadLocal, gdzie dane są „na sztywno” przypięte do samego wątku, po prostu tu nie działa: wątki znikają zbyt szybko, a kontekst może przypadkiem wyciec albo się pomylić.

ScopedValue przeciwnie — wiąże dane z samym zadaniem, z jego zakresem wykonania. To znaczy, że kontekst (np. nazwa użytkownika albo ID żądania) podąża za kodem, a nie za wątkiem. Gdy zadanie się kończy, wartość automatycznie znika. Dla wątków wirtualnych to rozwiązanie idealne: bezpieczne, czyste i bez niespodzianek.

Przykład: masowe przetwarzanie zadań z wątkami wirtualnymi

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() {
        // Dla każdego zadania własny TASK_ID
        System.out.println("Przetwarzamy zadanie #" + TASK_ID.get());
    }
}

Kluczowe punkty:

  • Dla każdego zadania tworzony jest własny zakres wartości TASK_ID.
  • Nawet jeśli zadania wykonują się równolegle, wartości nie mieszają się między wątkami.
  • Brak wycieków pamięci: zakres „umiera” razem z zadaniem.

5. Porównanie: ThreadLocal vs ScopedValue

Kryterium ThreadLocal ScopedValue
Powiązanie Z wątkiem Z zakresem kodu (scope)
Cykl życia Tak długo, jak żyje wątek Tak długo, jak wykonywany jest zakres
Bezpieczeństwo Ryzyko wycieków, pomyłek Brak wycieków, brak pomyłek
Wątki wirtualne Niewydajny, ryzykowny Idealnie pasuje
Użycie
set/get
where(...).run(...), get
Zagnieżdżanie Brak nadpisywania (override) Można nadpisywać wartości

6. Zagnieżdżone zakresy (scopes): przesłanianie wartości

ScopedValue<String> INFO = ScopedValue.newInstance();

ScopedValue.where(INFO, "Zewnętrzny").run(() -> {
    System.out.println(INFO.get()); // "Zewnętrzny"
    ScopedValue.where(INFO, "Wewnętrzny").run(() -> {
        System.out.println(INFO.get()); // "Wewnętrzny"
    });
    System.out.println(INFO.get()); // "Zewnętrzny"
});

Wynik:

Zewnętrzny
Wewnętrzny
Zewnętrzny

To wygodne, gdy na przykład w obrębie jednego zadania trzeba tymczasowo nadpisać wartość kontekstu.

Scoped Values: typowe scenariusze użycia

  • Przekazywanie identyfikatora użytkownika lub żądania: aby logować działania albo sprawdzać uprawnienia.
  • Logowanie: automatyczne wstawianie kontekstu do logów.
  • Tracowanie: do debugowania i profilowania.
  • Parametry transakcji: na przykład poziom izolacji albo tryb pracy.
  • Dowolny „kontekst” widoczny tylko w obrębie jednego zadania (lub jego podzadań).

7. Inne nowe mechanizmy: Structured Concurrency

Structured Concurrency — to podejście, w którym powiązane zadania (np. podprocesy jednej operacji) są zarządzane jako całość: jeśli zadanie nadrzędne zakończyło się lub upadło, wszystkie zadania podrzędne są automatycznie anulowane. Zmniejsza to ryzyko „zapomnianych” lub „wiszących” wątków.

Przykład (bardzo schematycznie):

try (var scope = StructuredTaskScope.ShutdownOnFailure()) {
    Future<String> result1 = scope.fork(() -> fetchData1());
    Future<String> result2 = scope.fork(() -> fetchData2());

    scope.join(); // czekamy na zakończenie obu
    scope.throwIfFailed(); // jeśli choć jedna się wyłożyła — rzucamy wyjątek

    String combined = result1.resultNow() + result2.resultNow();
    System.out.println(combined);
}

Zalety:

  • Czystsze zarządzanie cyklem życia zadań.
  • Brak „wiszących” podprocesów.
  • Łatwiejsza obsługa błędów.

Structured Concurrency jest obecnie w trybie preview, ale już intensywnie się rozwija.

8. Praktyczne wskazówki i ograniczenia

Kiedy używać Scoped Values?

  • Zawsze, gdy trzeba przekazywać kontekst między zadaniami, zwłaszcza z wątkami wirtualnymi.
  • Jeśli wcześniej używałeś ThreadLocal — rozważ przejście na ScopedValue.

Kiedy wciąż potrzebny jest ThreadLocal?

  • W rzadkich przypadkach, gdy wątek żyje bardzo długo, a kontekst ma być „stały” przez cały jego czas życia (np. podczas pracy z legacy kodem).

Ograniczenia

  • Scoped Values nie można modyfikować po utworzeniu zakresu — to „tylko do odczytu”.
  • Scoped Values nie można używać poza zakresem: próba pobrania wartości poza zakresem rzuci wyjątek.
  • Nie używaj Scoped Values do przechowywania dużych obiektów — zakres powinien być lekki i szybki.

9. Typowe błędy przy użyciu Scoped Values

Błąd nr 1: próba pobrania wartości poza zakresem. Jeśli wywołasz USER.get() poza blokiem ScopedValue.where(...), dostaniesz wyjątek NoSuchElementException. Upewnij się, że odwołujesz się do wartości tylko wewnątrz zakresu.

Błąd nr 2: próba zmiany wartości w obrębie zakresu. Scoped Values to niezmienny pojemnik. Jeśli trzeba tymczasowo „nadpisać” wartość, utwórz zagnieżdżony zakres.

Błąd nr 3: używanie ThreadLocal i ScopedValue razem. Nie mieszaj tych mechanizmów bez wyraźnej potrzeby — może to prowadzić do pomyłek i błędów w kontekście.

Błąd nr 4: zapomnienie o opakowaniu logiki w blok run(). Jeśli napiszesz ScopedValue.where(USER, "Alice") bez .run(() -> { ... }), żaden zakres nie zostanie utworzony!

Błąd nr 5: próba użycia Scoped Value do długo żyjącej informacji globalnej. Do takich celów lepiej użyć zwykłych zmiennych albo ThreadLocal (jeśli to uzasadnione).

1
Zadanie
JAVA 25 SELF, poziom 57, lekcja 4
Niedostępne
Poufność danych w sieci korporacyjnej 🔒
Poufność danych w sieci korporacyjnej 🔒
1
Zadanie
JAVA 25 SELF, poziom 57, lekcja 4
Niedostępne
Śledzenie zamówień w usłudze dostaw dronami 📦
Śledzenie zamówień w usłudze dostaw dronami 📦
1
Ankieta/quiz
Wirtualne wątki, poziom 57, lekcja 4
Niedostępny
Wirtualne wątki
Wirtualne wątki
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION