A ThreadLocal variable gives each thread its own independent value, so code running in one thread never sees or changes the value set by another thread. All threads share the same ThreadLocal object, but each get() returns the value of the thread that calls it.
We use ThreadLocal to carry per-request data, such as a request ID or the logged-in user, through many method calls without adding a parameter to each one. We also use it to give each thread its own copy of an object that is not thread safe.
The following example sets a value in the main thread and reads the same variable from a pool thread.
ThreadLocal<String> requestId = ThreadLocal.withInitial(() -> "none");
requestId.set("req-1");
CompletableFuture<String> fromPool = CompletableFuture.supplyAsync(requestId::get);
String inMain = requestId.get(); // "req-1"
String inPoolThread = fromPool.join(); // "none"
requestId.remove();
String afterRemove = requestId.get(); // "none"
Notice that the pool thread gets the initial value, because set() changed only the main thread’s copy, and remove() brings the main thread back to the initial value too. We look at how the class stores the values, two typical uses, the leak that thread pools cause, child threads and virtual threads, and ScopedValue, which replaces ThreadLocal for many cases in Java 25.
1. How Java ThreadLocal Keeps One Value per Thread
Every Thread object has a field named threadLocals that holds a small hash map of type ThreadLocalMap. The map’s keys are ThreadLocal objects and its values are the values set by that thread. A call to get() looks up the current thread’s map with the ThreadLocal as the key.

