Appearance
ThreadLocal and Virtual Threads in Java
This chapter covers two final multithreading topics: ThreadLocal, which gives every thread its own private copy of a variable, and virtual threads, which let the JVM run a huge number of threads on a few OS threads.
Part One: ThreadLocal
What Problem Does ThreadLocal Solve?
Imagine you are building a web server that handles many user requests at the same time. For every incoming request, you want to know which user sent it so that your business logic can make decisions based on their identity. You could pass the user information as a parameter through every method call in your application, but that quickly becomes a mess. Every method signature bloats up with an extra parameter that most methods do not even care about.
What you really want is a way to say: for the duration of this request, on this thread, the current user is Alice. Any code running on this thread should be able to ask "who is the current user?" and get Alice back, without me having to thread that information through every single method call.
That is precisely the problem ThreadLocal solves.
Each Thread Gets Its Own Copy
The core idea behind ThreadLocal is beautifully simple. When you declare a ThreadLocal<T> variable, every thread that touches that variable gets its own completely independent copy of the value stored inside it. Thread 1 setting its copy does not affect Thread 2's copy at all, even though both threads are using the exact same ThreadLocal object.
Think of it like a hotel with numbered lockers in the lobby. There is one locker number 42 in the building, but every guest gets their own version of locker 42 in their room. When you put something in locker 42, only you can retrieve it. The guest in the next room has their own locker 42 that is completely separate from yours.
Inside Java, every java.lang.Thread object carries a field called threadLocals, which is an instance of ThreadLocal.ThreadLocalMap. This is essentially a small hash map that belongs exclusively to that thread. When you call threadLocal.set(value), Java does the following steps automatically:
- It calls
Thread.currentThread()to find out which thread is executing right now. - It looks up that thread's internal
ThreadLocalMap. - It stores the value in that map, using the
ThreadLocalobject itself as the key.
When you call threadLocal.get(), Java reverses this process. It finds the current thread, looks up its private map, and retrieves the value stored under that ThreadLocal key. If no value has been set yet, it returns null by default (or whatever initialValue() returns if you override that method).
Using ThreadLocal: get, set, and remove
Here is a simple example showing all three core operations:
java
public class ThreadLocalDemo {
// One ThreadLocal object shared across the whole application
// But each thread gets its own independent value inside it
private static final ThreadLocal<String> currentUser = new ThreadLocal<>();
public static void main(String[] args) throws InterruptedException {
Thread thread1 = new Thread(() -> {
// Thread 1 stores its own value
currentUser.set("Alice");
System.out.println("Thread 1 sees: " + currentUser.get()); // Alice
// Always clean up when done
currentUser.remove();
});
Thread thread2 = new Thread(() -> {
// Thread 2 stores a completely different value
currentUser.set("Bob");
System.out.println("Thread 2 sees: " + currentUser.get()); // Bob
currentUser.remove();
});
thread1.start();
thread2.start();
thread1.join();
thread2.join();
}
}When Thread 1 prints its value, it sees "Alice". When Thread 2 prints its value, it sees "Bob". The two threads never interfere with each other even though they are using the exact same currentUser object. This is the power of ThreadLocal isolation.
The three methods you will use constantly are:
set(T value)stores a value for the current threadget()retrieves the value the current thread storedremove()deletes the current thread's value from the map
That remove() method deserves much more attention than most developers give it.
The Memory Leak and Data Pollution Problem with Thread Pools
In a real application, you almost never create raw Thread objects. Instead, you use a thread pool via ExecutorService. The whole point of a thread pool is to reuse threads across many tasks so you avoid the overhead of creating and destroying threads constantly.
But this reuse creates a serious problem with ThreadLocal.
Imagine a thread pool with two threads, and your application receives three user requests back to back. Request 1 is from Alice. The thread pool assigns Thread 1 to handle it. Your request handling code calls currentUser.set("Alice"). The request finishes. Thread 1 is returned to the pool, ready to handle the next task.
Now Request 2 arrives, this time from Bob. The thread pool assigns Thread 1 to handle it again (it is available and ready). Your code calls currentUser.get() to find out who the current user is. What does it return?
It returns "Alice".
Thread 1 is still holding Alice's data from the previous request because nobody ever called remove(). Thread 1 does not know it is working on a different request now. Its ThreadLocalMap still has the old value sitting there. Bob's request is now running with Alice's identity. This is a data pollution bug, and in a security sensitive application it is a serious vulnerability.
The problem goes beyond incorrect data. Every ThreadLocal value that is never removed occupies memory. In a long running server application, this adds up into a genuine memory leak.
The fix is non negotiable: always call remove() in a finally block, so it runs no matter what happens (even if an exception is thrown):
java
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class SafeThreadLocalWithPool {
private static final ThreadLocal<String> currentUser = new ThreadLocal<>();
public static void handleRequest(String userId) {
try {
// Step 1: Set the thread's context at the start of the task
currentUser.set(userId);
// Step 2: Do the actual work, which can call other methods freely
processRequest();
} finally {
// Step 3: MANDATORY cleanup before this thread returns to the pool
// Without this, the next task on this thread inherits our data
currentUser.remove();
}
}
private static void processRequest() {
// Any method can read the current user without needing it as a parameter
String user = currentUser.get();
System.out.println(Thread.currentThread().getName() + " is processing request for: " + user);
}
public static void main(String[] args) {
// A pool of only 2 threads handling 5 requests
ExecutorService pool = Executors.newFixedThreadPool(2);
String[] users = {"Alice", "Bob", "Charlie", "Diana", "Eve"};
for (String user : users) {
pool.submit(() -> handleRequest(user));
}
pool.shutdown();
}
}Run this and you will see each request correctly identifies its own user. The finally block guarantees that remove() runs before the thread goes back to the pool, so the next request always starts with a clean slate.
The golden rule is this: if you use ThreadLocal in an application that uses thread pools, which is every real Java web application, you must call remove() in a finally block. Every time. No exceptions.
Part Two: Virtual Threads vs Platform Threads
The Goal: Higher Throughput, Not Lower Latency
Remember this before anything else: virtual threads exist to raise throughput, not to cut latency. Throughput is how many tasks you finish per second. A single task does not run faster on a virtual thread; the gain is that far more tasks can be in progress at once, so if your server handled 100 requests per second before, it may handle many more now.
Platform Threads (Normal Threads)
Every thread we have used so far is a platform thread. When you call new Thread(task).start(), the JVM asks the operating system to create a native (OS) thread. The platform thread is a thin wrapper around that OS thread, so the mapping is one to one: 10 Java threads means 10 OS threads.
This has two disadvantages:
- Creation is slow. Creating a thread is a system call to the OS, and system calls are expensive. This is why we used thread pools in earlier chapters: create threads once, then reuse them. Pools reduce the cost but do not remove it, because a pool still creates threads when it grows toward its maximum size.
- Blocking wastes the OS thread. Suppose a thread runs a database query that takes 4 seconds. The Java thread waits, and because of the one-to-one mapping its OS thread also sits idle for those 4 seconds, even though it could have done other work.
Virtual Threads
A virtual thread is a lightweight thread managed entirely by the JVM. You can create a very large number of them (thousands, even millions), while the JVM keeps a small number of OS threads (called carrier threads) and decides how many it needs based on your machine.
The idea:
- A virtual thread that is ready to run gets mounted on a carrier thread and executes.
- When it starts waiting (a DB call, network I/O,
Thread.sleep()), the JVM unmounts it, breaking the link to the carrier. - The freed carrier immediately runs another virtual thread that is ready.
- When the wait finishes, the virtual thread is mounted again (possibly on a different carrier) and continues where it left off.
So there is no one-to-one mapping. You control how many virtual threads you create; the JVM controls the OS threads.
Virtual threads are backward compatible: everything you learned about multithreading (locks, executors, join, and so on) works with them. Only the way the JVM maps them onto OS threads is different.
Virtual threads were previewed in Java 19 and became a standard feature in Java 21 (JEP 444).
| Platform thread | Virtual thread | |
|---|---|---|
| Mapping to OS thread | One to one | Many virtual threads share a few carriers |
| Created by | OS (system call) | JVM (cheap Java object) |
| While blocked on I/O | Its OS thread sits idle | Unmounts; carrier runs other work |
| Purpose | General threading | Higher throughput for many waiting tasks |
Creating Virtual Threads
Way 1: Thread.ofVirtual()
java
public class SingleVirtualThread {
public static void main(String[] args) throws InterruptedException {
Thread vt = Thread.ofVirtual().start(() -> {
System.out.println("Is virtual: " + Thread.currentThread().isVirtual()); // true
});
vt.join();
}
}Way 2: an executor that creates one virtual thread per task
java
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class VirtualThreadExecutor {
public static void main(String[] args) {
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 1; i <= 10_000; i++) {
executor.submit(() -> {
Thread.sleep(1000); // simulated I/O wait
return null;
});
}
} // close() waits for all tasks to finish
System.out.println("All tasks completed.");
}
}All 10,000 tasks wait at the same time, so the program finishes in about one second. A fixed pool of 100 platform threads would need about 100 seconds for the same work.
Logging frameworks keep per-request fields in a ThreadLocal too: see MDC in structured logging.
Check yourself
- You create one
ThreadLocalobject and bothmainandthread1callset()on it. How many values are stored? - A pool of 5 threads runs one task that calls
set()withoutremove(), then 15 tasks that callget(). Why do some print a thread name instead ofnull? - Where should
remove()go so it always runs? - Do virtual threads make a single task run faster?
- Why is creating a platform thread expensive?
- What happens to a virtual thread's carrier when the virtual thread waits for a DB call?
- Name the two ways shown to create virtual threads.
Answers
- Two: each thread keeps its own value inside its own thread-local map, keyed by the same
ThreadLocalobject. - The pool reused the thread that ran the first task, and its value was never cleared, so later tasks on that thread see stale data.
- In a
finallyblock at the end of the task. - No. They raise throughput (tasks per second), not latency.
- It needs a system call to the OS to create a native thread, and platform threads map one to one to OS threads.
- The virtual thread is unmounted and the carrier runs another ready virtual thread.
Thread.ofVirtual().start(task)andExecutors.newVirtualThreadPerTaskExecutor()followed bysubmit(task).