Contents

ThreadLocal gives each thread its own independent copy of a variable — set() stores a value for the current thread and get() retrieves only that thread's copy, so no synchronization is needed. remove() is critical to call when the work is finished, especially in thread pools where threads are reused: without it, the value persists in the thread indefinitely, leaking memory and potentially exposing stale data from a previous task to the next one that runs on the same thread.

// Define a ThreadLocal — usually as a static field static final ThreadLocal<String> current = new ThreadLocal<>(); // Or with an initial value using withInitial() static final ThreadLocal<Integer> requestCount = ThreadLocal.withInitial(() -> 0); // Each thread gets its own copy Thread t1 = new Thread(() -> { current.set("user-alice"); System.out.println(Thread.currentThread().getName() + ": " + current.get()); current.remove(); // always remove when done! }); Thread t2 = new Thread(() -> { current.set("user-bob"); System.out.println(Thread.currentThread().getName() + ": " + current.get()); current.remove(); }); t1.start(); t2.start(); // Thread-0: user-alice // Thread-1: user-bob (independent copies — no synchronization needed) // get(), set(), remove() ThreadLocal<List<String>> log = ThreadLocal.withInitial(ArrayList::new); log.get().add("first"); log.get().add("second"); System.out.println(log.get()); // [first, second] log.remove(); // clears this thread's copy

The classic use case: store the current user/request context in a filter/interceptor, use it throughout the request processing stack, then clean up at the end.

// Request context holder public class RequestContext { private static final ThreadLocal<RequestContext> holder = new ThreadLocal<>(); private final String userId; private final String requestId; private final Locale locale; private RequestContext(String userId, String requestId, Locale locale) { this.userId = userId; this.requestId = requestId; this.locale = locale; } public static void set(String userId, String requestId, Locale locale) { holder.set(new RequestContext(userId, requestId, locale)); } public static RequestContext get() { RequestContext ctx = holder.get(); if (ctx == null) throw new IllegalStateException("No request context set"); return ctx; } public static void clear() { holder.remove(); } public String userId() { return userId; } public String requestId() { return requestId; } public Locale locale() { return locale; } } // Servlet filter sets and clears the context public class ContextFilter implements Filter { @Override public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException { try { RequestContext.set( extractUserId(req), UUID.randomUUID().toString(), req.getLocale()); chain.doFilter(req, res); } finally { RequestContext.clear(); // MUST clear in finally block } } } // Any service can access the context without parameter passing public class OrderService { public Order createOrder(OrderRequest request) { String userId = RequestContext.get().userId(); // no parameter needed String reqId = RequestContext.get().requestId(); // ... } }

InheritableThreadLocal copies the parent thread's value to child threads at creation time. Child threads start with the same value, but changes in child threads do not affect the parent, and vice versa after the initial copy.

static final InheritableThreadLocal<String> tenantId = new InheritableThreadLocal<>(); // Parent thread sets a value tenantId.set("acme-corp"); // Child thread inherits the parent's value Thread child = new Thread(() -> { System.out.println("Child sees: " + tenantId.get()); // "acme-corp" tenantId.set("child-tenant"); // only affects child System.out.println("Child after set: " + tenantId.get()); // "child-tenant" }); child.start(); child.join(); System.out.println("Parent still: " + tenantId.get()); // "acme-corp" // Override childValue() to transform the value for child threads static final InheritableThreadLocal<List<String>> callStack = new InheritableThreadLocal<>() { @Override protected List<String> childValue(List<String> parentValue) { return new ArrayList<>(parentValue); // deep copy for child } }; InheritableThreadLocal does NOT work correctly with thread pools — the thread is created once and reused, so the "parent" context at creation time is not the submitting task's context. Use ScopedValue (Java 21+) or explicit propagation instead.

When a thread pool reuses threads, a ThreadLocal value set during one task will still be present when the same thread processes a later task. This causes data leakage between tasks and can accumulate stale objects forever.

// DANGEROUS — leaks data between tasks in a thread pool ExecutorService pool = Executors.newFixedThreadPool(4); static final ThreadLocal<Connection> conn = new ThreadLocal<>(); pool.submit(() -> { conn.set(dataSource.getConnection()); doWork(); // Forgot conn.remove() — Connection stays in ThreadLocal forever! // The next task on this thread will see the stale connection! }); // SAFE — always use try/finally to call remove() pool.submit(() -> { conn.set(dataSource.getConnection()); try { doWork(); } finally { conn.get().close(); // release resource conn.remove(); // clear ThreadLocal — MANDATORY in thread pools } }); // Utility class for safe ThreadLocal usage class ThreadLocalScope<T> implements AutoCloseable { private final ThreadLocal<T> local; ThreadLocalScope(ThreadLocal<T> local, T value) { this.local = local; local.set(value); } @Override public void close() { local.remove(); } } // Usage with try-with-resources try (var scope = new ThreadLocalScope<>(currentUser, "alice")) { processRequest(); } // remove() called automatically

ScopedValue (JEP 446, preview in Java 21) is the preferred alternative to ThreadLocal for structured call-stack context — especially with virtual threads and structured concurrency. Values are immutable within a scope and do not need explicit cleanup.

// ScopedValue — immutable, bounded scope, no remove() needed static final ScopedValue<String> CURRENT_USER = ScopedValue.newInstance(); // Bind a value for the duration of a lambda ScopedValue.where(CURRENT_USER, "alice").run(() -> { System.out.println(CURRENT_USER.get()); // "alice" processOrder(); // CURRENT_USER.get() == "alice" anywhere in the call stack }); // After the lambda, CURRENT_USER is no longer bound — automatically // ScopedValue works correctly with virtual threads — no thread-pool leak risk // It also integrates with StructuredTaskScope — child tasks inherit the binding Prefer ScopedValue over ThreadLocal for new code using virtual threads or structured concurrency. ThreadLocal remains useful for mutable per-thread state and compatibility with existing frameworks.