Dlaczego Zakazałem ThreadLocal w Jądrze Exeris (i Co Go Zastąpiło)
W środowisku uruchomieniowym zero-copy zaprojektowanym dla gęstości 1-VT-per-Stream, ThreadLocal to zabójca wydajności. Oto analiza kryminalistyczna i jak JEP 506 Scoped Values zmieniło wszystko.
Kiedy zacząłem projektować Exeris Kernel — środowisko uruchomieniowe nowej generacji, zero-copy, zbudowane dla Java 26+ — ustaliłem jedno niepodlegające negocjacjom prawo architektoniczne: „Zero Zbędnych Obliczeń”.
W systemie zaprojektowanym do obsługi ekstremalnej gęstości poprzez mapowanie dokładnie jednego Wirtualnego Wątku na każdy strumień sieciowy (1-VT-per-Stream), każdy bajt pamięci i każdy cykl procesora musi być celowy.
Bardzo szybko jednak natrafiłem na ścianę dziedzictwa.
W standardowym ekosystemie Enterprise Java, gdy trzeba przekazać SecurityContext, TenantId lub TransactionID do warstwy bazy danych bez zaśmiecania dziesiątek sygnatur metod, sięga się po sprawdzone narzędzie: ThreadLocal. Przez ponad dwie dekady ThreadLocal był fundamentem magii frameworków Javy. Ale w erze Project Loom (JEP 444) i Structured Concurrency, ten stary znajomy staje się seryjnym mordercą wydajności.
Oto dlaczego wprowadziłem ścisły, ogólnokernelowy zakaz stosowania ThreadLocal w Exeris i jak adopcja JEP 506 (Scoped Values) całkowicie zmieniła sposób myślenia o wysokowydajnej architekturze.
Analiza Kryminalistyczna: 3 Grzechy ThreadLocal
Traktowanie Wirtualnych Wątków jak wątków OS-owych niweluje większość ich zalet skalowalności — zwłaszcza w zakresie propagacji kontekstu i zachowania alokacji. Kiedy łączymy ThreadLocal z wysoce współbieżną architekturą thread-per-request, wprowadzamy trzy krytyczne wady:
1. Spaghetti State (Niekontrolowana Mutowalność)
Każdy kod głęboko w stosie wywołań, który może odczytać ThreadLocal, może też wywołać na nim .set(). Jeśli zagnieżdżona biblioteka zmutuje SecurityContext w locie, odnalezienie kto to zmienił i kiedy jest koszmarem debugowania. Przepływ danych staje się całkowicie nieprzewidywalny.

Rysunek 1: Niekontrolowana mutowalność ThreadLocal kontra ścisłe gwarancje przepływu danych tylko do odczytu w leksykalnie ograniczonym ScopedValue.
2. Pułapka Wycieku Pamięci (Nieograniczony Czas Życia)
ThreadLocal przeżywa aż do śmierci wątku lub do czasu, kiedy ktoś jawnie wywoła .remove(). W starych pulach wątków, zapomnienie o czyszczeniu oznacza, że kontekst bezpieczeństwa przelewa się do żądania następnego użytkownika.
3. Podatek od Dziedziczenia (Zabójca RAM)
To jest cios śmiertelny. Aby dzielić kontekst z wątkami potomnymi, frameworki używają InheritableThreadLocal. Kiedy wątek nadrzędny tworzy dziecko, JVM musi zachłannie sklonować ThreadLocalMap rodzica. Zazwyczaj alokuje to od 32 do 128 bajtów na wpis na stercie, w zależności od współczynnika obciążenia i rozkładu kluczy.
Wyobraź sobie teraz pojedyncze żądanie HTTP, gdzie logika rozdziela się na 50 równoległych podzadań (Wirtualnych Wątków) pobierających dane. Właśnie wyzwoliłeś 50 kosztownych alokacji map. Pomnóż to przez 10 000 równoległych żądań, a Garbage Collector zatrzymuje aplikację tylko po to, by posprzątać bezużyteczne klony kontekstu. To staje się czystym podatkiem GC bez wartości biznesowej.

