ThreadLocal & Inheritable State
ThreadLocal stores per-thread values, every thread sees its own copy. Used for: request context, SimpleDateFormat (not thread-safe), DB connections per thread. Pitfalls: leaks in thread pools, doesn't inherit by default, virtual threads explode memory.
What ThreadLocal solves
Some objects aren't thread-safe (SimpleDateFormat), some are expensive to create (DB connections), some are inherently per-context (request ID for logging). ThreadLocal gives each thread its own copy without synchronization.
The classic use cases
- Request-scoped context, request ID, trace ID, authenticated user. Set in middleware, read anywhere.
- Per-thread caches of non-thread-safe objects,
SimpleDateFormat, regexMatcher. - Per-thread DB/HTTP connections, JDBC connection pool's thread-affinity model.
The thread-pool leak that bites everyone
ThreadLocal stores values on a Map keyed by Thread. In a thread pool, the same thread is reused for many tasks. If a task sets a ThreadLocal value and doesn't remove(), the next task on that thread sees the stale value.
// Task 1 on thread-1
ctx.set(new UserContext("alice"));
processRequest();
// (forgot to remove)
// Task 2 on thread-1 (same thread, reused!)
ctx.get(); // returns alice's context, BUG
Two consequences:
- Correctness bug, task 2 acts on task 1's context.
- Memory leak, alice's context stays in memory until the thread dies.
The fix: always wrap in try/finally with remove(). Or, in a framework, register a Filter/Interceptor that clears all known ThreadLocals at request boundary.
The virtual thread problem
Java 21 introduced virtual threads, millions per JVM. Each has its own ThreadLocal map. With 50 ThreadLocals and 1M virtual threads:
50 entries × 1M threads × ~100 bytes/entry ≈ 5 GB
Just for ThreadLocal storage. The original design assumed thousands of threads; now we have millions.
The modern answer: ScopedValue
ScopedValue (preview in Java 21-24, finalised in Java 25 via JEP 506) is the replacement:
- Lexically scoped, value bound for the duration of a block, auto-cleared.
- No leak risk, can't accumulate.
- Virtual-thread-friendly, designed for high thread counts.
- Inherits across structured tasks, works with StructuredTaskScope.
ScopedValue.where(REQUEST_ID, "abc").run(() -> {
// REQUEST_ID is set here
});
// REQUEST_ID is cleared here, guaranteed
Migration plan
For new code on Java 25+: use ScopedValue for request context (or the preview API on 21-24 with --enable-preview). For legacy code: keep ThreadLocal but always remove() in finally. For frameworks like Spring/MDC: their own clear-on-request-boundary mechanisms handle the lifecycle.
Primitives by language
- ThreadLocal / InheritableThreadLocal
- ThreadLocal.withInitial
- ScopedValue (preview in Java 21-24, finalised in Java 25 via JEP 506)
Implementation
Each thread sees its own copy. Set in middleware; read anywhere downstream without passing as parameter. The classic use for request-scoped data (auth user, request ID).
1 class RequestContext {
2 private static final ThreadLocal<String> requestId = new ThreadLocal<>();
3
4 public static void setRequestId(String id) { requestId.set(id); }
5 public static String getRequestId() { return requestId.get(); }
6 public static void clear() { requestId.remove(); }
7 }
8
9 // In middleware:
10 void handleRequest(Request req) {
11 RequestContext.setRequestId(req.id());
12 try {
13 processRequest(req);
14 } finally {
15 RequestContext.clear(); // ALWAYS clean up
16 }
17 }
18
19 // Anywhere in the call chain:
20 void log(String msg) {
21 System.out.println("[" + RequestContext.getRequestId() + "] " + msg);
22 }ThreadLocal.withInitial(supplier) provides a default value the first time get() is called per thread. Useful for per-thread caches and resources.
1 // Per-thread SimpleDateFormat (NOT thread-safe by default)
2 private static final ThreadLocal<SimpleDateFormat> formatter =
3 ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));
4
5 public String formatDate(Date d) {
6 return formatter.get().format(d); // each thread has its own formatter
7 }Regular ThreadLocal is per-thread; child threads start with NO inherited value. InheritableThreadLocal copies the parent's value at thread creation. Useful for tracing IDs that should propagate.
1 class TracingContext {
2 private static final InheritableThreadLocal<String> traceId =
3 new InheritableThreadLocal<>();
4
5 public static void setTraceId(String id) { traceId.set(id); }
6 public static String getTraceId() { return traceId.get(); }
7 }
8
9 // In a request handler:
10 TracingContext.setTraceId("abc-123");
11 Thread child = new Thread(() -> {
12 TracingContext.getTraceId(); // returns "abc-123", inherited
13 });
14 child.start();ThreadLocals in a thread pool DO NOT clear between tasks, the same thread is reused. If task 1 sets a value and doesn't remove(), task 2 sees task 1's stale value. Memory leak + correctness bug.
1 private static final ThreadLocal<UserContext> ctx = new ThreadLocal<>();
2
3 ExecutorService pool = Executors.newFixedThreadPool(8);
4
5 // BROKEN, values leak across tasks
6 pool.submit(() -> {
7 ctx.set(new UserContext("alice"));
8 processRequest();
9 // ↑ no ctx.remove(), next task on this thread sees alice's context
10 });
11
12 // FIXED, always clear in finally
13 pool.submit(() -> {
14 ctx.set(new UserContext("alice"));
15 try { processRequest(); }
16 finally { ctx.remove(); } // critical
17 });ScopedValue replaces ThreadLocal for new code. Values are scoped to a lexical block; auto-cleared at scope exit. No leak risk; works correctly with virtual threads. Preview API in Java 21-24, finalised in Java 25 (JEP 506).
1 // Preview API in 21-24, stable in 25
2 static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();
3
4 void handleRequest(Request req) {
5 ScopedValue.where(REQUEST_ID, req.id()).run(() -> {
6 processRequest(req); // REQUEST_ID is set inside this scope
7 });
8 // REQUEST_ID is cleared here automatically
9 }
10
11 void log(String msg) {
12 System.out.println("[" + REQUEST_ID.get() + "] " + msg);
13 }Key points
- •Each thread has its own value, isolated, no synchronization needed
- •Common uses: request-id for logging, per-thread DB connections, SimpleDateFormat
- •InheritableThreadLocal copies parent's value to child threads
- •Pitfall: thread pool reuses threads, ThreadLocal values persist across requests → leaks
- •ScopedValue is the modern replacement (preview in 21, finalised in Java 25): bounded scope, no leaks
Follow-up questions
▸Why is ThreadLocal a memory leak risk?
▸ThreadLocal vs InheritableThreadLocal vs ScopedValue?
▸Why are ThreadLocals problematic with virtual threads?
▸Where do logging frameworks use ThreadLocal?
Gotchas
- !ThreadLocal in a pool without remove() in finally → leak
- !InheritableThreadLocal copies AT CREATION, async dispatch doesn't propagate (need explicit handoff)
- !Virtual threads + many ThreadLocals → memory explosion (use ScopedValue)
- !ThreadLocal.set(null) is NOT the same as remove(), null entry stays in the map
- !MDC in async chains often loses the value, middleware must propagate or wrap