Contents
- Basic Usage
- Web Request Context Pattern
- InheritableThreadLocal
- Memory Leak in Thread Pools
- ScopedValue — The Modern Alternative (Java 21+)
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.