Rysunek 2: Kara za kopiowanie O(N) w InheritableThreadLocal w porównaniu z dziedziczeniem w czasie stałym O(1) wprowadzonym w JEP 506.
Brakujące Ogniwo: Niezgodność ze Structured Concurrency
Poza wydajnością, ThreadLocal jest fundamentalnie niezgodny ze Structured Concurrency. StructuredTaskScope opiera się na deterministycznym, drzewiastym wykonaniu, gdzie zadania potomne są ściśle powiązane z czasem życia swojego rodzica. ThreadLocal, będąc niedeterministycznym i w pełni mutowalnym na każdym poziomie drzewa, całkowicie łamie ten model.
Nie można zbudować niezawodnego, fail-fast drzewa współbieżności, jeśli dowolny węzeł-liść może po cichu mutować globalny stan gałęzi.
Dowód A: Rozwiązanie Zero-Waste (JEP 506)
Aby przeżyć miliony Wirtualnych Wątków, potrzebujemy mechanizmu, który jest niemutowalny, ograniczony czasowo i praktycznie darmowy w dziedziczeniu. Oto Scoped Values.
Zamiast globalnie mutowalnej zmiennej, ScopedValue definiuje Zakres Dynamiczny. Wiąże wartość z konkretnym blokiem kodu (i wszystkimi metodami wywoływanymi w jego wnętrzu). Gdy blok kończy działanie, wiązanie znika.
Tabela Porównawcza
| ThreadLocal | ScopedValue | |
|---|---|---|
| Mutowalność | Mutowalny (każdy może nadpisać) | Niemutowalny (tylko do odczytu dla wywołanych) |
| Czas życia | Nieograniczony (wymaga ręcznego czyszczenia) | Leksykalnie ograniczony (powiązany z blokiem .run()) |
| Koszt dziedziczenia | Kopiowanie pamięci O(N) | Dziedziczenie w czasie stałym O(1) z pomijalnym kosztem alokacji |
Dowód B: „Pokaż, Nie Mów” — Implementacja w Exeris
W Exeris Kernel propagacja kontekstu jest ściśle rozdzielona. Moduł Security uwierzytelnia, a moduł Persistence stosuje Row-Level Security. Nigdy nie rozmawiają bezpośrednio. Komunikują się wyłącznie przez „Niewidoczną Ścianę” za pomocą ScopedValue.

Rysunek 3: Propagacja kontekstu w Exeris Kernel. Moduły Security i Persistence pozostają całkowicie oddzielone, dzieląc tożsamość wyłącznie przez niemutowalny zakres dynamiczny.
Oto jak tożsamość jest wstrzykiwana przy bramce wejściowej. Zwróć uwagę na całkowity brak metod .set():
// 1. Dekodowanie tokenu bezpośrednio z pamięci off-heap (Zero-Alloc)
AuthenticationResult result = securityProvider.authenticate(tokenBuffer);
// 2. Otwarcie leksykalnie ograniczonego, niemutowalnego Zakresu Dynamicznego
// Uwaga: połączone wywołania .where() tworzą wydajne zagnieżdżone zakresy.
ScopedValue
.where(KernelProviders.PRINCIPAL_CONTEXT, result.principal())
.where(KernelProviders.STORAGE_CONTEXT, result.storage())
.run(() -> {
// Wewnątrz tego bloku kontekst jest bezpieczny.
// Zostanie odziedziczony przez każdy Wątek Wirtualny
// uruchomiony przez StructuredTaskScope.
dispatchRequest(request);
});
// 3. Zakres zamyka się automatycznie. Nie potrzeba .remove(). Zero wycieków.
Później, głęboko w module Persistence, TransactionOrchestrator musi znać Tenant ID, aby dołączyć go do zapytania SQL. Po prostu odpytuje aktywny zakres:
public class TransactionOrchestrator {
private static StorageContext resolveStorageContext() {
// Zero ThreadLocal, w pełni bezpieczny dla Wątków Wirtualnych (JEP 506)
// isBound() to sprawdzenie O(1)
if (KernelProviders.STORAGE_CONTEXT.isBound()) {
return KernelProviders.STORAGE_CONTEXT.get();
}
// Fallback do kontekstu systemowego bez alokowania obiektów
return ImmutableStorageContext.system();
}
// ... logika wykonania transakcji
}
Ponieważ ScopedValue jest niemutowalny, TransactionOrchestrator ma gwarancję przez leksykalne ograniczenie zakresu i niemutowalność, że odczytywany StorageContext to dokładnie ten ustawiony przez bramkę, nienaruszony przez żaden interceptor po drodze.
Zmiana Paradygmatu
Wyrywając ThreadLocal z jądra, wyeliminowaliśmy całą kategorię wycieków pamięci i presji GC. Gdy system uruchamia 1 000 000 Wirtualnych Wątków, różnica między „kopiowaniem mapy milion razy” a „udostępnianiem wskaźnika w czasie stałym” to różnica między crashem serwera a stabilną infrastrukturą.
Java 26 to nie jest po prostu „Java 8 z var”. Funkcje takie jak Project Loom, Panama (FFM) i Scoped Values wymagają fundamentalnej zmiany w sposobie projektowania systemów. Jeśli będziemy dalej budować frameworki używając wzorców z 2014 roku, nigdy nie odblokujemy prawdziwej wydajności nowoczesnego sprzętu.
Czy byłbyś skłonny zrefaktoryzować swoją aplikację, porzucając ThreadLocal i adoptując ScopedValue? Daj znać w komentarzach.
Poznaj Exeris Kernel
Architektura zero-alokacji opisana w tym artykule to nie tylko teoria — to działający kod. Exeris to open-core, post-kontenerowe jądro chmury zbudowane dla ekstremalnej gęstości. Jeśli masz dość pauz GC i chcesz zobaczyć jak natywne I/O, Panama FFM i orkiestracja Wirtualnych Wątków wyglądają w praktyce, zapoznaj się z Exeris Kernel:
🔗 Repozytorium GitHub: exeris-systems/exeris-kernel