Since each thread reads only its own map, the values need no locking. In the map, the key is a weak reference to the ThreadLocal, whereas the value is a strong reference, which matters for the leak in section 4.
The ThreadLocal API is small. In real code, the variable is a private static final field, so one ThreadLocal serves all instances of the class.
| Method | What it does |
|---|---|
| ThreadLocal.withInitial(supplier) | Creates a variable whose first get() in each thread calls the supplier (Java 8+) |
| get() | Returns the current thread’s value, creating it from the initial value if needed |
| set(value) | Replaces the current thread’s value |
| remove() | Deletes the current thread’s entry, so the next get() starts from the initial value |
| initialValue() | Protected method that subclasses override; withInitial() replaces this older style |
2. Passing a Request ID Without Method Parameters
Say an order service writes a request ID into every log line, so support staff can find all lines of one failed checkout. The controller knows the ID, but the repository and pricing code five calls deeper do not. Passing the ID through every method signature clutters all of them.
The following example stores the ID in a ThreadLocal when the request starts and clears it in a finally block when the request ends. The placeOrder() method reads it without any parameter.
class RequestContext {
private static final ThreadLocal<String> REQUEST_ID = new ThreadLocal<>();
static void set(String id) {
REQUEST_ID.set(id);
}
static String get() {
return REQUEST_ID.get();
}
static void clear() {
REQUEST_ID.remove();
}
}
static String placeOrder(String item) {
return "[" + RequestContext.get() + "] order placed for " + item;
}
static String handle(String requestId, String item) {
RequestContext.set(requestId);
try {
return placeOrder(item);
} finally {
RequestContext.clear(); // the pool thread serves other requests next
}
}
ExecutorService web = Executors.newFixedThreadPool(2);
Future<String> request1 = web.submit(() -> handle("req-7", "book"));
Future<String> request2 = web.submit(() -> handle("req-8", "pen"));
String log1 = request1.get(); // "[req-7] order placed for book"
String log2 = request2.get(); // "[req-8] order placed for pen"
String leftover = web.submit(() -> RequestContext.get()).get(); // null
web.close();
Frameworks use the same technique. Logback’s Mapped Diagnostic Context keeps log fields in a ThreadLocal, and Spring keeps the current request and the security context in thread-bound holders. A servlet filter or a HandlerInterceptor is the usual place to set and clear such values.
3. One Copy per Thread of an Object That Is Not Thread Safe
Some JDK classes keep internal state between calls, so two threads must not use one instance at the same time. A MessageDigest is one of them. Creating a new instance for every call works but costs a lookup each time, whereas a shared instance needs a lock.
class Hashing {
private static final ThreadLocal<MessageDigest> SHA_256 = ThreadLocal.withInitial(Hashing::newDigest);
private static MessageDigest newDigest() {
try {
return MessageDigest.getInstance("SHA-256");
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException(e);
}
}
static String sha256(String text) {
byte[] hash = SHA_256.get().digest(text.getBytes(StandardCharsets.UTF_8)); // digest() also resets it
return HexFormat.of().formatHex(hash);
}
}
String prefix = Hashing.sha256("abc").substring(0, 8); // "ba7816bf"
Older articles show the same trick with SimpleDateFormat. In current code, we use DateTimeFormatter instead, which is immutable and safe to share, so it needs no ThreadLocal at all.
A per-thread cache helps only when threads live long and run many tasks, as in a fixed thread pool. With a virtual thread per task, each task gets a new thread, so every task creates its own copy and the cache saves nothing.
4. Stale Values and Memory Leaks in Thread Pools
A thread pool reuses its threads for many tasks, and a ThreadLocal value stays in the thread until someone removes it. In the following example, the first task sets a value and never removes it. A task like that leaves the value for the next task that runs on the same thread.
ThreadLocal<String> loggedInUser = new ThreadLocal<>();
ExecutorService singleThread = Executors.newSingleThreadExecutor();
Future<?> firstRequest = singleThread.submit(() -> loggedInUser.set("alice"));
firstRequest.get();
Future<String> secondRequest = singleThread.submit(() -> loggedInUser.get());
String wrongUser = secondRequest.get(); // "alice"
singleThread.close();
Every set() on a pooled thread needs a matching remove() in a finally block. Otherwise the second request runs as “alice”, which is a security bug, not only a logging problem.
The same entries also cause memory leaks. The map holds each value with a strong reference for as long as the thread lives, and pool threads live as long as the app. A value that is a large object, or an object whose class comes from a web app’s class loader, keeps that memory reachable. After a redeploy in an application server, such a value keeps the whole old class loader and all its classes in the heap and in Metaspace, since Java 8 removed the permanent generation (JEP 122).
The weak key does not prevent the leak. It lets the garbage collector clear the key when no code refers to the ThreadLocal anymore, but the value stays in the map until the thread happens to clean up stale entries during later calls.
5. Passing Values to Child Threads With InheritableThreadLocal
A plain ThreadLocal value is not visible in threads started by the current thread. The subclass InheritableThreadLocal copies the parent’s values into a new thread when that thread is created.
InheritableThreadLocal<String> tenant = new InheritableThreadLocal<>();
ThreadLocal<String> plainTenant = new ThreadLocal<>();
tenant.set("acme");
plainTenant.set("acme");
FutureTask<String> inherited = new FutureTask<>(tenant::get);
FutureTask<String> notInherited = new FutureTask<>(plainTenant::get);
Thread.ofPlatform().start(inherited);
Thread.ofPlatform().start(notInherited);
String childTenant = inherited.get(); // "acme"
String childPlain = notInherited.get(); // null
The copy happens only at thread creation. Pool threads are created once and reused, so tasks submitted to an existing pool do not receive the submitting thread’s values. Virtual threads inherit by default, and Thread.Builder.inheritInheritableThreadLocals(false) turns that off.
6. ThreadLocal and Virtual Threads
Virtual threads, final since Java 21 with JEP 444, support ThreadLocal in full, so existing libraries keep working. The trouble is scale. An app can run a million virtual threads, and each one that touches a ThreadLocal gets its own value, so a per-thread cache of a large object multiplies memory use.
For request data on virtual threads, ThreadLocal still works when we set and remove the value in the same task. For new code, the JDK offers a better fit, as the next section shows.
7. ScopedValue as the Modern Alternative
A ScopedValue binds a value for the duration of one method call and everything it calls, and unbinds it when the call returns. Scoped values are final in Java 25 with JEP 506. A ScopedValue has no set() method, so code down the stack can read the value but never change it, and nothing remains to clean up afterwards.
ScopedValue<String> currentUser = ScopedValue.newInstance();
String greeting = ScopedValue.where(currentUser, "alice").call(() -> "hello " + currentUser.get()); // "hello alice"
boolean boundAfter = currentUser.isBound(); // false
String fallback = currentUser.orElse("guest"); // "guest"
String unbound = currentUser.get(); // NoSuchElementException
We can see that the binding ends with the call(), so a later request on the same thread cannot see “alice”. In real code, currentUser is a private static final field, as with ThreadLocal. The scoped values guide covers nested bindings and sharing values with subtasks.
| ThreadLocal | ScopedValue | |
|---|---|---|
| Available since | Java 1.2 | Java 25 (final) |
| Change the value | set() at any time | No, rebind in a nested scope |
| Lifetime | Until remove() or the thread ends | Until the bound call returns |
| Cleanup needed | Yes, remove() in finally | No |
| Child threads | Only with InheritableThreadLocal, copied | Subtasks of structured concurrency (a preview API in Java 25), shared without copying |
| Good for | Per-thread caches, older frameworks | Request context, user, transaction data |
We keep ThreadLocal for per-thread caches on platform threads and for libraries that need a value that changes during the task. For one-way context such as the user or the request ID, ScopedValue is the better default on Java 25.
8. ThreadLocal FAQs
Cleanup, field modifiers and async code account for most doubts about ThreadLocal.
8.1. Is ThreadLocal thread safe?
Yes, as long as each thread uses only its own value. The ThreadLocal object itself is safe to share. If the stored value is an object that the thread hands to other threads, that object needs its own protection.
8.2. Should we call set(null) or remove()?
We call remove(). The call set(null) keeps the map entry with a null value, whereas remove() deletes the entry, so the next get() returns the initial value and the entry cannot leak.
8.3. Should a ThreadLocal field be static?
Yes, in most cases, because the value is per thread, not per object. A non-static field creates one ThreadLocal for each instance, so the same thread ends up with many entries for what is logically one value.
8.4. Does ThreadLocal work with CompletableFuture and parallel streams?
Not across threads. A task passed to CompletableFuture.supplyAsync() or a parallel stream runs on pool threads, which have their own values, as the first example shows. We pass the value as an argument or capture it in the lambda instead.
9. Conclusion
A ThreadLocal stores one value per thread in that thread’s own map, so threads never see each other’s values and no locking is needed. It fits request context that many methods need, and per-thread copies of objects that are not thread safe.
In thread pools, every set() needs a remove() in a finally block, or the next task sees stale data and memory leaks build up. On Java 25, ScopedValue is the safer choice for context that flows down a call, and the Java concurrency guide lists the other tools for sharing data between threads.
10. References
- ThreadLocal (Java SE 25 API)
- InheritableThreadLocal (Java SE 25 API)
- ScopedValue (Java SE 25 API)
- JEP 506, Scoped Values
- JEP 444, Virtual Threads
Happy Learning !!
The above code is still error . The two ThreadLocal variables are still in runnable and not shared !
Hi Lokesh,
Going thru “ThreadLocal” i found “InheritableThreadLocal” but never found and good examples on it. Can you please explain on this and if possible can you also enlighten the internal data structure of the ThreadLocal i.e “ThreadLocalMap” and “WeekReference”
InheritabelThreadLocal is used in the situation of Parent Thread-Child Threads resource sharing.
It can be used when Child Thread wishes to utilize the local variables of it’s parent thread.
Imagine a Process which forks no. of Child threads for concurrent processing and every individual child thread needs to mark it’s completion so that Parent Thread can track (e.g. Thread Processing Completion Count)
Thanks, nice post
Hi Lokesh,
Thank you for your interesting post.
you said in the introduction “all the threads share the same attributes that have been defined inside run() method”.
I think you wanted to say : all threads share the same attributes of the runnable object, because attributes defined inside run method are not sharable, they live in the stack and every thread has its own stack.
You are right. It is a mistake indeed. Corrected it. Thanks for pointing out.