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 | |
|
| 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).
GO TO FULL VERSION